use std::borrow::Cow;
use std::sync::Arc;
use futures::StreamExt;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, info_span, warn};
use crate::error::AgentError;
use crate::stream::{
AssistantMessageDelta, AssistantMessageEvent, StreamFn, StreamOptions, accumulate_message,
};
use crate::types::{
AgentContext, AgentMessage, AssistantMessage, LlmMessage, ModelSpec, StopReason, ThinkingLevel,
};
use super::{
AgentEvent, AgentLoopConfig, StreamResult, build_abort_message, build_error_message,
classify_stream_error, emit,
};
#[allow(clippy::too_many_arguments)]
pub async fn stream_with_retry(
config: &Arc<AgentLoopConfig>,
agent_context: &AgentContext,
llm_messages: &[LlmMessage],
system_prompt: &str,
api_key: Option<String>,
emit_message_start: bool,
cancellation_token: &CancellationToken,
tx: &mpsc::Sender<AgentEvent>,
) -> StreamResult {
let primary_result = stream_with_retry_single(
&config.model,
&config.stream_fn,
config,
agent_context,
llm_messages,
system_prompt,
api_key.clone(),
emit_message_start,
cancellation_token,
tx,
config.fallback.as_ref().is_some_and(|fb| !fb.is_empty()),
)
.await;
let last_error_msg = match &primary_result {
StreamResult::Message(msg)
if msg.stop_reason != StopReason::Error || !is_fallback_eligible_error(msg) =>
{
return primary_result;
}
StreamResult::ContextOverflow | StreamResult::Aborted | StreamResult::ChannelClosed => {
return primary_result;
}
StreamResult::Message(msg) => msg.clone(),
};
let fallback = match config.fallback {
Some(ref fb) if !fb.is_empty() => fb,
_ => return StreamResult::Message(last_error_msg),
};
let mut last_result = StreamResult::Message(last_error_msg);
let fallback_models = fallback.models();
let fallback_count = fallback_models.len();
for (index, (fb_model, fb_stream_fn)) in fallback_models.iter().enumerate() {
if !emit(
tx,
AgentEvent::ModelFallback {
from_model: config.model.clone(),
to_model: fb_model.clone(),
},
)
.await
{
return StreamResult::ChannelClosed;
}
warn!(
from = %config.model.model_id,
to = %fb_model.model_id,
"falling back to alternate model"
);
let fb_result = stream_with_retry_single(
fb_model,
fb_stream_fn,
config,
agent_context,
llm_messages,
system_prompt,
api_key.clone(),
false,
cancellation_token,
tx,
index + 1 < fallback_count,
)
.await;
match &fb_result {
StreamResult::Message(msg)
if msg.stop_reason != StopReason::Error || !is_fallback_eligible_error(msg) =>
{
return fb_result;
}
StreamResult::ContextOverflow | StreamResult::Aborted | StreamResult::ChannelClosed => {
return fb_result;
}
StreamResult::Message(_) => {
last_result = fb_result;
}
}
}
last_result
}
fn is_fallback_eligible_error(msg: &AssistantMessage) -> bool {
let Some(error_message) = msg.error_message.as_deref() else {
return false;
};
let harness_error = classify_stream_error(error_message, StopReason::Error, msg.error_kind);
harness_error.is_retryable()
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn stream_with_retry_single(
model: &ModelSpec,
stream_fn: &Arc<dyn StreamFn>,
config: &Arc<AgentLoopConfig>,
agent_context: &AgentContext,
llm_messages: &[LlmMessage],
system_prompt: &str,
api_key: Option<String>,
emit_message_start: bool,
cancellation_token: &CancellationToken,
tx: &mpsc::Sender<AgentEvent>,
defer_fallback_eligible_fatal_errors: bool,
) -> StreamResult {
let llm_span = info_span!(
"agent.llm_call",
agent.model = %model.model_id,
agent.tokens.input = tracing::field::Empty,
agent.tokens.output = tracing::field::Empty,
agent.cost.total = tracing::field::Empty,
otel.status_code = tracing::field::Empty,
);
let _llm_guard = llm_span.enter();
let mut attempt: u32 = 0;
let mut attempt_llm_messages = llm_messages.to_vec();
loop {
attempt += 1;
debug!(attempt, model_id = %model.model_id, "starting stream attempt");
if cancellation_token.is_cancelled() {
return StreamResult::Aborted;
}
let call_context = AgentContext {
system_prompt: system_prompt.to_string(),
messages: attempt_llm_messages
.iter()
.map(|m| AgentMessage::Llm(m.clone()))
.collect(),
tools: agent_context.tools.clone(),
};
let mut stream_options = config.stream_options.clone();
stream_options.api_key = api_key.clone();
if emit_message_start && attempt == 1 && !emit(tx, AgentEvent::MessageStart).await {
return StreamResult::ChannelClosed;
}
let attempt_result = stream_single_attempt(
model,
stream_fn,
&call_context,
&stream_options,
cancellation_token,
)
.await;
let (events, had_error) = match attempt_result {
StreamAttemptResult::EarlyExit { result, events } => {
if let Some(exit) = emit_attempt_deltas(&events, tx).await {
return exit;
}
if matches!(result, StreamResult::Aborted) {
let abort_msg = build_abort_message(model);
if !emit(tx, AgentEvent::MessageEnd { message: abort_msg }).await {
return StreamResult::ChannelClosed;
}
}
return result;
}
StreamAttemptResult::Collected { events, error } => (events, error),
};
if let Some((stop_reason, error_message, _usage, error_kind, retry_after)) = had_error {
let retry_result = handle_stream_error(
model,
config,
stop_reason,
&error_message,
error_kind,
retry_after,
attempt,
);
match retry_result {
StreamErrorAction::ContextOverflow => return StreamResult::ContextOverflow,
StreamErrorAction::Retry {
delay,
cache_miss_prior_prefix_len,
} => {
if let Some(prior_prefix_len) = cache_miss_prior_prefix_len {
prepare_cache_miss_retry(
config,
&mut attempt_llm_messages,
prior_prefix_len,
);
}
tokio::time::sleep(delay).await;
continue;
}
StreamErrorAction::FatalError { result, message } => {
if !defer_fallback_eligible_fatal_errors
|| !is_fallback_eligible_error(&message)
{
if let Some(exit) = emit_attempt_deltas(&events, tx).await {
return exit;
}
if !emit(tx, AgentEvent::MessageEnd { message }).await {
return StreamResult::ChannelClosed;
}
}
llm_span.record("otel.status_code", "ERROR");
return result;
}
}
}
if let Some(exit) = emit_attempt_deltas(&events, tx).await {
return exit;
}
let result = finalize_stream_message(model, events, tx).await;
if let StreamResult::Message(ref msg) = result {
llm_span.record("agent.tokens.input", msg.usage.input);
llm_span.record("agent.tokens.output", msg.usage.output);
llm_span.record("agent.cost.total", msg.cost.total);
}
return result;
}
}
#[allow(clippy::large_enum_variant)]
enum StreamErrorAction {
ContextOverflow,
Retry {
delay: std::time::Duration,
cache_miss_prior_prefix_len: Option<usize>,
},
FatalError {
result: StreamResult,
message: AssistantMessage,
},
}
type StreamErrorInfo = (
StopReason,
String,
Option<crate::types::Usage>,
Option<crate::stream::StreamErrorKind>,
Option<std::time::Duration>,
);
enum StreamAttemptResult {
Collected {
events: Vec<AssistantMessageEvent>,
error: Option<StreamErrorInfo>,
},
EarlyExit {
result: StreamResult,
events: Vec<AssistantMessageEvent>,
},
}
async fn stream_single_attempt(
model: &ModelSpec,
stream_fn: &Arc<dyn StreamFn>,
call_context: &AgentContext,
stream_options: &StreamOptions,
cancellation_token: &CancellationToken,
) -> StreamAttemptResult {
let effective_model = apply_capability_overrides(model);
let mut stream = stream_fn.stream(
&effective_model,
call_context,
stream_options,
cancellation_token.clone(),
);
let mut events: Vec<AssistantMessageEvent> = Vec::new();
let mut had_error: Option<StreamErrorInfo> = None;
loop {
let event = tokio::select! {
() = cancellation_token.cancelled() => {
return StreamAttemptResult::EarlyExit {
result: StreamResult::Aborted,
events,
};
}
next = stream.next() => {
let Some(event) = next else {
break;
};
event
}
};
if cancellation_token.is_cancelled() {
return StreamAttemptResult::EarlyExit {
result: StreamResult::Aborted,
events,
};
}
if let AssistantMessageEvent::Error {
stop_reason,
error_message,
usage,
error_kind,
retry_after,
} = &event
{
had_error = Some((
*stop_reason,
error_message.clone(),
usage.clone(),
*error_kind,
*retry_after,
));
}
events.push(event);
}
StreamAttemptResult::Collected {
events,
error: had_error,
}
}
async fn emit_attempt_deltas(
events: &[AssistantMessageEvent],
tx: &mpsc::Sender<AgentEvent>,
) -> Option<StreamResult> {
for event in events {
if let Some(early_exit) = emit_delta_event(event, tx).await {
return Some(early_exit);
}
}
None
}
async fn emit_delta_event(
event: &AssistantMessageEvent,
tx: &mpsc::Sender<AgentEvent>,
) -> Option<StreamResult> {
let delta = match event {
AssistantMessageEvent::TextDelta {
content_index,
delta,
} => Some(AssistantMessageDelta::Text {
content_index: *content_index,
delta: Cow::Owned(delta.clone()),
}),
AssistantMessageEvent::ThinkingDelta {
content_index,
delta,
} => Some(AssistantMessageDelta::Thinking {
content_index: *content_index,
delta: Cow::Owned(delta.clone()),
}),
AssistantMessageEvent::ToolCallDelta {
content_index,
delta,
} => Some(AssistantMessageDelta::ToolCall {
content_index: *content_index,
delta: Cow::Owned(delta.clone()),
}),
_ => None,
};
if let Some(d) = delta
&& !emit(tx, AgentEvent::MessageUpdate { delta: d }).await
{
return Some(StreamResult::ChannelClosed);
}
None
}
#[allow(clippy::too_many_arguments)]
fn handle_stream_error(
model: &ModelSpec,
config: &Arc<AgentLoopConfig>,
stop_reason: StopReason,
error_message: &str,
error_kind: Option<crate::stream::StreamErrorKind>,
retry_after: Option<std::time::Duration>,
attempt: u32,
) -> StreamErrorAction {
let harness_error = classify_stream_error(error_message, stop_reason, error_kind);
if matches!(harness_error, AgentError::ContextWindowOverflow { .. }) {
warn!("context window overflow, signaling prune");
return StreamErrorAction::ContextOverflow;
}
let mut retry_strategy_consulted = false;
if matches!(harness_error, AgentError::CacheMiss) {
warn!("provider cache miss, resetting cache state for retry");
let prior_prefix_len = {
let mut cache_state = config
.cache_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior_prefix_len = cache_state.cached_prefix_len;
cache_state.reset();
prior_prefix_len
};
retry_strategy_consulted = true;
if config.retry_strategy.should_retry(&harness_error, attempt) {
let delay = config.retry_strategy.delay(attempt);
warn!(
attempt,
?delay,
error = %harness_error,
"retrying after cache miss"
);
return StreamErrorAction::Retry {
delay,
cache_miss_prior_prefix_len: Some(prior_prefix_len),
};
}
}
if matches!(harness_error, AgentError::Aborted) {
let abort_msg = build_abort_message(model);
return StreamErrorAction::FatalError {
result: StreamResult::Aborted,
message: abort_msg,
};
}
if !retry_strategy_consulted && config.retry_strategy.should_retry(&harness_error, attempt) {
let delay = retry_after.unwrap_or_else(|| config.retry_strategy.delay(attempt));
warn!(
attempt,
?delay,
provider_hint = retry_after.is_some(),
error = %harness_error,
"retrying after transient error"
);
return StreamErrorAction::Retry {
delay,
cache_miss_prior_prefix_len: None,
};
}
error!(error = %harness_error, "non-retryable stream error");
let error_msg = build_error_message(model, &harness_error);
StreamErrorAction::FatalError {
result: StreamResult::Message(error_msg.clone()),
message: error_msg,
}
}
fn prepare_cache_miss_retry(
config: &Arc<AgentLoopConfig>,
messages: &mut [LlmMessage],
prior_prefix_len: usize,
) {
let Some(cache_config) = config.cache_config.as_ref() else {
return;
};
let write_hint = crate::context_cache::CacheHint::Write {
ttl: cache_config.ttl,
};
let mut saw_cached_message = false;
for message in messages {
if llm_cache_hint(message).is_some() {
saw_cached_message = true;
set_llm_cache_hint(message, write_hint.clone());
}
}
if !saw_cached_message && prior_prefix_len == 0 {
return;
}
let mut cache_state = config
.cache_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let hint = cache_state.advance_turn(cache_config);
debug_assert!(matches!(
hint,
crate::context_cache::CacheHint::Write { .. }
));
cache_state.cached_prefix_len = prior_prefix_len;
}
fn llm_cache_hint(message: &LlmMessage) -> Option<&crate::context_cache::CacheHint> {
match message {
LlmMessage::User(message) => message.cache_hint.as_ref(),
LlmMessage::Assistant(message) => message.cache_hint.as_ref(),
LlmMessage::ToolResult(message) => message.cache_hint.as_ref(),
}
}
fn set_llm_cache_hint(message: &mut LlmMessage, hint: crate::context_cache::CacheHint) {
match message {
LlmMessage::User(message) => message.cache_hint = Some(hint),
LlmMessage::Assistant(message) => message.cache_hint = Some(hint),
LlmMessage::ToolResult(message) => message.cache_hint = Some(hint),
}
}
fn apply_capability_overrides(model: &ModelSpec) -> Cow<'_, ModelSpec> {
let Some(ref caps) = model.capabilities else {
return Cow::Borrowed(model);
};
let mut changed = false;
let mut overridden = model.clone();
if !caps.supports_thinking && overridden.thinking_level != ThinkingLevel::Off {
debug!(
model_id = %model.model_id,
"model does not support thinking — forcing thinking_level to Off"
);
overridden.thinking_level = ThinkingLevel::Off;
changed = true;
}
if changed {
Cow::Owned(overridden)
} else {
Cow::Borrowed(model)
}
}
pub fn capability_filter_tools(
model: &ModelSpec,
tools: &[Arc<dyn crate::tool::AgentTool>],
) -> Vec<Arc<dyn crate::tool::AgentTool>> {
if let Some(ref caps) = model.capabilities
&& !caps.supports_tool_use
&& !tools.is_empty()
{
debug!(
model_id = %model.model_id,
tool_count = tools.len(),
"model does not support tool use — stripping tools from context"
);
return Vec::new();
}
tools.to_vec()
}
async fn finalize_stream_message(
model: &ModelSpec,
events: Vec<AssistantMessageEvent>,
tx: &mpsc::Sender<AgentEvent>,
) -> StreamResult {
let message = match accumulate_message(events, &model.provider, &model.model_id) {
Ok(msg) => msg,
Err(e) => {
let err = AgentError::StreamError {
source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
};
let error_msg = build_error_message(model, &err);
let _ = emit(
tx,
AgentEvent::MessageEnd {
message: error_msg.clone(),
},
)
.await;
return StreamResult::Message(error_msg);
}
};
info!(
input_tokens = message.usage.input,
output_tokens = message.usage.output,
total_tokens = message.usage.total,
stop_reason = ?message.stop_reason,
"stream completed"
);
if !emit(
tx,
AgentEvent::MessageEnd {
message: message.clone(),
},
)
.await
{
return StreamResult::ChannelClosed;
}
StreamResult::Message(message)
}