From 1b0f95bcdc1b45456d3bf0a3bfae816131a6f5ea Mon Sep 17 00:00:00 2001 From: moss-grok Date: Sun, 11 Oct 2026 14:19:42 +0800 Subject: [PATCH 1/2] fix(loop): O13 recover thinking-only turns by lowering reasoning per provider --- src/core/loop/agent-loop-llm-call.ts | 10 +++ src/core/loop/agent-loop-post-llm.ts | 32 ++++++++- src/core/loop/agent-loop-response.ts | 17 ++++- src/core/loop/agent-loop-state.ts | 2 + src/core/loop/agent-loop.ts | 2 + test/agent-loop-post-llm.spec.mjs | 2 +- test/o13-thinking-only-recovery.spec.mjs | 91 ++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 test/o13-thinking-only-recovery.spec.mjs diff --git a/src/core/loop/agent-loop-llm-call.ts b/src/core/loop/agent-loop-llm-call.ts index 8c37fb28..424ede3b 100644 --- a/src/core/loop/agent-loop-llm-call.ts +++ b/src/core/loop/agent-loop-llm-call.ts @@ -141,6 +141,16 @@ export async function executeLlmTurn(params: ExecuteLlmTurnParams): Promise 0 ? '[System] Your previous turn produced only private reasoning and no tool call. ' + diff --git a/src/core/loop/agent-loop-response.ts b/src/core/loop/agent-loop-response.ts index bfaaac2c..56b34b00 100644 --- a/src/core/loop/agent-loop-response.ts +++ b/src/core/loop/agent-loop-response.ts @@ -16,7 +16,11 @@ import { shouldNudgeMissingToolInvocation, } from './agent-loop-assistant-turn.js'; import { buildNamedWebToolMatcher } from '../../prompts/plan-detection.js'; -import { decidePostLlmAction, nextThinkingOnlyRetryAttempts } from './agent-loop-post-llm.js'; +import { + THINKING_ONLY_RETRY_BUDGET, + decidePostLlmAction, + nextThinkingOnlyRetryAttempts, +} from './agent-loop-post-llm.js'; import { escalateOutputTokens, isOutputLimitStopReason, @@ -65,6 +69,9 @@ export interface ProcessLlmResponseParams { streamStopReason: StopReason | undefined; /** Gateway-reported model for this turn, recorded on the assistant message. */ responseModel?: string; + /** O13: provider id of the active model, for per-provider thinking-only recovery. */ + provider?: string; + modelId?: string; maxTurns: number; maxOutputContinuations: number; abortSignal: AbortSignal; @@ -357,6 +364,8 @@ export async function processLlmResponse( turns: state.turns, shouldNudge, abortAborted: abortSignal.aborted, + provider: params.provider, + modelId: params.modelId, }); state.postToolThinkingOnlyRetryAttempts = nextThinkingOnlyRetryAttempts( @@ -367,12 +376,16 @@ export async function processLlmResponse( switch (postLlmAction.kind) { case 'thinking_retry': state.pendingMessages = [buildCorrectionMessage(postLlmAction.systemText)]; + // O13: the retry must change the request, not just repeat it with a nudge. + state.reasoningOverrideNextTurn = postLlmAction.reasoningOverride; pushTurnEnd(); state.lastTurnEndMs = Date.now(); return { control: 'continue' }; case 'thinking_only_complete': - throw new Error('The model returned private reasoning twice without a visible answer.'); + throw new Error( + `The model returned private reasoning ${THINKING_ONLY_RETRY_BUDGET + 1} times in a row without a visible answer or tool call.` + ); case 'continuation': { state.outputContinuationCount++; diff --git a/src/core/loop/agent-loop-state.ts b/src/core/loop/agent-loop-state.ts index 013b3002..b909b724 100644 --- a/src/core/loop/agent-loop-state.ts +++ b/src/core/loop/agent-loop-state.ts @@ -46,6 +46,8 @@ export interface AgentLoopMutableState { /** Soft mid-run recovery after a red task_acceptance verdict (repair-loop discipline). */ taskRepairNudgeAttempts: number; postToolThinkingOnlyRetryAttempts: number; + /** O13: one-shot reasoning level for the request after a thinking-only turn. */ + reasoningOverrideNextTurn?: 'low' | 'off'; emptyResponseRetryAttempts: number; completionGateAttempts: number; postLimitToolFollowUpsUsed: number; diff --git a/src/core/loop/agent-loop.ts b/src/core/loop/agent-loop.ts index 4b8ebde4..eea6bb40 100644 --- a/src/core/loop/agent-loop.ts +++ b/src/core/loop/agent-loop.ts @@ -568,6 +568,8 @@ export function runAgentLoop( turnToolCalls = llmResult.toolCalls; const responseResult = await processLlmResponse({ + provider: modelDef.provider, + modelId: modelDef.id, state, runId, assistantContent: llmResult.assistantContent, diff --git a/test/agent-loop-post-llm.spec.mjs b/test/agent-loop-post-llm.spec.mjs index e7f5f463..a7441e1b 100644 --- a/test/agent-loop-post-llm.spec.mjs +++ b/test/agent-loop-post-llm.spec.mjs @@ -25,7 +25,7 @@ function thinkingOnly(attempts) { }); } -assert.equal(THINKING_ONLY_RETRY_BUDGET, 1); +assert.equal(THINKING_ONLY_RETRY_BUDGET, 2); // O13 const first = thinkingOnly(0); assert.equal(first.kind, 'thinking_retry'); diff --git a/test/o13-thinking-only-recovery.spec.mjs b/test/o13-thinking-only-recovery.spec.mjs new file mode 100644 index 00000000..dd14a82a --- /dev/null +++ b/test/o13-thinking-only-recovery.spec.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// O13: a thinking-only turn (glm-5.3 self run1) must be recovered, not abort the run. +// Simulates the model's responses at the decision layer: no live model needed. +import assert from 'node:assert/strict'; +import { + THINKING_ONLY_RETRY_BUDGET, + decidePostLlmAction, + nextThinkingOnlyRetryAttempts, + thinkingOnlyReasoningOverride, +} from '../dist/core/loop/agent-loop-post-llm.js'; + +function ctx(over) { + return { + hasThinkingOnly: true, + toolCallCount: 0, + postToolThinkingOnlyRetryAttempts: 0, + emptyResponseRetryAttempts: 0, + totalToolCalls: 7, + streamStopReason: 'stop', + outputContinuationCount: 0, + maxOutputContinuations: 2, + missingToolNudgeAttempts: 0, + finalText: '', + maxTurns: 50, + turns: 9, + shouldNudge: false, + abortAborted: false, + provider: 'openai-compatible', + modelId: 'glm-5.3', + ...over, + }; +} + +// Replays a scripted sequence of turn kinds; returns how the run ends. +function replay(script) { + let attempts = 0; + const overrides = []; + for (const turn of script) { + const action = decidePostLlmAction( + ctx({ + hasThinkingOnly: turn === 'thinking', + toolCallCount: turn === 'tool' ? 1 : 0, + finalText: turn === 'answer' ? 'done' : '', + postToolThinkingOnlyRetryAttempts: attempts, + }) + ); + attempts = nextThinkingOnlyRetryAttempts(action, attempts); + if (action.kind === 'thinking_retry') overrides.push(action.reasoningOverride); + if (action.kind === 'thinking_only_complete') return { aborted: true, overrides }; + if (action.kind === 'steering_or_complete') return { aborted: false, overrides }; + } + return { aborted: false, overrides }; +} + +// run1 shape: two consecutive thinking-only turns. Before O13 this aborted. +const run1 = replay(['tool', 'thinking', 'thinking', 'tool', 'answer']); +assert.equal(run1.aborted, false, 'two thinking-only turns in a row must not abort'); +assert.deepEqual( + run1.overrides, + ['low', 'off'], + 'glm: first retry lowers reasoning, second turns it off' +); + +// Acceptance proxy: "3 runs" with thinking-only bursts at different points. +for (const script of [ + ['thinking', 'answer'], + ['tool', 'thinking', 'tool', 'thinking', 'thinking', 'answer'], + ['tool', 'tool', 'thinking', 'thinking', 'answer'], +]) { + assert.equal(replay(script).aborted, false, `aborted on ${script.join(',')}`); +} + +// Counter resets after a productive turn. +assert.equal(nextThinkingOnlyRetryAttempts({ kind: 'tool_execute' }, 2), 0); + +// Bounded: a model that never acts still stops (no infinite loop). +const stuck = replay(Array(THINKING_ONLY_RETRY_BUDGET + 2).fill('thinking')); +assert.equal(stuck.aborted, true); + +// Per provider: a model that cannot turn reasoning off only gets 'low'. +assert.equal(thinkingOnlyReasoningOverride('anthropic', 1, 'claude-sonnet-4-5'), 'low'); +assert.equal(thinkingOnlyReasoningOverride('openai-compatible', 1, 'glm-5.3'), 'off'); +assert.equal(thinkingOnlyReasoningOverride('deepseek', 1, 'deepseek-reasoner'), 'off'); +assert.equal(thinkingOnlyReasoningOverride('anyone', 0, 'glm-5.3'), 'low'); + +// The nudge text still steers toward a tool call mid-task. +const first = decidePostLlmAction(ctx({})); +assert.equal(first.kind, 'thinking_retry'); +assert.match(first.systemText, /call the next tool/); + +console.log('o13-thinking-only-recovery: ok'); From 749835ab541e25fc782303866d6ba805a0290925 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Sun, 11 Oct 2026 16:19:33 +0800 Subject: [PATCH 2/2] test(o13): align single-turn thinking-only bound with retry budget 2 --- test/single-turn-completion.spec.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/single-turn-completion.spec.mjs b/test/single-turn-completion.spec.mjs index f97915db..26ceac87 100644 --- a/test/single-turn-completion.spec.mjs +++ b/test/single-turn-completion.spec.mjs @@ -77,10 +77,10 @@ const thinkingAgent = new MossAgent({ await assert.rejects(thinkingAgent.chat('thinking-only', 'reply visibly'), /visible answer/i); assert.equal( thinkingCalls, - 2, - 'thinking-only output gets one corrective retry, then fails honestly' + 3, + 'thinking-only output gets two corrective retries, then fails honestly' ); -console.log('[PASS] thinking-only output is bounded to one corrective retry'); +console.log('[PASS] thinking-only output is bounded to two corrective retries'); let emptyCalls = 0; const emptyProvider = {