Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/core/loop/agent-loop-llm-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ export async function executeLlmTurn(params: ExecuteLlmTurnParams): Promise<Exec
effectiveReasoning = 'high';
}
}
// O13: after a thinking-only turn, the next request runs with less (or no)
// reasoning. One-shot: cleared as soon as it is consumed.
if (
state.reasoningOverrideNextTurn &&
effectiveReasoning !== undefined &&
effectiveReasoning !== null
) {
effectiveReasoning = state.reasoningOverrideNextTurn;
}
state.reasoningOverrideNextTurn = undefined;
try {
const llmTurn = await runAgentLoopLlmTurn({
requestModel,
Expand Down
32 changes: 30 additions & 2 deletions src/core/loop/agent-loop-post-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isNudgeDisabled } from './nudges/disable.js';
export type OutputLimitContinuationMode = 'text' | 'thinking' | 'tool';

export type PostLlmAction =
| { kind: 'thinking_retry'; systemText: string }
| { kind: 'thinking_retry'; systemText: string; reasoningOverride?: 'low' | 'off' }
| { kind: 'thinking_only_complete' }
| { kind: 'continuation'; systemText: string; mode: OutputLimitContinuationMode }
| { kind: 'output_limit_exhausted' }
Expand All @@ -31,10 +31,32 @@ export interface PostLlmContext {
turns: number;
shouldNudge: boolean;
abortAborted: boolean;
/** Provider id of the active model (O13: per-provider thinking-only recovery). */
provider?: string;
modelId?: string;
}

/** Consecutive reasoning-only turns that get another chance before the run stops. */
export const THINKING_ONLY_RETRY_BUDGET = 1;
export const THINKING_ONLY_RETRY_BUDGET = 2;

/**
* O13: how the next request changes after a reasoning-only turn.
* Attempt 0 lowers reasoning to 'low'; attempt 1 turns it off where the
* provider supports that (glm/zai, deepseek, qwen thinking toggles). Providers
* whose models cannot disable reasoning keep the text nudge only.
*/
// Matched against provider id and model id: glm is usually served through an
// OpenAI-compatible gateway, so the provider id alone does not identify it.
const REASONING_OFF_RE = /(^|[/:_-])(zai|zhipu|glm|deepseek|qwen|dashscope|kimi|moonshot)/i;
export function thinkingOnlyReasoningOverride(
provider: string | undefined,
attempt: number,
modelId?: string
): 'low' | 'off' {
if (attempt <= 0) return 'low';
const key = `${provider ?? ''}/${modelId ?? ''}`;
return REASONING_OFF_RE.test(key) ? 'off' : 'low';
}

export function nextThinkingOnlyRetryAttempts(action: PostLlmAction, current: number): number {
if (action.kind === 'thinking_retry') return current + 1;
Expand Down Expand Up @@ -88,8 +110,14 @@ export function decidePostLlmAction(ctx: PostLlmContext): PostLlmAction {
ctx.turns < ctx.maxTurns &&
!ctx.abortAborted
) {
const reasoningOverride = thinkingOnlyReasoningOverride(
ctx.provider,
ctx.postToolThinkingOnlyRetryAttempts,
ctx.modelId
);
return {
kind: 'thinking_retry',
reasoningOverride,
systemText:
ctx.totalToolCalls > 0
? '[System] Your previous turn produced only private reasoning and no tool call. ' +
Expand Down
17 changes: 15 additions & 2 deletions src/core/loop/agent-loop-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -357,6 +364,8 @@ export async function processLlmResponse(
turns: state.turns,
shouldNudge,
abortAborted: abortSignal.aborted,
provider: params.provider,
modelId: params.modelId,
});

state.postToolThinkingOnlyRetryAttempts = nextThinkingOnlyRetryAttempts(
Expand All @@ -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++;
Expand Down
2 changes: 2 additions & 0 deletions src/core/loop/agent-loop-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/core/loop/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion test/agent-loop-post-llm.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
91 changes: 91 additions & 0 deletions test/o13-thinking-only-recovery.spec.mjs
Original file line number Diff line number Diff line change
@@ -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');
6 changes: 3 additions & 3 deletions test/single-turn-completion.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading