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
2 changes: 1 addition & 1 deletion crates/openfang-channels/src/line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
"{}...{}",
Expand Down
12 changes: 12 additions & 0 deletions crates/openfang-runtime/src/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions crates/openfang-runtime/src/drivers/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,11 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
}

// 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");
Expand All @@ -394,7 +398,9 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
.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
Expand Down
27 changes: 27 additions & 0 deletions crates/openfang-runtime/src/session_repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,33 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, 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<Message>) -> Vec<Message> {
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.
Expand Down