use crate::context::{
self, CompactionStrategy, ContextConfig, ContextTracker, DefaultCompaction, ExecutionLimits,
ExecutionTracker,
};
use crate::provider::{
ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider, ToolDefinition,
};
use crate::types::*;
use std::sync::Arc;
pub type ConvertToLlmFn = Box<dyn Fn(&[AgentMessage]) -> Vec<Message> + Send + Sync>;
pub type TransformContextFn = Box<dyn Fn(Vec<AgentMessage>) -> Vec<AgentMessage> + Send + Sync>;
pub type GetMessagesFn = Box<dyn Fn() -> Vec<AgentMessage> + Send + Sync>;
pub type BeforeTurnFn = Arc<dyn Fn(&[AgentMessage], usize) -> bool + Send + Sync>;
pub type AfterTurnFn = Arc<dyn Fn(&[AgentMessage], &Usage) + Send + Sync>;
pub type OnErrorFn = Arc<dyn Fn(&str) + Send + Sync>;
use tokio::sync::mpsc;
use tracing::warn;
pub struct AgentLoopConfig {
pub provider: Arc<dyn StreamProvider>,
pub model: String,
pub api_key: String,
pub thinking_level: ThinkingLevel,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub model_config: Option<ModelConfig>,
pub convert_to_llm: Option<ConvertToLlmFn>,
pub transform_context: Option<TransformContextFn>,
pub get_steering_messages: Option<GetMessagesFn>,
pub get_follow_up_messages: Option<GetMessagesFn>,
pub context_config: Option<ContextConfig>,
pub compaction_strategy: Option<Arc<dyn CompactionStrategy>>,
pub execution_limits: Option<ExecutionLimits>,
pub cache_config: CacheConfig,
pub tool_output_sink: Option<crate::shared_state::SharedState>,
pub tool_execution: ToolExecutionStrategy,
pub tool_middleware: Vec<Arc<dyn ToolMiddleware>>,
pub output_schema: Option<crate::provider::OutputSchema>,
pub retry_config: crate::retry::RetryConfig,
pub before_turn: Option<BeforeTurnFn>,
pub after_turn: Option<AfterTurnFn>,
pub on_error: Option<OnErrorFn>,
pub input_filters: Vec<Arc<dyn InputFilter>>,
pub turn_delay: Option<std::time::Duration>,
}
fn default_convert_to_llm(messages: &[AgentMessage]) -> Vec<Message> {
messages
.iter()
.filter_map(|m| m.as_llm().cloned())
.collect()
}
pub const AGENT_STOPPED_PREFIX: &str = "[Agent stopped:";
pub const LOOP_ABORT_PREFIX: &str = "[Agent stopped: repeated tool call —";
pub(crate) const LOOP_NUDGE_PREFIX: &str = "[You have called ";
#[derive(Default)]
struct LoopScope {
decision: DecisionStats,
prompts: Vec<Message>,
}
tokio::task_local! {
static LOOP_SCOPE: std::cell::RefCell<LoopScope>;
}
#[cfg_attr(not(feature = "decision"), allow(dead_code))]
pub(crate) fn record_decision(f: impl FnOnce(&mut DecisionStats)) {
let _ = LOOP_SCOPE.try_with(|cell| f(&mut cell.borrow_mut().decision));
}
pub(crate) fn run_prompts() -> Vec<Message> {
LOOP_SCOPE
.try_with(|cell| cell.borrow().prompts.clone())
.unwrap_or_default()
}
fn user_messages(messages: &[AgentMessage]) -> Vec<Message> {
messages
.iter()
.filter_map(|m| match m {
AgentMessage::Llm(msg @ Message::User { .. }) => Some(msg.clone()),
_ => None,
})
.collect()
}
fn note_run_prompts(messages: &[AgentMessage]) {
let users = user_messages(messages);
if !users.is_empty() {
let _ = LOOP_SCOPE.try_with(|cell| cell.borrow_mut().prompts.extend(users));
}
}
async fn with_loop_scope<T>(
prompts: Vec<Message>,
fut: impl std::future::Future<Output = T>,
) -> (T, DecisionStats) {
let scope = LoopScope {
prompts,
..Default::default()
};
LOOP_SCOPE
.scope(std::cell::RefCell::new(scope), async {
let out = fut.await;
let recorded = LOOP_SCOPE.with(|cell| std::mem::take(&mut cell.borrow_mut().decision));
(out, recorded)
})
.await
}
const REFUSAL_TOOL_RESULT_TEXT: &str = "Tool call not run: the response was stopped as a refusal \
(declined by the model or stopped by the content filter).";
pub async fn agent_loop(
prompts: Vec<AgentMessage>,
context: &mut AgentContext,
config: &AgentLoopConfig,
tx: mpsc::UnboundedSender<AgentEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Vec<AgentMessage> {
agent_loop_with_stats(prompts, context, config, tx, cancel)
.await
.0
}
pub(crate) async fn agent_loop_with_stats(
prompts: Vec<AgentMessage>,
context: &mut AgentContext,
config: &AgentLoopConfig,
tx: mpsc::UnboundedSender<AgentEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> (Vec<AgentMessage>, SessionStats) {
tx.send(AgentEvent::AgentStart).ok();
let (outcome, decision) = with_loop_scope(Vec::new(), async {
let prompts = apply_input_filters(prompts, config).await?;
note_run_prompts(&prompts);
let mut new_messages: Vec<AgentMessage> = prompts.clone();
for prompt in &prompts {
context.messages.push(prompt.clone());
}
tx.send(AgentEvent::TurnStart).ok();
for prompt in &prompts {
tx.send(AgentEvent::MessageStart {
message: prompt.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: prompt.clone(),
})
.ok();
}
let stats = {
use tracing::Instrument;
run_loop(context, &mut new_messages, config, &tx, &cancel)
.instrument(tracing::info_span!("agent_loop", model = %config.model))
.await
};
Ok::<_, String>((new_messages, stats))
})
.await;
let (new_messages, mut stats) = match outcome {
Ok(done) => done,
Err(reason) => {
tx.send(AgentEvent::InputRejected { reason }).ok();
let stats = SessionStats {
decision,
..Default::default()
};
tx.send(AgentEvent::AgentEnd {
messages: vec![],
stats: stats.clone(),
})
.ok();
return (vec![], stats);
}
};
stats.decision.merge(&decision);
tx.send(AgentEvent::AgentEnd {
messages: new_messages.clone(),
stats: stats.clone(),
})
.ok();
(new_messages, stats)
}
async fn apply_input_filters(
prompts: Vec<AgentMessage>,
config: &AgentLoopConfig,
) -> Result<Vec<AgentMessage>, String> {
let prompts = if !config.input_filters.is_empty() {
let user_text: String = prompts
.iter()
.filter_map(|m| {
if let AgentMessage::Llm(Message::User { content, .. }) = m {
Some(
content
.iter()
.filter_map(|c| {
if let Content::Text { text } = c {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n"),
)
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
let mut warnings: Vec<String> = Vec::new();
for filter in &config.input_filters {
let verdict = match filter.as_async() {
Some(async_filter) => {
use futures::FutureExt;
std::panic::AssertUnwindSafe(async_filter.filter(&user_text))
.catch_unwind()
.await
.unwrap_or_else(|_| {
warn!("async input filter panicked; rejecting the input");
FilterResult::Reject("input filter panicked".into())
})
}
None => filter.filter(&user_text),
};
match verdict {
FilterResult::Pass => {}
FilterResult::Warn(w) => warnings.push(w),
FilterResult::Reject(reason) => return Err(reason),
}
}
if !warnings.is_empty() {
let warning_text = warnings
.iter()
.map(|w| format!("[Warning: {}]", w))
.collect::<Vec<_>>()
.join("\n");
let mut modified = prompts;
for msg in modified.iter_mut().rev() {
if let AgentMessage::Llm(Message::User { content, .. }) = msg {
content.push(Content::Text { text: warning_text });
break;
}
}
modified
} else {
prompts
}
} else {
prompts
};
Ok(prompts)
}
pub async fn agent_loop_continue(
context: &mut AgentContext,
config: &AgentLoopConfig,
tx: mpsc::UnboundedSender<AgentEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Vec<AgentMessage> {
agent_loop_continue_with_stats(context, config, tx, cancel)
.await
.0
}
pub(crate) async fn agent_loop_continue_with_stats(
context: &mut AgentContext,
config: &AgentLoopConfig,
tx: mpsc::UnboundedSender<AgentEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> (Vec<AgentMessage>, SessionStats) {
assert!(
!context.messages.is_empty(),
"Cannot continue: no messages in context"
);
if let Some(last) = context.messages.last() {
assert!(
last.role() != "assistant",
"Cannot continue from assistant message"
);
}
let mut new_messages: Vec<AgentMessage> = Vec::new();
tx.send(AgentEvent::AgentStart).ok();
tx.send(AgentEvent::TurnStart).ok();
let stats = {
use tracing::Instrument;
let (mut stats, decision) = with_loop_scope(
Vec::new(),
run_loop(context, &mut new_messages, config, &tx, &cancel)
.instrument(tracing::info_span!("agent_loop", model = %config.model)),
)
.await;
stats.decision.merge(&decision);
stats
};
tx.send(AgentEvent::AgentEnd {
messages: new_messages.clone(),
stats: stats.clone(),
})
.ok();
(new_messages, stats)
}
async fn run_loop(
context: &mut AgentContext,
new_messages: &mut Vec<AgentMessage>,
config: &AgentLoopConfig,
tx: &mpsc::UnboundedSender<AgentEvent>,
cancel: &tokio_util::sync::CancellationToken,
) -> SessionStats {
let mut stats = SessionStats::default();
let mut first_turn = true;
let mut turn_number: usize = 0;
let mut last_context_tokens: Option<usize> = None;
let mut growth_total: usize = 0;
let mut growth_samples: usize = 0;
let mut context_tracker = ContextTracker::new();
let mut tracker = config
.execution_limits
.as_ref()
.map(|limits| ExecutionTracker::new(limits.clone()));
let mut pending: Vec<AgentMessage> = config
.get_steering_messages
.as_ref()
.map(|f| f())
.unwrap_or_default();
loop {
if cancel.is_cancelled() {
return stats;
}
let mut steering_after_tools: Option<Vec<AgentMessage>> = None;
loop {
if cancel.is_cancelled() {
return stats;
}
if !first_turn {
tx.send(AgentEvent::TurnStart).ok();
} else {
first_turn = false;
}
if !pending.is_empty() {
note_run_prompts(&pending);
for msg in pending.drain(..) {
tx.send(AgentEvent::MessageStart {
message: msg.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: msg.clone(),
})
.ok();
context.messages.push(msg.clone());
new_messages.push(msg);
}
}
if let Some(ref tracker) = tracker {
if let Some(reason) = tracker.check_limits() {
warn!("Execution limit reached: {}", reason);
let limit_msg = AgentMessage::Llm(Message::User {
content: vec![Content::Text {
text: format!("{AGENT_STOPPED_PREFIX} {}]", reason),
}],
timestamp: now_ms(),
});
tx.send(AgentEvent::MessageStart {
message: limit_msg.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: limit_msg.clone(),
})
.ok();
context.messages.push(limit_msg.clone());
new_messages.push(limit_msg);
return stats;
}
}
if let Some(ref before_turn) = config.before_turn {
if !before_turn(&context.messages, turn_number) {
return stats;
}
}
if turn_number > 0 {
if let Some(delay) = config.turn_delay {
crate::rt::sleep(delay).await;
}
}
turn_number += 1;
if let Some(ref ctx_config) = config.context_config {
let estimated = context::total_tokens(&context.messages);
let hybrid = context_tracker.estimate_context_tokens(&context.messages);
let overhead = hybrid.saturating_sub(estimated);
if let Some(previous) = last_context_tokens {
growth_samples += 1;
growth_total += estimated.saturating_sub(previous);
}
let growth_per_turn = if growth_samples > 0 {
growth_total as f64 / growth_samples as f64
} else {
0.0
};
let calibrated;
let effective_config = if overhead > 0 {
let floor = ctx_config.max_context_tokens / 10;
calibrated = ContextConfig {
max_context_tokens: ctx_config
.max_context_tokens
.saturating_sub(overhead)
.max(floor),
system_prompt_tokens: 0,
..ctx_config.clone()
};
tracing::debug!(
"compaction budget calibrated: {} -> {} (measured overhead: {} tokens)",
ctx_config.max_context_tokens,
calibrated.max_context_tokens,
overhead
);
&calibrated
} else {
ctx_config
};
let with_headroom;
let effective_config = {
let ratio = effective_config.effective_target_ratio(growth_per_turn);
if ratio != effective_config.compact_target_ratio {
tracing::debug!(
"compaction target adapted: ratio {} -> {:.3} \
({} tokens/turn growth, {:?} turns of headroom)",
effective_config.compact_target_ratio,
ratio,
growth_per_turn as usize,
effective_config.compact_headroom_turns,
);
with_headroom = ContextConfig {
compact_target_ratio: ratio,
..effective_config.clone()
};
&with_headroom
} else {
effective_config
}
};
let strategy: &dyn CompactionStrategy = config
.compaction_strategy
.as_deref()
.unwrap_or(&DefaultCompaction);
let before_len = context.messages.len();
let before_tokens = estimated;
context.messages =
strategy.compact(std::mem::take(&mut context.messages), effective_config);
let after_tokens = context::total_tokens(&context.messages);
if context.messages.len() != before_len || after_tokens != before_tokens {
context_tracker.reset();
stats.compactions += 1;
}
last_context_tokens = Some(after_tokens);
}
let llm_span = tracing::info_span!(
"llm_stream",
turn = turn_number,
model = %config.model,
tokens_in = tracing::field::Empty,
tokens_out = tracing::field::Empty,
tokens_cached = tracing::field::Empty,
cost_usd = tracing::field::Empty,
error = tracing::field::Empty,
);
let message = {
use tracing::Instrument;
stream_assistant_response(context, config, tx, cancel)
.instrument(llm_span.clone())
.await
};
if let Message::Assistant {
usage, stop_reason, ..
} = &message
{
llm_span.record("error", *stop_reason == StopReason::Error);
llm_span.record("tokens_in", usage.input);
llm_span.record("tokens_out", usage.output);
llm_span.record("tokens_cached", usage.cache_read);
if let Some(cost) = config.model_config.as_ref().and_then(|mc| mc.cost.as_ref()) {
llm_span.record("cost_usd", cost.cost_usd(usage));
}
}
let message = if structured_output_is_tool_forced(config.model_config.as_ref()) {
unwrap_structured_tool_call(message, config.output_schema.as_ref())
} else {
message
};
let agent_msg: AgentMessage = message.clone().into();
context.messages.push(agent_msg.clone());
new_messages.push(agent_msg.clone());
if let Message::Assistant { usage, .. } = &message {
context_tracker.record_usage(usage, context.messages.len() - 1);
stats.record_turn(
usage,
config.model_config.as_ref().and_then(|mc| mc.cost.as_ref()),
);
}
if let Message::Assistant {
ref stop_reason,
ref error_message,
ref usage,
..
} = message
{
if *stop_reason == StopReason::Error || *stop_reason == StopReason::Aborted {
if *stop_reason == StopReason::Error {
if let Some(ref on_error) = config.on_error {
let err_str = error_message.as_deref().unwrap_or("Unknown error");
on_error(err_str);
}
}
if let Some(ref after_turn) = config.after_turn {
after_turn(&context.messages, usage);
}
tx.send(AgentEvent::TurnEnd {
message: agent_msg,
tool_results: vec![],
})
.ok();
return stats;
}
}
if let Message::Assistant {
stop_reason: StopReason::Refusal,
ref content,
ref usage,
..
} = message
{
let mut tool_results: Vec<Message> = Vec::new();
for c in content {
if let Content::ToolCall {
id,
name,
arguments,
..
} = c
{
tracing::warn!(
tool = name.as_str(),
tool_call_id = id.as_str(),
"tool call not executed: the response was a refusal"
);
let (result, _) = unexecuted_tool_call(
id,
name,
arguments,
REFUSAL_TOOL_RESULT_TEXT.to_string(),
tx,
);
let am: AgentMessage = result.clone().into();
context.messages.push(am.clone());
new_messages.push(am);
tool_results.push(result);
}
}
if let Some(ref after_turn) = config.after_turn {
after_turn(&context.messages, usage);
}
tx.send(AgentEvent::TurnEnd {
message: agent_msg,
tool_results,
})
.ok();
return stats;
}
let tool_calls: Vec<_> = match &message {
Message::Assistant { content, .. } => content
.iter()
.filter_map(|c| match c {
Content::ToolCall {
id,
name,
arguments,
..
} => Some((id.clone(), name.clone(), arguments.clone())),
_ => None,
})
.collect(),
_ => vec![],
};
let mut loop_nudge: Option<AgentMessage> = None;
if !tool_calls.is_empty() {
if let Some(ref mut tracker) = tracker {
let sigs: Vec<(String, serde_json::Value)> = tool_calls
.iter()
.map(|(_, name, args)| (name.clone(), args.clone()))
.collect();
match tracker.record_tool_calls(&sigs) {
context::LoopVerdict::Continue => {}
context::LoopVerdict::Steer {
tool_name,
repetitions,
} => {
warn!(
"loop detection: {tool_name} called {repetitions}x with identical arguments; steering"
);
tx.send(AgentEvent::LoopDetected {
tool_name: tool_name.clone(),
repetitions,
aborted: false,
})
.ok();
let nudge = AgentMessage::Llm(Message::User {
content: vec![Content::Text {
text: format!(
"{LOOP_NUDGE_PREFIX}{tool_name} {repetitions} times with identical arguments. The result will not change — change approach, or say why the repetition is needed.]"
),
}],
timestamp: now_ms(),
});
loop_nudge = Some(nudge);
}
context::LoopVerdict::Abort {
tool_name,
repetitions,
} => {
warn!("loop detection: {tool_name} repeated after steering; stopping");
tx.send(AgentEvent::LoopDetected {
tool_name: tool_name.clone(),
repetitions,
aborted: true,
})
.ok();
let stop = AgentMessage::Llm(Message::User {
content: vec![Content::Text {
text: format!(
"{LOOP_ABORT_PREFIX} {tool_name} was called repeatedly with identical \
arguments after being asked to change approach.]"
),
}],
timestamp: now_ms(),
});
for (id, name, _) in &tool_calls {
let denied = Message::ToolResult {
tool_call_id: id.clone(),
tool_name: name.clone(),
content: vec![Content::Text {
text: "Not executed: the run was stopped for repeating \
this call with identical arguments."
.into(),
}],
is_error: true,
timestamp: now_ms(),
};
let am: AgentMessage = denied.into();
context.messages.push(am.clone());
new_messages.push(am);
}
tx.send(AgentEvent::MessageStart {
message: stop.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: stop.clone(),
})
.ok();
context.messages.push(stop.clone());
new_messages.push(stop);
if let Some(after_turn) = config.after_turn.as_ref() {
let usage = match &message {
Message::Assistant { usage, .. } => usage.clone(),
_ => Usage::default(),
};
after_turn(&context.messages, &usage);
}
tx.send(AgentEvent::TurnEnd {
message: agent_msg,
tool_results: Vec::new(),
})
.ok();
return stats;
}
}
}
}
let has_tool_calls = !tool_calls.is_empty();
let mut tool_results: Vec<Message> = Vec::new();
if has_tool_calls {
let execution = execute_tool_calls(
&context.tools,
&tool_calls,
tx,
cancel,
config.get_steering_messages.as_ref(),
&config.tool_execution,
Gate {
middleware: &config.tool_middleware,
history: &context.messages,
},
)
.await;
tool_results = execution.tool_results;
steering_after_tools = execution.steering_messages;
for child in &execution.sub_agent_stats {
stats.sub_agents.record_run(child);
stats.decision.merge(&child.decision);
}
let append_cap = config
.context_config
.as_ref()
.filter(|c| c.truncate_tool_output_on_append);
for result in &tool_results {
let original: AgentMessage = result.clone().into();
let mut am = original.clone();
if let Some(ctx_config) = append_cap {
let (plain, marked) =
context::truncate_tool_output_keyed(original.clone(), ctx_config, None);
am = plain;
if let (Some(sink), false) = (&config.tool_output_sink, marked.is_empty()) {
if let Message::ToolResult { tool_call_id, .. } = result {
let blocks = context::block_texts(&original);
let base = context::tool_output_key(
tool_call_id,
&context::message_text(&original),
);
let mut all_stored = true;
let mut written: Vec<String> = Vec::new();
for idx in &marked {
let Some((_, text)) = blocks.iter().find(|(i, _)| i == idx)
else {
debug_assert!(
false,
"marked block {idx} absent from block_texts"
);
all_stored = false;
break;
};
let key = context::block_key(&base, *idx);
match sink.set(&key, text.clone()).await {
Ok(()) => written.push(key),
Err(e) => {
all_stored = false;
warn!(
tool_call_id = %tool_call_id,
block = idx,
bytes = text.len(),
"could not stash tool output: {e}; no marker in \
this result will offer retrieval"
);
break;
}
}
}
if all_stored {
for key in &written {
if sink.get(key).await.is_none() {
all_stored = false;
warn!(
tool_call_id = %tool_call_id,
key = %key,
stored = written.len(),
"a stashed block did not survive storing its \
siblings — the backend cap cannot hold this \
result; no marker will offer retrieval"
);
break;
}
}
}
if !all_stored {
for key in &written {
if !sink.remove(key).await {
warn!("shared state: rollback could not remove {key}");
}
}
}
if all_stored {
am = context::truncate_tool_output_keyed(
original.clone(),
ctx_config,
Some(&base),
)
.0;
}
}
}
}
context.messages.push(am.clone());
new_messages.push(am);
}
}
if let Some(ref mut tracker) = tracker {
let turn_tokens = match &message {
Message::Assistant { usage, .. } => {
(usage.input + usage.output + usage.cache_read + usage.cache_write) as usize
}
_ => context::message_tokens(&agent_msg),
};
tracker.record_turn(turn_tokens);
}
if let Some(ref after_turn) = config.after_turn {
let usage = match &message {
Message::Assistant { usage, .. } => usage.clone(),
_ => Usage::default(),
};
after_turn(&context.messages, &usage);
}
if let Some(nudge) = loop_nudge.take() {
tx.send(AgentEvent::MessageStart {
message: nudge.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: nudge.clone(),
})
.ok();
context.messages.push(nudge.clone());
new_messages.push(nudge);
}
tx.send(AgentEvent::TurnEnd {
message: agent_msg,
tool_results,
})
.ok();
if let Some(steering) = steering_after_tools.take() {
if !steering.is_empty() {
pending = steering;
continue;
}
}
pending = config
.get_steering_messages
.as_ref()
.map(|f| f())
.unwrap_or_default();
if !has_tool_calls && pending.is_empty() {
break;
}
}
let follow_ups = config
.get_follow_up_messages
.as_ref()
.map(|f| f())
.unwrap_or_default();
if !follow_ups.is_empty() {
pending = follow_ups;
continue;
}
break;
}
stats
}
async fn stream_assistant_response(
context: &AgentContext,
config: &AgentLoopConfig,
tx: &mpsc::UnboundedSender<AgentEvent>,
cancel: &tokio_util::sync::CancellationToken,
) -> Message {
let messages = if let Some(transform) = &config.transform_context {
transform(context.messages.clone())
} else {
context.messages.clone()
};
let convert = config.convert_to_llm.as_ref();
let llm_messages = match convert {
Some(f) => f(&messages),
None => default_convert_to_llm(&messages),
};
let tool_defs: Vec<ToolDefinition> = context
.tools
.iter()
.map(|t| ToolDefinition {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters_schema(),
})
.collect();
let retry = &config.retry_config;
let mut attempt = 0;
let result = loop {
let stream_config = StreamConfig {
model: config.model.clone(),
system_prompt: context.system_prompt.clone(),
messages: llm_messages.clone(),
tools: tool_defs.clone(),
thinking_level: config.thinking_level,
api_key: config.api_key.clone(),
max_tokens: config.max_tokens,
temperature: config.temperature,
model_config: config.model_config.clone(),
cache_config: config.cache_config.clone(),
output_schema: config.output_schema.clone(),
};
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel();
let provider_cancel = cancel.clone();
let event_tx = tx.clone();
let model_for_events = config.model.clone();
let forward_handle = crate::rt::spawn(async move {
let mut partial_message: Option<AgentMessage> = None;
let mut ended = false;
while let Some(event) = stream_rx.recv().await {
match &event {
StreamEvent::Start => {
let placeholder = AgentMessage::Llm(Message::Assistant {
content: Vec::new(),
stop_reason: StopReason::Stop,
model: model_for_events.clone(),
provider: String::new(),
usage: Usage::default(),
timestamp: now_ms(),
error_message: None,
});
partial_message = Some(placeholder.clone());
event_tx
.send(AgentEvent::MessageStart {
message: placeholder,
})
.ok();
}
StreamEvent::TextDelta { delta, .. } => {
if let Some(ref msg) = partial_message {
event_tx
.send(AgentEvent::MessageUpdate {
message: msg.clone(),
delta: StreamDelta::Text {
delta: delta.clone(),
},
})
.ok();
}
}
StreamEvent::ThinkingDelta { delta, .. } => {
if let Some(ref msg) = partial_message {
event_tx
.send(AgentEvent::MessageUpdate {
message: msg.clone(),
delta: StreamDelta::Thinking {
delta: delta.clone(),
},
})
.ok();
}
}
StreamEvent::ToolCallDelta { delta, .. } => {
if let Some(ref msg) = partial_message {
event_tx
.send(AgentEvent::MessageUpdate {
message: msg.clone(),
delta: StreamDelta::ToolCallDelta {
delta: delta.clone(),
},
})
.ok();
}
}
StreamEvent::Done { message } => {
let am: AgentMessage = message.clone().into();
partial_message = Some(am.clone());
ended = true;
event_tx.send(AgentEvent::MessageEnd { message: am }).ok();
}
StreamEvent::Error { message } => {
let am: AgentMessage = message.clone().into();
if partial_message.is_none() {
event_tx
.send(AgentEvent::MessageStart {
message: am.clone(),
})
.ok();
}
partial_message = Some(am.clone());
ended = true;
event_tx.send(AgentEvent::MessageEnd { message: am }).ok();
}
_ => {}
}
}
AttemptEvents {
started: partial_message.is_some(),
ended,
}
});
let result = config
.provider
.stream(stream_config, stream_tx, provider_cancel)
.await;
let attempt_events = forward_handle.await.unwrap_or_default();
match &result {
Err(e) if e.is_retryable() && attempt < retry.max_retries && !cancel.is_cancelled() => {
if attempt_events.needs_end() {
let failed = error_message(
&config.model,
format!(
"attempt {} of {} failed and will be retried: {e}",
attempt + 1,
retry.max_retries + 1
),
);
tx.send(AgentEvent::MessageEnd {
message: failed.into(),
})
.ok();
}
attempt += 1;
let delay = e
.retry_after()
.map(|d| d.min(std::time::Duration::from_millis(retry.max_delay_ms)))
.unwrap_or_else(|| retry.delay_for_attempt(attempt));
crate::retry::log_retry(attempt, retry.max_retries, &delay, e);
tx.send(AgentEvent::provider_retry(
attempt,
retry.max_retries + 1,
e.to_string(),
delay,
))
.ok();
let backoff = std::pin::pin!(crate::rt::sleep(delay));
let cancelled = std::pin::pin!(cancel.cancelled());
if let futures::future::Either::Right(_) =
futures::future::select(backoff, cancelled).await
{
break (Err(ProviderError::Cancelled), AttemptEvents::default());
}
continue;
}
_ => break (result, attempt_events),
}
};
let (result, attempt_events) = result;
match result {
Ok(msg) => msg,
Err(e) => {
warn!("Provider error: {}", e);
let mut failed = error_message(&config.model, e.to_string());
if matches!(e, ProviderError::Cancelled) || cancel.is_cancelled() {
if let Message::Assistant { stop_reason, .. } = &mut failed {
*stop_reason = StopReason::Aborted;
}
}
if attempt_events.needs_end() {
tx.send(AgentEvent::MessageEnd {
message: failed.clone().into(),
})
.ok();
}
failed
}
}
}
#[derive(Debug, Default, Clone, Copy)]
struct AttemptEvents {
started: bool,
ended: bool,
}
impl AttemptEvents {
fn needs_end(self) -> bool {
self.started && !self.ended
}
}
fn error_message(model: &str, error: String) -> Message {
Message::Assistant {
content: vec![Content::Text {
text: String::new(),
}],
stop_reason: StopReason::Error,
model: model.to_string(),
provider: "unknown".into(),
usage: Usage::default(),
timestamp: now_ms(),
error_message: Some(error),
}
}
struct ToolExecutionResult {
tool_results: Vec<Message>,
steering_messages: Option<Vec<AgentMessage>>,
sub_agent_stats: Vec<SessionStats>,
}
fn structured_output_is_tool_forced(model_config: Option<&ModelConfig>) -> bool {
!model_config.is_some_and(|mc| {
mc.api == crate::provider::ApiProtocol::AnthropicMessages
&& mc
.anthropic
.as_ref()
.is_some_and(|c| c.native_structured_output)
})
}
fn unwrap_structured_tool_call(
message: Message,
schema: Option<&crate::provider::OutputSchema>,
) -> Message {
let Some(schema) = schema else {
return message;
};
let Message::Assistant {
content,
model,
provider,
usage,
stop_reason,
error_message,
..
} = &message
else {
return message;
};
let Some(payload) = content.iter().find_map(|c| match c {
Content::ToolCall {
name, arguments, ..
} if *name == schema.name => Some(arguments.clone()),
_ => None,
}) else {
return message;
};
let mut new_content: Vec<Content> = content
.iter()
.filter(|c| !matches!(c, Content::ToolCall { name, .. } if *name == schema.name))
.cloned()
.collect();
new_content.push(Content::Text {
text: payload.to_string(),
});
let new_stop = if *stop_reason == StopReason::ToolUse {
StopReason::Stop
} else {
stop_reason.clone()
};
let rebuilt = Message::assistant(
new_content,
new_stop,
model.clone(),
provider.clone(),
usage.clone(),
);
match error_message {
Some(msg) => rebuilt.with_error_message(msg.clone()),
None => rebuilt,
}
}
#[derive(Clone, Copy)]
struct Gate<'a> {
middleware: &'a [Arc<dyn ToolMiddleware>],
history: &'a [AgentMessage],
}
async fn execute_tool_calls(
tools: &[Box<dyn AgentTool>],
tool_calls: &[(String, String, serde_json::Value)],
tx: &mpsc::UnboundedSender<AgentEvent>,
cancel: &tokio_util::sync::CancellationToken,
get_steering: Option<&GetMessagesFn>,
strategy: &ToolExecutionStrategy,
gate: Gate<'_>,
) -> ToolExecutionResult {
match strategy {
ToolExecutionStrategy::Sequential => {
execute_sequential(tools, tool_calls, tx, cancel, get_steering, gate).await
}
ToolExecutionStrategy::Parallel => {
execute_batch(tools, tool_calls, tx, cancel, get_steering, gate).await
}
ToolExecutionStrategy::Batched { size } => {
let mut results: Vec<Message> = Vec::new();
let mut steering_messages: Option<Vec<AgentMessage>> = None;
let mut sub_agent_stats: Vec<SessionStats> = Vec::new();
for (batch_idx, batch) in tool_calls.chunks(*size).enumerate() {
let batch_result = execute_batch(tools, batch, tx, cancel, None, gate).await;
results.extend(batch_result.tool_results);
sub_agent_stats.extend(batch_result.sub_agent_stats);
if let Some(get_steering_fn) = get_steering {
let steering = get_steering_fn();
if !steering.is_empty() {
steering_messages = Some(steering);
let executed = (batch_idx + 1) * *size;
if executed < tool_calls.len() {
for (skip_id, skip_name, _) in &tool_calls[executed..] {
results.push(skip_tool_call(skip_id, skip_name, tx));
}
}
break;
}
}
}
ToolExecutionResult {
tool_results: results,
steering_messages,
sub_agent_stats,
}
}
}
}
async fn execute_sequential(
tools: &[Box<dyn AgentTool>],
tool_calls: &[(String, String, serde_json::Value)],
tx: &mpsc::UnboundedSender<AgentEvent>,
cancel: &tokio_util::sync::CancellationToken,
get_steering: Option<&GetMessagesFn>,
gate: Gate<'_>,
) -> ToolExecutionResult {
let mut results: Vec<Message> = Vec::new();
let mut steering_messages: Option<Vec<AgentMessage>> = None;
let mut sub_agent_stats: Vec<SessionStats> = Vec::new();
for (index, (id, name, args)) in tool_calls.iter().enumerate() {
let (result_msg, delegated) =
execute_single_tool(tools, id, name, args, tx, cancel, gate).await;
results.push(result_msg);
sub_agent_stats.extend(delegated);
if let Some(get_steering_fn) = get_steering {
let steering = get_steering_fn();
if !steering.is_empty() {
steering_messages = Some(steering);
for (skip_id, skip_name, _) in &tool_calls[index + 1..] {
results.push(skip_tool_call(skip_id, skip_name, tx));
}
break;
}
}
}
ToolExecutionResult {
tool_results: results,
steering_messages,
sub_agent_stats,
}
}
async fn execute_batch(
tools: &[Box<dyn AgentTool>],
tool_calls: &[(String, String, serde_json::Value)],
tx: &mpsc::UnboundedSender<AgentEvent>,
cancel: &tokio_util::sync::CancellationToken,
get_steering: Option<&GetMessagesFn>,
gate: Gate<'_>,
) -> ToolExecutionResult {
use futures::future::join_all;
let futures: Vec<_> = tool_calls
.iter()
.map(|(id, name, args)| execute_single_tool(tools, id, name, args, tx, cancel, gate))
.collect();
let batch_results = join_all(futures).await;
let mut sub_agent_stats: Vec<SessionStats> = Vec::new();
let results: Vec<Message> = batch_results
.into_iter()
.map(|(msg, delegated)| {
sub_agent_stats.extend(delegated);
msg
})
.collect();
let steering_messages = if let Some(get_steering_fn) = get_steering {
let steering = get_steering_fn();
if steering.is_empty() {
None
} else {
Some(steering)
}
} else {
None
};
ToolExecutionResult {
tool_results: results,
steering_messages,
sub_agent_stats,
}
}
async fn execute_single_tool(
tools: &[Box<dyn AgentTool>],
id: &str,
name: &str,
args: &serde_json::Value,
tx: &mpsc::UnboundedSender<AgentEvent>,
cancel: &tokio_util::sync::CancellationToken,
gate: Gate<'_>,
) -> (Message, Vec<SessionStats>) {
if let Some(raw) = crate::provider::unparsed_tool_arguments(args) {
let (msg, _) = unparsed_arguments_tool_call(id, name, args, raw, tx);
return (msg, Vec::new());
}
let mut effective_args = args.clone();
let prompts = if gate.middleware.is_empty() {
Vec::new()
} else {
run_prompts()
};
for mw in gate.middleware {
let call = ToolCallRequest {
tool_call_id: id,
tool_name: name,
args: &effective_args,
messages: gate.history,
run_prompts: &prompts,
};
let decision = {
use futures::FutureExt;
std::panic::AssertUnwindSafe(mw.before_tool(&call))
.catch_unwind()
.await
.unwrap_or_else(|_| {
tracing::warn!(tool = name, "tool middleware panicked; denying the call");
ToolDecision::Deny("tool middleware panicked".into())
})
};
match decision {
ToolDecision::Allow => {}
ToolDecision::Modify(new_args) => effective_args = new_args,
ToolDecision::Deny(reason) => {
let (msg, _) = denied_tool_call(id, name, &effective_args, &reason, tx);
return (msg, Vec::new());
}
}
}
let args = &effective_args;
let tool = tools.iter().find(|t| t.name() == name);
tx.send(AgentEvent::ToolExecutionStart {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
args: args.clone(),
})
.ok();
let on_update: Option<ToolUpdateFn> = {
let tx = tx.clone();
let id = id.to_string();
let name = name.to_string();
Some(Arc::new(move |partial: ToolResult| {
tx.send(AgentEvent::ToolExecutionUpdate {
tool_call_id: id.clone(),
tool_name: name.clone(),
partial_result: partial,
})
.ok();
}))
};
let on_progress: Option<ProgressFn> = {
let tx = tx.clone();
let id = id.to_string();
let name = name.to_string();
Some(Arc::new(move |text: String| {
tx.send(AgentEvent::ProgressMessage {
tool_call_id: id.clone(),
tool_name: name.clone(),
text,
})
.ok();
}))
};
let sub_agent_report: SubAgentReport = Arc::default();
let ctx = ToolContext {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
cancel: cancel.child_token(),
on_update,
on_progress,
sub_agent_report: Some(sub_agent_report.clone()),
};
let tool_span = tracing::info_span!(
"tool",
tool = %name,
tool_call_id = %id,
is_error = tracing::field::Empty,
);
use tracing::Instrument;
let (result, is_error) = match tool {
Some(tool) => {
let execution = tool
.execute(args.clone(), ctx)
.instrument(tool_span.clone())
.await;
match execution {
Ok(r) => (r, false),
Err(e) => (
ToolResult {
content: vec![Content::Text {
text: e.to_string(),
}],
details: serde_json::Value::Null,
},
true,
),
}
}
None => (
ToolResult {
content: vec![Content::Text {
text: format!("Tool {} not found", name),
}],
details: serde_json::Value::Null,
},
true,
),
};
tool_span.record("is_error", is_error);
let delegated =
std::mem::take(&mut *sub_agent_report.lock().unwrap_or_else(|e| e.into_inner()));
let mut result = result;
if let Some((first, rest)) = delegated.split_first() {
let mut combined = first.clone();
for stats in rest {
combined.merge(stats);
}
attach_sub_agent_stats(&mut result.details, &combined);
}
tx.send(AgentEvent::ToolExecutionEnd {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
result: result.clone(),
is_error,
})
.ok();
let tool_result_msg = Message::ToolResult {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
content: result.content,
is_error,
timestamp: now_ms(),
};
tx.send(AgentEvent::MessageStart {
message: tool_result_msg.clone().into(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: tool_result_msg.clone().into(),
})
.ok();
(tool_result_msg, delegated)
}
fn attach_sub_agent_stats(details: &mut serde_json::Value, stats: &SessionStats) {
if details.is_null() {
*details = serde_json::json!({});
}
if let Some(obj) = details.as_object_mut() {
if !obj.contains_key(SUB_AGENT_STATS_KEY) {
if let Ok(v) = serde_json::to_value(stats) {
obj.insert(SUB_AGENT_STATS_KEY.to_string(), v);
}
}
}
}
fn denied_tool_call(
id: &str,
name: &str,
args: &serde_json::Value,
reason: &str,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> (Message, bool) {
tracing::warn!(
tool = name,
tool_call_id = id,
reason,
"tool call denied by middleware"
);
unexecuted_tool_call(id, name, args, format!("Tool call denied: {}", reason), tx)
}
fn unparsed_arguments_tool_call(
id: &str,
name: &str,
args: &serde_json::Value,
raw: &str,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> (Message, bool) {
let text = match serde_json::from_str::<serde_json::Value>(raw) {
Err(e) => {
tracing::warn!(
tool = name,
tool_call_id = id,
len = raw.len(),
parse_error = %e,
"tool call not executed: arguments did not parse as JSON"
);
if e.is_eof() {
format!(
"The arguments for tool `{name}` were cut off before they were complete \
(the response likely hit the output token limit): {e}. The tool was not \
run. Do not resend the same call unchanged — make the arguments smaller \
(for example, split large content across several calls)."
)
} else {
format!(
"The arguments for tool `{name}` are not valid JSON: {e}. The tool was not \
run. Send the arguments as a single valid JSON object."
)
}
}
Ok(v) => {
let kind = match v {
serde_json::Value::String(_) => "a string",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Null | serde_json::Value::Object(_) => "",
};
tracing::warn!(
tool = name,
tool_call_id = id,
kind,
"tool call not executed: arguments were not delivered as a JSON object"
);
if kind.is_empty() {
format!(
"The arguments for tool `{name}` were not delivered as a parsed JSON \
object. The tool was not run."
)
} else {
format!(
"The arguments for tool `{name}` were not a JSON object (got {kind}). \
The tool was not run. Send the arguments as a single JSON object — \
not encoded as a string."
)
}
}
};
unexecuted_tool_call(id, name, args, text, tx)
}
fn unexecuted_tool_call(
id: &str,
name: &str,
args: &serde_json::Value,
text: String,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> (Message, bool) {
tx.send(AgentEvent::ToolExecutionStart {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
args: args.clone(),
})
.ok();
let result = ToolResult {
content: vec![Content::Text { text }],
details: serde_json::Value::Null,
};
tx.send(AgentEvent::ToolExecutionEnd {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
result: result.clone(),
is_error: true,
})
.ok();
let msg = Message::ToolResult {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
content: result.content,
is_error: true,
timestamp: now_ms(),
};
tx.send(AgentEvent::MessageStart {
message: msg.clone().into(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: msg.clone().into(),
})
.ok();
(msg, true)
}
fn skip_tool_call(
tool_call_id: &str,
tool_name: &str,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> Message {
let result = ToolResult {
content: vec![Content::Text {
text: "Skipped due to queued user message.".into(),
}],
details: serde_json::Value::Null,
};
tx.send(AgentEvent::ToolExecutionStart {
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
args: serde_json::Value::Null,
})
.ok();
tx.send(AgentEvent::ToolExecutionEnd {
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
result: result.clone(),
is_error: true,
})
.ok();
let msg = Message::ToolResult {
tool_call_id: tool_call_id.into(),
tool_name: tool_name.into(),
content: result.content,
is_error: true,
timestamp: now_ms(),
};
tx.send(AgentEvent::MessageStart {
message: msg.clone().into(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
message: msg.clone().into(),
})
.ok();
msg
}