forked from NoeFabris/opencode-antigravity-auth
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy paththinking-recovery.ts
More file actions
395 lines (341 loc) · 12.2 KB
/
Copy paththinking-recovery.ts
File metadata and controls
395 lines (341 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
/**
* Thinking Recovery Module
*
* Minimal implementation for recovering from corrupted thinking state.
* When Claude's conversation history gets corrupted (thinking blocks stripped/malformed),
* this module provides a "last resort" recovery by closing the current turn and starting fresh.
*
* Philosophy: "Let it crash and start again" - Instead of trying to fix corrupted state,
* we abandon the corrupted turn and let Claude generate fresh thinking.
*/
// ============================================================================
// TYPES
// ============================================================================
/**
* Conversation state for thinking mode analysis
*/
export interface ConversationState {
/** True if we're in an incomplete tool use loop (ends with functionResponse) */
inToolLoop: boolean;
/** Index of first model message in current turn */
turnStartIdx: number;
/** Whether the TURN started with thinking */
turnHasThinking: boolean;
/** Index of last model message */
lastModelIdx: number;
/** Whether last model msg has thinking */
lastModelHasThinking: boolean;
/** Whether last model msg has tool calls */
lastModelHasToolCalls: boolean;
}
// ============================================================================
// DETECTION HELPERS
// ============================================================================
/**
* Checks if a message part is a thinking/reasoning block.
*/
function isThinkingPart(part: any): boolean {
if (!part || typeof part !== "object") return false;
return (
part.thought === true ||
part.type === "thinking" ||
part.type === "redacted_thinking"
);
}
/**
* Checks if a message part is a function response (tool result).
*/
function isFunctionResponsePart(part: any): boolean {
return part && typeof part === "object" && "functionResponse" in part;
}
/**
* Checks if a message part is a function call.
*/
function isFunctionCallPart(part: any): boolean {
return part && typeof part === "object" && "functionCall" in part;
}
/**
* Checks if a message is a tool result container (user role with functionResponse).
*/
function isToolResultMessage(msg: any): boolean {
if (!msg || msg.role !== "user") return false;
const parts = msg.parts || [];
return parts.some(isFunctionResponsePart);
}
/**
* Checks if a message contains thinking/reasoning content.
*/
function messageHasThinking(msg: any): boolean {
if (!msg || typeof msg !== "object") return false;
// Gemini format: parts array
if (Array.isArray(msg.parts)) {
return msg.parts.some(isThinkingPart);
}
// Anthropic format: content array
if (Array.isArray(msg.content)) {
return msg.content.some(
(block: any) =>
block?.type === "thinking" || block?.type === "redacted_thinking",
);
}
return false;
}
/**
* Checks if a message contains tool calls.
*/
function messageHasToolCalls(msg: any): boolean {
if (!msg || typeof msg !== "object") return false;
// Gemini format: parts array with functionCall
if (Array.isArray(msg.parts)) {
return msg.parts.some(isFunctionCallPart);
}
// Anthropic format: content array with tool_use
if (Array.isArray(msg.content)) {
return msg.content.some((block: any) => block?.type === "tool_use");
}
return false;
}
// ============================================================================
// CONVERSATION STATE ANALYSIS
// ============================================================================
/**
* Analyzes conversation state to detect tool use loops and thinking mode issues.
*
* Key insight: A "turn" can span multiple assistant messages in a tool-use loop.
* We need to find the TURN START (first assistant message after last real user message)
* and check if THAT message had thinking, not just the last assistant message.
*/
export function analyzeConversationState(contents: any[]): ConversationState {
const state: ConversationState = {
inToolLoop: false,
turnStartIdx: -1,
turnHasThinking: false,
lastModelIdx: -1,
lastModelHasThinking: false,
lastModelHasToolCalls: false,
};
if (!Array.isArray(contents) || contents.length === 0) {
return state;
}
// First pass: Find the last "real" user message (not a tool result)
let lastRealUserIdx = -1;
for (let i = 0; i < contents.length; i++) {
const msg = contents[i];
if (msg?.role === "user" && !isToolResultMessage(msg)) {
lastRealUserIdx = i;
}
}
// Second pass: Analyze conversation and find turn boundaries
for (let i = 0; i < contents.length; i++) {
const msg = contents[i];
const role = msg?.role;
if (role === "model" || role === "assistant") {
const hasThinking = messageHasThinking(msg);
const hasToolCalls = messageHasToolCalls(msg);
// Track if this is the turn start
if (i > lastRealUserIdx && state.turnStartIdx === -1) {
state.turnStartIdx = i;
state.turnHasThinking = hasThinking;
}
state.lastModelIdx = i;
state.lastModelHasToolCalls = hasToolCalls;
state.lastModelHasThinking = hasThinking;
}
}
// Determine if we're in a tool loop
// We're in a tool loop if the conversation ends with a tool result
if (contents.length > 0) {
const lastMsg = contents[contents.length - 1];
if (lastMsg?.role === "user" && isToolResultMessage(lastMsg)) {
state.inToolLoop = true;
}
}
return state;
}
// ============================================================================
// RECOVERY FUNCTIONS
// ============================================================================
/**
* Strips all thinking blocks from messages.
* Used before injecting synthetic messages to avoid invalid thinking patterns.
*/
function stripAllThinkingBlocks(contents: any[]): any[] {
return contents.map((content) => {
if (!content || typeof content !== "object") return content;
// Handle Gemini-style parts
if (Array.isArray(content.parts)) {
const filteredParts = content.parts.filter(
(part: any) => !isThinkingPart(part),
);
// Keep at least one part to avoid empty messages
if (filteredParts.length === 0 && content.parts.length > 0) {
return content;
}
return { ...content, parts: filteredParts };
}
// Handle Anthropic-style content
if (Array.isArray(content.content)) {
const filteredContent = content.content.filter(
(block: any) =>
block?.type !== "thinking" && block?.type !== "redacted_thinking",
);
if (filteredContent.length === 0 && content.content.length > 0) {
return content;
}
return { ...content, content: filteredContent };
}
return content;
});
}
/**
* Counts tool results at the end of the conversation.
*/
function countTrailingToolResults(contents: any[]): number {
let count = 0;
for (let i = contents.length - 1; i >= 0; i--) {
const msg = contents[i];
if (msg?.role === "user") {
const parts = msg.parts || [];
const functionResponses = parts.filter(isFunctionResponsePart);
if (functionResponses.length > 0) {
count += functionResponses.length;
} else {
break; // Real user message, stop counting
}
} else if (msg?.role === "model" || msg?.role === "assistant") {
break; // Stop at the model that made the tool calls
}
}
return count;
}
/**
* Closes an incomplete tool loop by injecting synthetic messages to start a new turn.
*
* This is the "let it crash and start again" recovery mechanism.
*
* When we detect:
* - We're in a tool loop (conversation ends with functionResponse)
* - The tool call was made WITHOUT thinking (thinking was stripped/corrupted)
* - We NOW want to enable thinking
*
* Instead of trying to fix the corrupted state, we:
* 1. Strip ALL thinking blocks (removes any corrupted ones)
* 2. Add synthetic MODEL message to complete the non-thinking turn
* 3. Add synthetic USER message to start a NEW turn
*
* This allows Claude to generate fresh thinking for the new turn.
*/
export function closeToolLoopForThinking(contents: any[]): any[] {
// Strip any old/corrupted thinking first
const strippedContents = stripAllThinkingBlocks(contents);
// Count tool results from the end of the conversation
const toolResultCount = countTrailingToolResults(strippedContents);
// Build synthetic model message content based on tool count
let syntheticModelContent: string;
if (toolResultCount === 0) {
syntheticModelContent = "[Processing previous context.]";
} else if (toolResultCount === 1) {
syntheticModelContent = "[Tool execution completed.]";
} else {
syntheticModelContent = `[${toolResultCount} tool executions completed.]`;
}
// Step 1: Inject synthetic MODEL message to complete the non-thinking turn
const syntheticModel = {
role: "model",
parts: [{ text: syntheticModelContent }],
};
// Step 2: Inject synthetic USER message to start a NEW turn
const syntheticUser = {
role: "user",
parts: [{ text: "[Continue]" }],
};
return [...strippedContents, syntheticModel, syntheticUser];
}
/**
* Checks if conversation state requires tool loop closure for thinking recovery.
*
* Returns true if:
* - We're in a tool loop (state.inToolLoop)
* - The turn didn't start with thinking (state.turnHasThinking === false)
*
* This is the trigger for the "let it crash and start again" recovery.
*/
export function needsThinkingRecovery(state: ConversationState): boolean {
return state.inToolLoop && !state.turnHasThinking;
}
// ============================================================================
// COMPACTED THINKING TURN DETECTION (Ported from LLM-API-Key-Proxy)
// ============================================================================
/**
* Detects if a message looks like it was compacted from a thinking-enabled turn.
*
* This is a heuristic to distinguish between:
* - "Never had thinking" (model didn't use thinking mode)
* - "Thinking was stripped" (context compaction removed thinking blocks)
*
* Port of LLM-API-Key-Proxy's _looks_like_compacted_thinking_turn()
*
* Heuristics:
* 1. Has functionCall parts (typical thinking flow produces tool calls)
* 2. No thinking parts (thought: true)
* 3. No text content before functionCall (thinking responses usually have text)
*
* @param msg - A single message from the conversation
* @returns true if the message looks like thinking was stripped
*/
export function looksLikeCompactedThinkingTurn(msg: any): boolean {
if (!msg || typeof msg !== "object") return false;
const parts = msg.parts || [];
if (parts.length === 0) return false;
// Check if message has function calls
const hasFunctionCall = parts.some(
(p: any) => p && typeof p === "object" && p.functionCall,
);
if (!hasFunctionCall) return false;
// Check for thinking blocks
const hasThinking = parts.some(
(p: any) =>
p &&
typeof p === "object" &&
(p.thought === true || p.type === "thinking" || p.type === "redacted_thinking"),
);
if (hasThinking) return false;
// Check for text content (not thinking)
const hasTextBeforeFunctionCall = parts.some((p: any, idx: number) => {
if (!p || typeof p !== "object") return false;
// Only check parts before the first functionCall
const firstFuncIdx = parts.findIndex(
(fp: any) => fp && typeof fp === "object" && fp.functionCall,
);
if (idx >= firstFuncIdx) return false;
// Check for non-thinking text
return (
"text" in p &&
typeof p.text === "string" &&
p.text.trim().length > 0 &&
!p.thought
);
});
// If we have functionCall but no text before it, likely compacted
return !hasTextBeforeFunctionCall;
}
/**
* Checks if any message in the current turn looks like it was compacted.
*
* @param contents - Full conversation contents
* @param turnStartIdx - Index of the first model message in current turn
* @returns true if any model message in the turn looks compacted
*/
export function hasPossibleCompactedThinking(
contents: any[],
turnStartIdx: number,
): boolean {
if (!Array.isArray(contents) || turnStartIdx < 0) return false;
for (let i = turnStartIdx; i < contents.length; i++) {
const msg = contents[i];
if (msg?.role === "model" && looksLikeCompactedThinkingTurn(msg)) {
return true;
}
}
return false;
}