diff --git a/crates/openfang-channels/src/line.rs b/crates/openfang-channels/src/line.rs index b20294af..ab5211f5 100644 --- a/crates/openfang-channels/src/line.rs +++ b/crates/openfang-channels/src/line.rs @@ -108,7 +108,7 @@ impl LineAdapter { diff |= a ^ b; } if diff != 0 { - let computed = base64::engine::general_purpose::STANDARD.encode(&result); + let computed = base64::engine::general_purpose::STANDARD.encode(result); // Log first/last 4 chars of each signature for debugging without leaking full HMAC let comp_redacted = format!( "{}...{}", diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index f773def4..76d57c3e 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -342,6 +342,10 @@ pub async fn run_agent_loop( // pair across the cut boundary, leaving orphaned blocks that cause the LLM // to return empty responses (input_tokens=0). messages = crate::session_repair::validate_and_repair(&messages); + // Ensure history starts with a user turn: trimming may have left an + // assistant turn at position 0, which strict providers (e.g. Gemini) + // reject with INVALID_ARGUMENT on function-call turns. + messages = crate::session_repair::ensure_starts_with_user(messages); } // Use autonomous config max_iterations if set, else default @@ -381,6 +385,8 @@ pub async fn run_agent_loop( // which may have broken assistant→tool ordering invariants. if recovery != RecoveryStage::None { messages = crate::session_repair::validate_and_repair(&messages); + // Ensure history starts with a user turn after overflow recovery. + messages = crate::session_repair::ensure_starts_with_user(messages); } // Context guard: compact oversized tool results before LLM call @@ -1504,6 +1510,10 @@ pub async fn run_agent_loop_streaming( // pair across the cut boundary, leaving orphaned blocks that cause the LLM // to return empty responses (input_tokens=0). messages = crate::session_repair::validate_and_repair(&messages); + // Ensure history starts with a user turn: trimming may have left an + // assistant turn at position 0, which strict providers (e.g. Gemini) + // reject with INVALID_ARGUMENT on function-call turns. + messages = crate::session_repair::ensure_starts_with_user(messages); } // Use autonomous config max_iterations if set, else default @@ -1561,6 +1571,8 @@ pub async fn run_agent_loop_streaming( // be followed by tool messages" errors after context overflow recovery.) if recovery != RecoveryStage::None { messages = crate::session_repair::validate_and_repair(&messages); + // Ensure history starts with a user turn after overflow recovery. + messages = crate::session_repair::ensure_starts_with_user(messages); } // Context guard: compact oversized tool results before LLM call diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index cd01017c..3a1b9b96 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -370,7 +370,11 @@ fn sanitize_gemini_turns(contents: Vec) -> Vec { } // Step 2: Drop orphaned functionCall parts from model turns. - // A model turn with functionCall must be followed by a user turn with functionResponse. + // A model turn with functionCall must be: + // (a) followed by a user turn with functionResponse, AND + // (b) preceded by a user turn (i.e. not at position 0). + // Gemini rejects with INVALID_ARGUMENT if a functionCall turn is at + // position 0 with no preceding user turn, even when (a) is satisfied. let len = merged.len(); for i in 0..len { let is_model = merged[i].role.as_deref() == Some("model"); @@ -394,7 +398,9 @@ fn sanitize_gemini_turns(contents: Vec) -> Vec { .iter() .any(|p| matches!(p, GeminiPart::FunctionResponse { .. })); - if !next_has_response { + // After Step 1 merge, i > 0 guarantees a user turn precedes this model + // turn (alternating roles). i == 0 means no preceding user turn. + if i == 0 || !next_has_response { // Drop the functionCall parts from this model turn (keep text parts) merged[i] .parts diff --git a/crates/openfang-runtime/src/session_repair.rs b/crates/openfang-runtime/src/session_repair.rs index 5975a05a..c4ba99d9 100644 --- a/crates/openfang-runtime/src/session_repair.rs +++ b/crates/openfang-runtime/src/session_repair.rs @@ -177,6 +177,33 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec, Re (merged, stats) } +/// Ensure the message history starts with a user turn. +/// +/// After context trimming the drain boundary may land on an assistant turn, +/// leaving it at position 0. Providers (especially Gemini) require the first +/// message to be from the user. This function drops leading assistant messages +/// and re-validates to clean up newly-orphaned ToolResults. +/// +/// The loop handles the edge case where the first user turn consisted entirely +/// of ToolResult blocks that became orphaned (dropped by `validate_and_repair`), +/// which would re-expose another leading assistant turn. +pub fn ensure_starts_with_user(mut messages: Vec) -> Vec { + loop { + match messages.iter().position(|m| m.role == Role::User) { + Some(0) | None => break, + Some(i) => { + warn!( + dropped = i, + "Dropping leading assistant turn(s) to ensure history starts with user" + ); + messages.drain(..i); + messages = validate_and_repair(&messages); + } + } + } + messages +} + /// Phase 2b: Reorder misplaced ToolResults -- ensure each result follows its use. /// /// Builds a map of tool_use_id to the index of the assistant message containing it.