Skip to content

Commit 3c221dc

Browse files
committed
fix: ensure message history starts with a user turn for Gemini compatibility
1 parent 64631a3 commit 3c221dc

File tree

4 files changed

+48
-3
lines changed

4 files changed

+48
-3
lines changed

crates/openfang-channels/src/line.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ impl LineAdapter {
108108
diff |= a ^ b;
109109
}
110110
if diff != 0 {
111-
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
111+
let computed = base64::engine::general_purpose::STANDARD.encode(result);
112112
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
113113
let comp_redacted = format!(
114114
"{}...{}",

crates/openfang-runtime/src/agent_loop.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,10 @@ pub async fn run_agent_loop(
342342
// pair across the cut boundary, leaving orphaned blocks that cause the LLM
343343
// to return empty responses (input_tokens=0).
344344
messages = crate::session_repair::validate_and_repair(&messages);
345+
// Ensure history starts with a user turn: trimming may have left an
346+
// assistant turn at position 0, which strict providers (e.g. Gemini)
347+
// reject with INVALID_ARGUMENT on function-call turns.
348+
messages = crate::session_repair::ensure_starts_with_user(messages);
345349
}
346350

347351
// Use autonomous config max_iterations if set, else default
@@ -381,6 +385,8 @@ pub async fn run_agent_loop(
381385
// which may have broken assistant→tool ordering invariants.
382386
if recovery != RecoveryStage::None {
383387
messages = crate::session_repair::validate_and_repair(&messages);
388+
// Ensure history starts with a user turn after overflow recovery.
389+
messages = crate::session_repair::ensure_starts_with_user(messages);
384390
}
385391

386392
// Context guard: compact oversized tool results before LLM call
@@ -1504,6 +1510,10 @@ pub async fn run_agent_loop_streaming(
15041510
// pair across the cut boundary, leaving orphaned blocks that cause the LLM
15051511
// to return empty responses (input_tokens=0).
15061512
messages = crate::session_repair::validate_and_repair(&messages);
1513+
// Ensure history starts with a user turn: trimming may have left an
1514+
// assistant turn at position 0, which strict providers (e.g. Gemini)
1515+
// reject with INVALID_ARGUMENT on function-call turns.
1516+
messages = crate::session_repair::ensure_starts_with_user(messages);
15071517
}
15081518

15091519
// Use autonomous config max_iterations if set, else default
@@ -1561,6 +1571,8 @@ pub async fn run_agent_loop_streaming(
15611571
// be followed by tool messages" errors after context overflow recovery.)
15621572
if recovery != RecoveryStage::None {
15631573
messages = crate::session_repair::validate_and_repair(&messages);
1574+
// Ensure history starts with a user turn after overflow recovery.
1575+
messages = crate::session_repair::ensure_starts_with_user(messages);
15641576
}
15651577

15661578
// Context guard: compact oversized tool results before LLM call

crates/openfang-runtime/src/drivers/gemini.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,11 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
370370
}
371371

372372
// Step 2: Drop orphaned functionCall parts from model turns.
373-
// A model turn with functionCall must be followed by a user turn with functionResponse.
373+
// A model turn with functionCall must be:
374+
// (a) followed by a user turn with functionResponse, AND
375+
// (b) preceded by a user turn (i.e. not at position 0).
376+
// Gemini rejects with INVALID_ARGUMENT if a functionCall turn is at
377+
// position 0 with no preceding user turn, even when (a) is satisfied.
374378
let len = merged.len();
375379
for i in 0..len {
376380
let is_model = merged[i].role.as_deref() == Some("model");
@@ -394,7 +398,9 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
394398
.iter()
395399
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
396400

397-
if !next_has_response {
401+
// After Step 1 merge, i > 0 guarantees a user turn precedes this model
402+
// turn (alternating roles). i == 0 means no preceding user turn.
403+
if i == 0 || !next_has_response {
398404
// Drop the functionCall parts from this model turn (keep text parts)
399405
merged[i]
400406
.parts

crates/openfang-runtime/src/session_repair.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,33 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, Re
177177
(merged, stats)
178178
}
179179

180+
/// Ensure the message history starts with a user turn.
181+
///
182+
/// After context trimming the drain boundary may land on an assistant turn,
183+
/// leaving it at position 0. Providers (especially Gemini) require the first
184+
/// message to be from the user. This function drops leading assistant messages
185+
/// and re-validates to clean up newly-orphaned ToolResults.
186+
///
187+
/// The loop handles the edge case where the first user turn consisted entirely
188+
/// of ToolResult blocks that became orphaned (dropped by `validate_and_repair`),
189+
/// which would re-expose another leading assistant turn.
190+
pub fn ensure_starts_with_user(mut messages: Vec<Message>) -> Vec<Message> {
191+
loop {
192+
match messages.iter().position(|m| m.role == Role::User) {
193+
Some(0) | None => break,
194+
Some(i) => {
195+
warn!(
196+
dropped = i,
197+
"Dropping leading assistant turn(s) to ensure history starts with user"
198+
);
199+
messages.drain(..i);
200+
messages = validate_and_repair(&messages);
201+
}
202+
}
203+
}
204+
messages
205+
}
206+
180207
/// Phase 2b: Reorder misplaced ToolResults -- ensure each result follows its use.
181208
///
182209
/// Builds a map of tool_use_id to the index of the assistant message containing it.

0 commit comments

Comments
 (0)