Skip to content

Commit 787f348

Browse files
committed
fix: handle empty Gemini responses and reasoning loops
1 parent 483e70c commit 787f348

File tree

2 files changed

+44
-8
lines changed

2 files changed

+44
-8
lines changed

src/api/providers/gemini.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,17 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
199199
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
200200
let pendingGroundingMetadata: GroundingMetadata | undefined
201201
let finalResponse: { responseId?: string } | undefined
202+
let finishReason: string | undefined
202203

203204
let toolCallCounter = 0
205+
let hasContent = false
206+
let hasReasoning = false
204207

205208
for await (const chunk of result) {
206209
// Track the final structured response (per SDK pattern: candidate.finishReason)
207210
if (chunk.candidates && chunk.candidates[0]?.finishReason) {
208211
finalResponse = chunk as { responseId?: string }
212+
finishReason = chunk.candidates[0].finishReason
209213
}
210214
// Process candidates and their parts to separate thoughts from content
211215
if (chunk.candidates && chunk.candidates.length > 0) {
@@ -233,9 +237,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
233237
if (part.thought) {
234238
// This is a thinking/reasoning part
235239
if (part.text) {
240+
hasReasoning = true
236241
yield { type: "reasoning", text: part.text }
237242
}
238243
} else if (part.functionCall) {
244+
hasContent = true
239245
// Gemini sends complete function calls in a single chunk
240246
// Emit as partial chunks for consistent handling with NativeToolCallParser
241247
const callId = `${part.functionCall.name}-${toolCallCounter}`
@@ -263,6 +269,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
263269
} else {
264270
// This is regular content
265271
if (part.text) {
272+
hasContent = true
266273
yield { type: "text", text: part.text }
267274
}
268275
}
@@ -272,6 +279,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
272279

273280
// Fallback to the original text property if no candidates structure
274281
else if (chunk.text) {
282+
hasContent = true
275283
yield { type: "text", text: chunk.text }
276284
}
277285

@@ -280,6 +288,21 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
280288
}
281289
}
282290

291+
// If we had reasoning but no content, emit a placeholder text to prevent "Empty assistant response" errors.
292+
// This typically happens when the model hits max output tokens while reasoning.
293+
if (hasReasoning && !hasContent) {
294+
let message = "(Thinking complete, but no output was generated.)"
295+
if (finishReason === "MAX_TOKENS") {
296+
message = "(Thinking complete, but output was truncated due to token limit.)"
297+
} else if (finishReason === "SAFETY") {
298+
message = "(Thinking complete, but output was blocked due to safety settings.)"
299+
} else if (finishReason === "RECITATION") {
300+
message = "(Thinking complete, but output was blocked due to recitation check.)"
301+
}
302+
303+
yield { type: "text", text: message }
304+
}
305+
283306
if (finalResponse?.responseId) {
284307
// Capture responseId so Task.addToApiConversationHistory can store it
285308
// alongside the assistant message in api_history.json.

src/api/transform/gemini-format.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,11 @@ export function convertAnthropicContentToGemini(
4747
return [{ text: content }]
4848
}
4949

50-
return content.flatMap((block): Part | Part[] => {
50+
const parts = content.flatMap((block): Part | Part[] => {
5151
// Handle thoughtSignature blocks first
5252
if (isThoughtSignatureContentBlock(block)) {
53-
if (includeThoughtSignatures && typeof block.thoughtSignature === "string") {
54-
// The Google GenAI SDK currently exposes thoughtSignature as an
55-
// extension field on Part; model it structurally without widening
56-
// the upstream type.
57-
return { thoughtSignature: block.thoughtSignature } as Part
58-
}
59-
// Explicitly omit thoughtSignature when not including it.
53+
// We process thought signatures globally and attach them to the relevant parts
54+
// or create a placeholder part if no other content exists.
6055
return []
6156
}
6257

@@ -135,6 +130,24 @@ export function convertAnthropicContentToGemini(
135130
return []
136131
}
137132
})
133+
134+
// Post-processing: Ensure thought signature is attached if required
135+
if (includeThoughtSignatures && activeThoughtSignature) {
136+
const hasSignature = parts.some((p) => "thoughtSignature" in p)
137+
138+
if (!hasSignature) {
139+
if (parts.length > 0) {
140+
// Attach to the first part (usually text)
141+
// We cast to any to allow adding the property
142+
;(parts[0] as any).thoughtSignature = activeThoughtSignature
143+
} else {
144+
// Create a placeholder part if no other content exists
145+
parts.push({ text: "", thoughtSignature: activeThoughtSignature } as any as Part)
146+
}
147+
}
148+
}
149+
150+
return parts
138151
}
139152

140153
export function convertAnthropicMessageToGemini(

0 commit comments

Comments
 (0)