agent_framework_core/client.rs
1//! The chat client trait and the automatic function-invocation loop.
2//!
3//! Rust equivalent of `agent_framework._clients` plus the tool loop from
4//! `_tools.use_function_invocation`.
5
6use async_trait::async_trait;
7use futures::stream::{self, Stream, StreamExt};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::pin::Pin;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use tracing::Instrument;
14
15use crate::error::{Error, Result};
16use crate::middleware::{FunctionInvocationContext, LiveToolList, MiddlewarePipeline, Terminal};
17use crate::tools::{FunctionInvocationConfig, ToolDefinition, ToolKind};
18use crate::types::{
19 ChatOptions, ChatResponse, ChatResponseUpdate, Content, EmbeddingGenerationOptions,
20 FunctionApprovalRequestContent, FunctionApprovalResponseContent, FunctionCallContent,
21 FunctionResultContent, GeneratedEmbeddings, Message, Role, ToolMode, UsageContent,
22};
23
24/// A boxed stream of streaming chat updates.
25pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponseUpdate>> + Send>>;
26
27/// The interface every chat client implements.
28///
29/// Implementors provide [`ChatClient::get_response`] and
30/// [`ChatClient::get_streaming_response`]; the framework layers tool invocation
31/// and middleware on top via [`FunctionInvokingChatClient`].
32#[async_trait]
33pub trait ChatClient: Send + Sync {
34 /// Get a complete (non-streaming) response.
35 async fn get_response(
36 &self,
37 messages: Vec<Message>,
38 options: ChatOptions,
39 ) -> Result<ChatResponse>;
40
41 /// Get a streaming response as a sequence of updates.
42 async fn get_streaming_response(
43 &self,
44 messages: Vec<Message>,
45 options: ChatOptions,
46 ) -> Result<ChatStream>;
47
48 /// The default model id for this client, if any.
49 fn model(&self) -> Option<&str> {
50 None
51 }
52}
53
54/// Blanket impl so `Arc<dyn ChatClient>` and wrappers are usable as clients.
55#[async_trait]
56impl<T: ChatClient + ?Sized> ChatClient for Arc<T> {
57 async fn get_response(
58 &self,
59 messages: Vec<Message>,
60 options: ChatOptions,
61 ) -> Result<ChatResponse> {
62 (**self).get_response(messages, options).await
63 }
64 async fn get_streaming_response(
65 &self,
66 messages: Vec<Message>,
67 options: ChatOptions,
68 ) -> Result<ChatStream> {
69 (**self).get_streaming_response(messages, options).await
70 }
71 fn model(&self) -> Option<&str> {
72 (**self).model()
73 }
74}
75
76/// The interface every embedding client implements.
77///
78/// Rust equivalent of upstream's `SupportsGetEmbeddings` protocol /
79/// `BaseEmbeddingClient` (`_clients.py`): generate one embedding per input
80/// string, batched in a single request. Vectors are `Vec<f32>` — see the
81/// note on [`crate::types::Embedding`] about upstream's genericity.
82#[async_trait]
83pub trait EmbeddingClient: Send + Sync {
84 /// Generate embeddings for the given values (one per value, in order).
85 async fn get_embeddings(
86 &self,
87 values: Vec<String>,
88 options: Option<EmbeddingGenerationOptions>,
89 ) -> Result<GeneratedEmbeddings>;
90
91 /// The default embedding model id for this client, if any.
92 fn model(&self) -> Option<&str> {
93 None
94 }
95}
96
97/// Blanket impl so `Arc<dyn EmbeddingClient>` and wrappers are usable as
98/// clients.
99#[async_trait]
100impl<T: EmbeddingClient + ?Sized> EmbeddingClient for Arc<T> {
101 async fn get_embeddings(
102 &self,
103 values: Vec<String>,
104 options: Option<EmbeddingGenerationOptions>,
105 ) -> Result<GeneratedEmbeddings> {
106 (**self).get_embeddings(values, options).await
107 }
108 fn model(&self) -> Option<&str> {
109 (**self).model()
110 }
111}
112
113/// Wraps a [`ChatClient`] to automatically execute local tool calls in a loop,
114/// mirroring `use_function_invocation`.
115pub struct FunctionInvokingChatClient<C: ChatClient> {
116 inner: C,
117 config: FunctionInvocationConfig,
118 /// Middleware run around every individual tool call (mirrors Python's
119 /// function-middleware pipeline, driven here instead of by a
120 /// `use_function_invocation` decorator).
121 function_middleware: MiddlewarePipeline<FunctionInvocationContext>,
122}
123
124impl<C: ChatClient> FunctionInvokingChatClient<C> {
125 pub fn new(inner: C) -> Self {
126 Self {
127 inner,
128 config: FunctionInvocationConfig::default(),
129 function_middleware: MiddlewarePipeline::default(),
130 }
131 }
132
133 /// Override the function-invocation configuration.
134 pub fn with_config(mut self, config: FunctionInvocationConfig) -> Self {
135 self.config = config;
136 self
137 }
138
139 /// Configure the function-invocation middleware pipeline run around every
140 /// tool call: middleware may inspect/rewrite
141 /// [`FunctionInvocationContext::arguments`], short-circuit execution by
142 /// setting [`FunctionInvocationContext::result`] (and either not calling
143 /// `next`, or setting `terminate = true`), or observe a propagated
144 /// execution error by matching on the `Result` returned from their own
145 /// `next.run(...)` call. Replaces any previously configured middleware.
146 pub fn with_function_middleware(
147 mut self,
148 middleware: Vec<Arc<crate::middleware::FunctionMiddleware>>,
149 ) -> Self {
150 self.function_middleware = MiddlewarePipeline::new(middleware);
151 self
152 }
153
154 /// A reference to the wrapped client.
155 pub fn inner(&self) -> &C {
156 &self.inner
157 }
158
159 async fn inner_get_response(
160 &self,
161 messages: Vec<Message>,
162 options: ChatOptions,
163 ) -> Result<ChatResponse> {
164 self.inner.get_response(messages, options).await
165 }
166}
167
168/// Extract the executable tools from the options into a name→tool map.
169fn executable_tools(options: &ChatOptions) -> Vec<ToolDefinition> {
170 options
171 .tools
172 .iter()
173 .filter(|t| t.is_executable())
174 .cloned()
175 .collect()
176}
177
178/// Whether `tool` is a *declaration-only* function tool: a known function with
179/// no local executor. Mirrors Python's `AIFunction.declaration_only`. A call to
180/// such a tool is returned to the caller unexecuted (the frontend-tool pattern
181/// that makes AG-UI client-side tools work). Hosted tools (web search, MCP, …)
182/// are deliberately excluded — they are not function tools and a call whose
183/// name matches none of the local function tools is treated as unknown, not
184/// declaration-only, exactly as Python's `_get_tool_map` omits them.
185fn is_declaration_only(tool: &ToolDefinition) -> bool {
186 tool.kind == ToolKind::Function && tool.executor.is_none()
187}
188
189/// The exact rejection payload Python emits for a denied tool call.
190const REJECTION_MESSAGE: &str = "Error: Tool call invocation was rejected by user.";
191
192/// Execute a single requested tool call through the function-middleware
193/// pipeline, with the actual invocation (wrapped in an `execute_tool` span)
194/// as the pipeline's terminal handler.
195///
196/// Returns `(is_error, result)`. `terminate_on_unknown` turns an unknown-tool
197/// call into a hard error (propagated) rather than an error result. Unknown
198/// tools and unparseable arguments are rejected before middleware ever sees
199/// them (there is no function to hand the pipeline in that case); once a
200/// [`FunctionInvocationContext`] is built, middleware can rewrite
201/// `arguments`, short-circuit by setting `result` (without calling `next`, or
202/// with `terminate = true`), or observe an execution error by matching on the
203/// `Result` their own `next.run(...)` call returns. A propagated error is
204/// converted to the same `(true, FunctionResultContent { exception: .. })`
205/// shape the direct-error path used before middleware existed, so
206/// `include_detailed_errors` behaves identically either way.
207async fn execute_tool_call(
208 tool: Option<ToolDefinition>,
209 call: &FunctionCallContent,
210 include_detailed_errors: bool,
211 terminate_on_unknown: bool,
212 function_middleware: &MiddlewarePipeline<FunctionInvocationContext>,
213 session: Option<&crate::session::AgentSession>,
214 live_tools: Option<&LiveToolList>,
215) -> Result<(bool, FunctionResultContent)> {
216 match tool {
217 None => {
218 if terminate_on_unknown {
219 return Err(Error::tool(format!("unknown tool: {}", call.name)));
220 }
221 Ok((
222 true,
223 FunctionResultContent {
224 call_id: call.call_id.clone(),
225 result: None,
226 exception: Some(format!("tool '{}' not found", call.name)),
227 },
228 ))
229 }
230 Some(def) => {
231 // Reject unparseable arguments rather than silently invoking the tool
232 // with null/default input.
233 let args = match call.parse_arguments() {
234 Ok(m) => Value::Object(m.into_iter().collect()),
235 Err(e) => {
236 let msg = if include_detailed_errors {
237 format!("invalid tool arguments: {e}")
238 } else {
239 "invalid tool arguments".to_string()
240 };
241 return Ok((
242 true,
243 FunctionResultContent {
244 call_id: call.call_id.clone(),
245 result: None,
246 exception: Some(msg),
247 },
248 ));
249 }
250 };
251 let exec = def.executor.as_ref().unwrap().clone();
252 let tool_name = def.name.clone();
253 let description = def.description.clone();
254 let call_id = call.call_id.clone();
255 let terminal: Terminal<FunctionInvocationContext> = Box::new(move |mut ctx| {
256 Box::pin(async move {
257 if ctx.terminate {
258 return Ok(ctx);
259 }
260 let span = crate::observability::tool_span_ex(
261 &tool_name,
262 &call_id,
263 Some(&description),
264 );
265 let capture =
266 crate::observability::ObservabilityConfig::from_env().enable_sensitive_data;
267 crate::observability::record_tool_arguments(&span, &ctx.arguments, capture);
268 #[cfg(feature = "otel-metrics")]
269 let started = std::time::Instant::now();
270 let outcome = async {
271 let result = exec.invoke_in_context(ctx.arguments.clone(), &ctx).await;
272 if let Err(e) = &result {
273 crate::observability::record_error(&tracing::Span::current(), e);
274 }
275 result
276 }
277 .instrument(span.clone())
278 .await;
279 #[cfg(feature = "otel-metrics")]
280 crate::observability::metrics::record_function_invocation_duration(
281 &tool_name,
282 started.elapsed(),
283 outcome
284 .as_ref()
285 .err()
286 .map(crate::observability::error_type)
287 .as_deref(),
288 );
289 if let Ok(value) = &outcome {
290 crate::observability::record_tool_result(&span, value, capture);
291 }
292 ctx.result = Some(outcome?);
293 Ok(ctx)
294 }) as crate::tools::BoxFuture<Result<FunctionInvocationContext>>
295 });
296
297 let ctx = FunctionInvocationContext::new(call.name.clone(), args)
298 .with_session(session.cloned())
299 .with_tools(live_tools.cloned());
300 match function_middleware.execute(ctx, terminal).await {
301 Ok(ctx) => Ok((
302 false,
303 FunctionResultContent {
304 call_id: call.call_id.clone(),
305 result: Some(ctx.result.unwrap_or(Value::Null)),
306 exception: None,
307 },
308 )),
309 Err(e) => {
310 let msg = if include_detailed_errors {
311 format!("{e}")
312 } else {
313 "tool execution failed".to_string()
314 };
315 Ok((
316 true,
317 FunctionResultContent {
318 call_id: call.call_id.clone(),
319 result: None,
320 exception: Some(msg),
321 },
322 ))
323 }
324 }
325 }
326 }
327}
328
329/// Collect all function-approval responses present in a conversation.
330fn collect_approval_responses(messages: &[Message]) -> Vec<FunctionApprovalResponseContent> {
331 let mut out = Vec::new();
332 for msg in messages {
333 for content in &msg.contents {
334 if let Content::FunctionApprovalResponse(resp) = content {
335 out.push(resp.clone());
336 }
337 }
338 }
339 out
340}
341
342/// Rewrite approval request/response contents in place, mirroring Python's
343/// `_replace_approval_contents_with_results`.
344///
345/// * A [`FunctionApprovalRequestContent`] becomes its embedded
346/// [`FunctionCallContent`], unless that call already exists in the same
347/// message (a duplicate), in which case the request is removed.
348/// * An approved [`FunctionApprovalResponseContent`] becomes the corresponding
349/// result (correlated strictly by call id) and the message role becomes
350/// `tool`.
351/// * A rejected response becomes a [`FunctionResultContent`] carrying the
352/// rejection payload, and the message role becomes `tool`.
353fn replace_approval_contents_with_results(
354 messages: &mut [Message],
355 approved_results: &HashMap<String, FunctionResultContent>,
356) {
357 for msg in messages.iter_mut() {
358 let existing_call_ids: std::collections::HashSet<String> = msg
359 .contents
360 .iter()
361 .filter_map(Content::as_function_call)
362 .filter(|fc| !fc.call_id.is_empty())
363 .map(|fc| fc.call_id.clone())
364 .collect();
365
366 let mut to_remove: Vec<usize> = Vec::new();
367 let mut set_role_tool = false;
368
369 for (idx, content) in msg.contents.iter_mut().enumerate() {
370 match content {
371 Content::FunctionApprovalRequest(req) => {
372 if existing_call_ids.contains(&req.function_call.call_id) {
373 to_remove.push(idx);
374 } else {
375 *content = Content::FunctionCall(req.function_call.clone());
376 }
377 }
378 Content::FunctionApprovalResponse(resp) => {
379 let call_id = resp.function_call.call_id.clone();
380 if resp.approved {
381 if let Some(result) = approved_results.get(&call_id) {
382 *content = Content::FunctionResult(result.clone());
383 set_role_tool = true;
384 }
385 } else {
386 *content = Content::FunctionResult(FunctionResultContent {
387 call_id,
388 result: Some(Value::String(REJECTION_MESSAGE.to_string())),
389 exception: None,
390 });
391 set_role_tool = true;
392 }
393 }
394 _ => {}
395 }
396 }
397
398 for idx in to_remove.into_iter().rev() {
399 msg.contents.remove(idx);
400 }
401 if set_role_tool {
402 msg.role = Role::tool();
403 }
404 }
405}
406
407#[async_trait]
408impl<C: ChatClient> ChatClient for FunctionInvokingChatClient<C> {
409 async fn get_response(
410 &self,
411 messages: Vec<Message>,
412 mut options: ChatOptions,
413 ) -> Result<ChatResponse> {
414 // After the tool loop settles, auto-populate `ChatResponse.value` from
415 // the final text when a structured `response_format` was requested
416 // (mirrors Python `try_parse_value`). This is the central non-streaming
417 // fill point: it covers a bare `FunctionInvokingChatClient` and every
418 // `Agent` run (whose client is always wrapped in one). The tool
419 // loop is run inside an `async move` block so its interior `return`s
420 // funnel through this single fill/return path.
421 let response_format = options.response_format.clone();
422 let mut response: ChatResponse = async move {
423 self.config.validate()?;
424 // Pop the agent-session side channel before the inner provider
425 // client ever sees the options (mirrors upstream's
426 // `effective_client_kwargs.pop("session")`); it is handed to
427 // invoked tools via `FunctionInvocationContext::session`.
428 let session = options.session.take();
429
430 // Default tool choice to auto when tools are present and unset.
431 if !options.tools.is_empty() && options.tool_choice.is_none() {
432 options.tool_choice = Some(ToolMode::Auto);
433 }
434
435 if executable_tools(&options).is_empty() || !self.config.enabled {
436 return self.inner_get_response(messages, options).await;
437 }
438
439 // The run's live tool list (progressive tool exposure): handed to
440 // every invocation via `FunctionInvocationContext::tools`, and
441 // re-snapshotted into the wire options at the top of every model
442 // iteration — so `add_tools`/`remove_tools` from middleware or
443 // tools take effect on the NEXT iteration, never the in-flight
444 // batch (mirrors upstream `_middleware.py` add_tools/remove_tools
445 // semantics).
446 let live_tools = LiveToolList::new(std::mem::take(&mut options.tools));
447
448 let mut conversation = messages;
449 let mut carried: Vec<Message> = Vec::new();
450 let mut consecutive_errors = 0usize;
451
452 for _ in 0..self.config.max_iterations {
453 options.tools = live_tools.snapshot();
454 let tools = executable_tools(&options);
455 // Process any function-approval responses supplied in the input:
456 // execute the approved calls and splice their results into the
457 // conversation (mirrors Python's `_collect_approval_responses` +
458 // `_replace_approval_contents_with_results`).
459 let approval_responses = collect_approval_responses(&conversation);
460 if !approval_responses.is_empty() {
461 let mut approved_results: HashMap<String, FunctionResultContent> =
462 HashMap::new();
463 let mut had_error = false;
464 for resp in &approval_responses {
465 if !resp.approved {
466 continue;
467 }
468 let call = &resp.function_call;
469 let tool = tools.iter().find(|t| t.name == call.name).cloned();
470 let (is_error, content) = execute_tool_call(
471 tool,
472 call,
473 self.config.include_detailed_errors,
474 self.config.terminate_on_unknown_calls,
475 &self.function_middleware,
476 session.as_ref(),
477 Some(&live_tools),
478 )
479 .await?;
480 had_error |= is_error;
481 approved_results.insert(content.call_id.clone(), content);
482 }
483 replace_approval_contents_with_results(&mut conversation, &approved_results);
484 if had_error {
485 consecutive_errors += 1;
486 if consecutive_errors > self.config.max_consecutive_errors_per_request {
487 options.tool_choice = Some(ToolMode::None);
488 }
489 }
490 }
491
492 let response = self
493 .inner_get_response(conversation.clone(), options.clone())
494 .await?;
495
496 // A call whose result is already present in the same response
497 // was executed by the provider (e.g. Anthropic server-side
498 // web-search/code-execution/MCP `server_tool_use` blocks,
499 // which arrive paired with their `*_tool_result`). Executing
500 // it locally would produce a bogus "tool not found" — only
501 // unresolved calls enter the local tool loop.
502 let resolved_call_ids: std::collections::HashSet<&str> = response
503 .messages
504 .iter()
505 .flat_map(|m| m.contents.iter())
506 .filter_map(Content::as_function_result)
507 .map(|fr| fr.call_id.as_str())
508 .collect();
509 let calls: Vec<_> = response
510 .messages
511 .iter()
512 .flat_map(|m| m.contents.iter())
513 .filter_map(Content::as_function_call)
514 .filter(|fc| !resolved_call_ids.contains(fc.call_id.as_str()))
515 .cloned()
516 .collect();
517
518 if calls.is_empty() {
519 // Prepend the accumulated tool-interaction messages so the final
520 // assistant message stays last.
521 let mut final_resp = response;
522 let mut msgs = std::mem::take(&mut carried);
523 msgs.append(&mut final_resp.messages);
524 final_resp.messages = msgs;
525 return Ok(final_resp);
526 }
527
528 // Human-in-the-loop gate: if *any* requested tool requires approval,
529 // defer *all* calls (matching Python) and return an assistant message
530 // that carries the original calls plus one approval request each.
531 let needs_approval = calls.iter().any(|c| {
532 tools
533 .iter()
534 .find(|t| t.name == c.name)
535 .map(ToolDefinition::requires_approval)
536 .unwrap_or(false)
537 });
538 if needs_approval {
539 let mut resp = response;
540 let approval_contents: Vec<Content> = calls
541 .iter()
542 .map(|c| {
543 Content::FunctionApprovalRequest(FunctionApprovalRequestContent {
544 id: c.call_id.clone(),
545 function_call: c.clone(),
546 })
547 })
548 .collect();
549 if let Some(m) = resp
550 .messages
551 .iter_mut()
552 .rev()
553 .find(|m| m.role == Role::assistant())
554 {
555 m.contents.extend(approval_contents);
556 } else {
557 resp.messages
558 .push(Message::with_contents(Role::assistant(), approval_contents));
559 }
560 let mut msgs = std::mem::take(&mut carried);
561 msgs.append(&mut resp.messages);
562 resp.messages = msgs;
563 return Ok(resp);
564 }
565
566 // Declaration-only calls: a call targeting a KNOWN tool that has
567 // no local executor (declaration-only — e.g. an AG-UI frontend
568 // tool, or a per-run `additional_tools` entry) terminates the
569 // loop and returns the response with the `FunctionCallContent`
570 // intact, so the caller can execute it. Mirrors Python's
571 // `_try_execute_function_calls` `declaration_only` branch
572 // (`_tools.py:1396-1420`): if *any* requested call is
573 // declaration-only, the whole response is returned unexecuted.
574 // A genuinely unknown tool name is NOT declaration-only and
575 // keeps today's not-found handling in `execute_tool_call`.
576 let has_declaration_only = calls.iter().any(|c| {
577 options
578 .tools
579 .iter()
580 .any(|t| t.name == c.name && is_declaration_only(t))
581 });
582 if has_declaration_only {
583 let mut resp = response;
584 let mut msgs = std::mem::take(&mut carried);
585 msgs.append(&mut resp.messages);
586 resp.messages = msgs;
587 return Ok(resp);
588 }
589
590 // Record the assistant message(s) that requested the calls.
591 carried.extend(response.messages.iter().cloned());
592 let response_conversation_id = response.conversation_id.clone();
593
594 // Execute all calls concurrently: the model may emit several
595 // parallel tool calls, and I/O-bound tools should not be serialized.
596 let invocations = calls.iter().map(|call| {
597 let tool = tools.iter().find(|t| t.name == call.name).cloned();
598 let call = call.clone();
599 let include_detailed_errors = self.config.include_detailed_errors;
600 let terminate_on_unknown = self.config.terminate_on_unknown_calls;
601 let function_middleware = self.function_middleware.clone();
602 let session = session.clone();
603 let live_tools = live_tools.clone();
604 async move {
605 execute_tool_call(
606 tool,
607 &call,
608 include_detailed_errors,
609 terminate_on_unknown,
610 &function_middleware,
611 session.as_ref(),
612 Some(&live_tools),
613 )
614 .await
615 }
616 });
617
618 let outcomes = futures::future::try_join_all(invocations).await?;
619 let mut result_contents: Vec<Content> = Vec::with_capacity(outcomes.len());
620 let mut had_error = false;
621 for (is_error, content) in outcomes {
622 had_error |= is_error;
623 result_contents.push(Content::FunctionResult(content));
624 }
625
626 if had_error {
627 consecutive_errors += 1;
628 if consecutive_errors > self.config.max_consecutive_errors_per_request {
629 // Give up on tools and let the model answer directly.
630 options.tool_choice = Some(ToolMode::None);
631 }
632 } else {
633 consecutive_errors = 0;
634 }
635
636 let tool_message = Message::with_contents(Role::tool(), result_contents);
637 carried.push(tool_message.clone());
638 match response_conversation_id {
639 // A service-managed client that created (or continued) the
640 // conversation now holds the history server-side. Propagate
641 // its id so the follow-up tool-output submission targets the
642 // right thread — without this, Assistants / Azure AI reject
643 // the submission because `conversation_id` is still `None` —
644 // and send ONLY the new tool results next turn rather than
645 // re-sending the whole history (mirrors Python
646 // `_tools.py:1635-1637, 1695-1699`).
647 Some(cid) => {
648 options.conversation_id = Some(cid);
649 conversation = vec![tool_message];
650 }
651 // Stateless client (e.g. Chat Completions): accumulate and
652 // re-send the full history each turn.
653 None => {
654 conversation.extend(response.messages);
655 conversation.push(tool_message);
656 }
657 }
658 }
659
660 // Failsafe: one final call with tools disabled.
661 options.tool_choice = Some(ToolMode::None);
662 let mut final_resp = self.inner_get_response(conversation, options).await?;
663 let mut msgs = std::mem::take(&mut carried);
664 msgs.append(&mut final_resp.messages);
665 final_resp.messages = msgs;
666 Ok(final_resp)
667 }
668 .await?;
669 response.try_parse_value(response_format.as_ref());
670 Ok(response)
671 }
672
673 async fn get_streaming_response(
674 &self,
675 messages: Vec<Message>,
676 options: ChatOptions,
677 ) -> Result<ChatStream> {
678 let tools = executable_tools(&options);
679 if tools.is_empty() || !self.config.enabled {
680 return self.inner.get_streaming_response(messages, options).await;
681 }
682 // With tools, run the full loop then stream the aggregated result.
683 // Each message is replayed as its own update with a stable, distinct
684 // `message_id` so that consumers re-aggregating via
685 // `ChatResponse::from_updates` keep the messages separate rather than
686 // merging the tool-call and final assistant messages by role.
687 let response = self.get_response(messages, options).await?;
688 // Response-level metadata must survive the replay so re-aggregation
689 // (and the agent's thread adoption) sees it: ids on every update,
690 // and usage/finish-reason on the final one (usage rides as a
691 // `Content::Usage` item, which `absorb_update` folds into
692 // `usage_details` rather than the message contents — the same shape
693 // providers use for their terminal stream chunk).
694 let conversation_id = response.conversation_id.clone();
695 let response_id = response.response_id.clone();
696 let finish_reason = response.finish_reason.clone();
697 let usage_details = response.usage_details.clone();
698 let last = response.messages.len().saturating_sub(1);
699 // Keep the provider message ids only when they're all present and
700 // distinct; otherwise use positional ids for every message. A service
701 // (e.g. Assistants) can reuse one run id for both the tool-call turn
702 // and the final assistant turn, and `ChatResponse::from_updates` keys
703 // messages by id — a duplicate would merge the final answer into the
704 // tool-call message, ahead of the tool result.
705 let keep_provider_ids = {
706 let mut seen = std::collections::HashSet::new();
707 response.messages.iter().all(|m| {
708 m.message_id
709 .as_ref()
710 .is_some_and(|id| !id.is_empty() && seen.insert(id.as_str()))
711 })
712 };
713 let mut updates: Vec<Result<ChatResponseUpdate>> = response
714 .messages
715 .into_iter()
716 .enumerate()
717 .map(|(i, m)| {
718 let message_id = if keep_provider_ids {
719 m.message_id.clone()
720 } else {
721 Some(format!("replay-{i}"))
722 };
723 let mut contents = m.contents;
724 let is_last = i == last;
725 if is_last {
726 if let Some(usage) = usage_details.clone() {
727 contents.push(Content::Usage(UsageContent { details: usage }));
728 }
729 }
730 Ok(ChatResponseUpdate {
731 contents,
732 role: Some(m.role),
733 author_name: m.author_name,
734 message_id,
735 conversation_id: conversation_id.clone(),
736 response_id: response_id.clone(),
737 finish_reason: is_last.then(|| finish_reason.clone()).flatten(),
738 ..Default::default()
739 })
740 })
741 .collect();
742 // A messageless response (unusual, but possible) still carries its
743 // terminal metadata in one trailing update.
744 if updates.is_empty() && (usage_details.is_some() || finish_reason.is_some()) {
745 let contents = usage_details
746 .map(|u| vec![Content::Usage(UsageContent { details: u })])
747 .unwrap_or_default();
748 updates.push(Ok(ChatResponseUpdate {
749 contents,
750 role: Some(Role::assistant()),
751 conversation_id,
752 response_id,
753 finish_reason,
754 ..Default::default()
755 }));
756 }
757 Ok(stream::iter(updates).boxed())
758 }
759
760 fn model(&self) -> Option<&str> {
761 self.inner.model()
762 }
763}
764
765// ---------------------------------------------------------------------------
766// Retry / backoff layer
767// ---------------------------------------------------------------------------
768
769/// Which errors a [`RetryPolicy`] considers retryable.
770#[derive(Clone)]
771pub enum RetryOn {
772 /// The built-in default predicate (see [`RetryPolicy`] docs for the exact
773 /// rule): retries HTTP `408`/`429`/`5xx` ([`Error::ServiceStatus`]) and
774 /// transport-ish [`Error::Service`] failures (timeouts / connection
775 /// errors). Never retries [`Error::ServiceInvalidAuth`],
776 /// [`Error::ServiceInvalidRequest`], or [`Error::ServiceContentFilter`] —
777 /// authentication/authorization failures, malformed requests, and
778 /// content-filter refusals are non-transient, so retrying would just
779 /// repeat the same rejection.
780 Default,
781 /// A fully custom predicate deciding, per error, whether to retry.
782 Predicate(Arc<dyn Fn(&Error) -> bool + Send + Sync>),
783}
784
785impl std::fmt::Debug for RetryOn {
786 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787 match self {
788 RetryOn::Default => f.write_str("RetryOn::Default"),
789 RetryOn::Predicate(_) => f.write_str("RetryOn::Predicate(..)"),
790 }
791 }
792}
793
794impl RetryOn {
795 /// A custom retry predicate.
796 pub fn predicate<F>(f: F) -> Self
797 where
798 F: Fn(&Error) -> bool + Send + Sync + 'static,
799 {
800 RetryOn::Predicate(Arc::new(f))
801 }
802
803 fn should_retry(&self, err: &Error) -> bool {
804 match self {
805 RetryOn::Default => default_should_retry(err),
806 RetryOn::Predicate(p) => p(err),
807 }
808 }
809}
810
811/// The default retryability rule used by [`RetryOn::Default`].
812///
813/// Retries when either:
814/// * the error is an [`Error::ServiceStatus`] whose status is `408`
815/// (Request Timeout), `429` (Too Many Requests), or any `5xx`; or
816/// * the error is an [`Error::Service`] whose (lowercased) message contains one
817/// of the transport-failure markers the provider clients emit — `"request
818/// failed"` (the prefix wrapping every `reqwest` send error: DNS, connect,
819/// timeout, reset), `"timed out"`, `"timeout"`, `"connection"`, or `"stream
820/// error"`.
821///
822/// Everything else (4xx other than 408/429, parse errors, tool/workflow errors,
823/// non-transport service errors) is treated as non-retryable. This explicitly
824/// includes [`Error::ServiceInvalidAuth`], [`Error::ServiceInvalidRequest`],
825/// and [`Error::ServiceContentFilter`] — authentication/authorization
826/// failures, malformed requests, and content-filter refusals are
827/// non-transient, so retrying would just repeat the same rejection. None of
828/// the three carry a status via [`Error::status`], so they fall through to
829/// the final `_ => false` below (there's no dedicated match arm for them:
830/// merging one in would just duplicate that `false`, which `clippy` flags as
831/// `match_same_arms`).
832fn default_should_retry(err: &Error) -> bool {
833 if let Some(status) = err.status() {
834 return status == 408 || status == 429 || (500..600).contains(&status);
835 }
836 match err {
837 Error::Service(msg) => {
838 let m = msg.to_lowercase();
839 m.contains("request failed")
840 || m.contains("timed out")
841 || m.contains("timeout")
842 || m.contains("connection")
843 || m.contains("stream error")
844 }
845 _ => false,
846 }
847}
848
849/// Policy controlling [`RetryingChatClient`] backoff.
850///
851/// Delays grow exponentially from [`initial_delay`](Self::initial_delay) by
852/// [`backoff_multiplier`](Self::backoff_multiplier) per attempt, are capped at
853/// [`max_delay`](Self::max_delay), and are then reduced by up to
854/// [`jitter`](Self::jitter) (a fraction of the delay). When the failing error
855/// carries a server `Retry-After` (see [`Error::retry_after`]) that value is
856/// used instead of the computed backoff (still capped by `max_delay`, and not
857/// jittered — it is an explicit server instruction).
858#[derive(Clone, Debug)]
859pub struct RetryPolicy {
860 /// Maximum number of *retries* after the initial attempt (default `3`, so
861 /// up to four total attempts).
862 pub max_retries: usize,
863 /// Base delay before the first retry (default `500ms`).
864 pub initial_delay: Duration,
865 /// Upper bound on any single delay, also capping a server `Retry-After`
866 /// (default `30s`).
867 pub max_delay: Duration,
868 /// Exponential growth factor applied per retry (default `2.0`).
869 pub backoff_multiplier: f64,
870 /// Jitter as a fraction in `0.0..=1.0` (default `0.3`): the computed delay
871 /// is multiplied by `1 - jitter * r` for a per-attempt random `r` in
872 /// `[0, 1)`. `0.0` disables jitter (fully deterministic delays).
873 pub jitter: f64,
874 /// Which errors to retry (default [`RetryOn::Default`]).
875 pub retry_on: RetryOn,
876}
877
878impl Default for RetryPolicy {
879 fn default() -> Self {
880 Self {
881 max_retries: 3,
882 initial_delay: Duration::from_millis(500),
883 max_delay: Duration::from_secs(30),
884 backoff_multiplier: 2.0,
885 jitter: 0.3,
886 retry_on: RetryOn::Default,
887 }
888 }
889}
890
891impl RetryPolicy {
892 /// A policy with the given retry count and otherwise-default backoff.
893 pub fn with_max_retries(max_retries: usize) -> Self {
894 Self {
895 max_retries,
896 ..Self::default()
897 }
898 }
899
900 /// Set the base delay before the first retry.
901 pub fn initial_delay(mut self, delay: Duration) -> Self {
902 self.initial_delay = delay;
903 self
904 }
905
906 /// Set the per-delay cap (also caps a server `Retry-After`).
907 pub fn max_delay(mut self, delay: Duration) -> Self {
908 self.max_delay = delay;
909 self
910 }
911
912 /// Set the exponential growth factor.
913 pub fn backoff_multiplier(mut self, multiplier: f64) -> Self {
914 self.backoff_multiplier = multiplier;
915 self
916 }
917
918 /// Set the jitter fraction (clamped to `0.0..=1.0`).
919 pub fn jitter(mut self, jitter: f64) -> Self {
920 self.jitter = jitter.clamp(0.0, 1.0);
921 self
922 }
923
924 /// Set the retryability rule.
925 pub fn retry_on(mut self, retry_on: RetryOn) -> Self {
926 self.retry_on = retry_on;
927 self
928 }
929
930 /// The delay to wait before a retry, given the 1-based `attempt` number
931 /// (attempt `1` is the first retry) and the error that triggered it.
932 fn delay_for(&self, attempt: usize, err: &Error) -> Duration {
933 // A server-advised `Retry-After` wins over computed backoff (capped by
934 // `max_delay`, not jittered — it is an explicit instruction).
935 if let Some(secs) = err.retry_after() {
936 let capped = secs.min(self.max_delay.as_secs_f64()).max(0.0);
937 return Duration::from_secs_f64(capped);
938 }
939 let exp = self.backoff_multiplier.powi((attempt - 1) as i32);
940 let base = self.initial_delay.as_secs_f64() * exp;
941 let capped = base.min(self.max_delay.as_secs_f64());
942 let jittered = capped * jitter_factor(self.jitter);
943 Duration::from_secs_f64(jittered.max(0.0))
944 }
945}
946
947/// A cheap jitter multiplier in `[1 - jitter, 1.0]`, without a `rand`
948/// dependency: entropy comes from the current wall-clock nanoseconds mixed
949/// with a process-lifetime counter (so repeated calls within the same
950/// nanosecond still differ). `jitter <= 0` returns `1.0` (no jitter).
951fn jitter_factor(jitter: f64) -> f64 {
952 let jitter = jitter.clamp(0.0, 1.0);
953 if jitter == 0.0 {
954 return 1.0;
955 }
956 use std::sync::atomic::{AtomicU64, Ordering};
957 static COUNTER: AtomicU64 = AtomicU64::new(0);
958 let nanos = SystemTime::now()
959 .duration_since(UNIX_EPOCH)
960 .map(|d| d.as_nanos() as u64)
961 .unwrap_or(0);
962 let mixed = nanos ^ COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
963 // Map to [0, 1) via the top 53 bits (f64 mantissa width).
964 let r = (mixed >> 11) as f64 / ((1u64 << 53) as f64);
965 1.0 - jitter * r
966}
967
968/// A [`ChatClient`] decorator that retries transient failures with exponential
969/// backoff, honoring a server `Retry-After` when present.
970///
971/// Wraps any inner [`ChatClient`] and re-issues the request per its
972/// [`RetryPolicy`]. For streaming, only the *initial connection* is retried:
973/// if establishing the stream (or its very first item, before anything is
974/// yielded to the consumer) fails with a retryable error, the connection is
975/// re-attempted; once the first update flows, later stream errors propagate
976/// unchanged.
977///
978/// ```no_run
979/// # use std::time::Duration;
980/// # use agent_framework_core::client::{RetryingChatClient, RetryPolicy};
981/// # use agent_framework_core::prelude::*;
982/// # fn demo(inner: impl ChatClient + 'static) {
983/// let client = RetryingChatClient::new(inner)
984/// .with_policy(RetryPolicy::with_max_retries(5).initial_delay(Duration::from_millis(200)));
985/// # let _ = client;
986/// # }
987/// ```
988pub struct RetryingChatClient<C: ChatClient> {
989 inner: C,
990 policy: RetryPolicy,
991}
992
993impl<C: ChatClient> RetryingChatClient<C> {
994 /// Wrap `inner` with the default [`RetryPolicy`].
995 pub fn new(inner: C) -> Self {
996 Self {
997 inner,
998 policy: RetryPolicy::default(),
999 }
1000 }
1001
1002 /// Set the retry policy (builder-style).
1003 pub fn with_policy(mut self, policy: RetryPolicy) -> Self {
1004 self.policy = policy;
1005 self
1006 }
1007
1008 /// A reference to the wrapped client.
1009 pub fn inner(&self) -> &C {
1010 &self.inner
1011 }
1012
1013 /// A reference to the active retry policy.
1014 pub fn policy(&self) -> &RetryPolicy {
1015 &self.policy
1016 }
1017
1018 /// Sleep before a retry, emitting a tracing warning describing the attempt.
1019 async fn backoff(&self, attempt: usize, err: &Error) {
1020 let delay = self.policy.delay_for(attempt, err);
1021 tracing::warn!(
1022 attempt,
1023 max_retries = self.policy.max_retries,
1024 delay_ms = delay.as_millis() as u64,
1025 retry_after = err.retry_after(),
1026 status = err.status(),
1027 error = %err,
1028 "retrying chat request after transient error"
1029 );
1030 tokio::time::sleep(delay).await;
1031 }
1032}
1033
1034#[async_trait]
1035impl<C: ChatClient> ChatClient for RetryingChatClient<C> {
1036 async fn get_response(
1037 &self,
1038 messages: Vec<Message>,
1039 options: ChatOptions,
1040 ) -> Result<ChatResponse> {
1041 let mut attempt = 0usize;
1042 loop {
1043 match self
1044 .inner
1045 .get_response(messages.clone(), options.clone())
1046 .await
1047 {
1048 Ok(resp) => return Ok(resp),
1049 Err(e) => {
1050 if attempt >= self.policy.max_retries || !self.policy.retry_on.should_retry(&e)
1051 {
1052 return Err(e);
1053 }
1054 attempt += 1;
1055 self.backoff(attempt, &e).await;
1056 }
1057 }
1058 }
1059 }
1060
1061 async fn get_streaming_response(
1062 &self,
1063 messages: Vec<Message>,
1064 options: ChatOptions,
1065 ) -> Result<ChatStream> {
1066 let mut attempt = 0usize;
1067 loop {
1068 let established = self
1069 .inner
1070 .get_streaming_response(messages.clone(), options.clone())
1071 .await;
1072 match established {
1073 // The stream opened: peek its first item. An error there (with
1074 // nothing yet yielded to the consumer) is still an
1075 // initial-connection failure and is eligible for retry; any Ok
1076 // item — or a non-retryable / retries-exhausted error — is
1077 // handed back with the rest of the stream chained after it.
1078 Ok(mut stream) => match stream.next().await {
1079 Some(Err(e))
1080 if attempt < self.policy.max_retries
1081 && self.policy.retry_on.should_retry(&e) =>
1082 {
1083 attempt += 1;
1084 self.backoff(attempt, &e).await;
1085 continue;
1086 }
1087 Some(first) => {
1088 let head = stream::once(async move { first });
1089 return Ok(head.chain(stream).boxed());
1090 }
1091 None => return Ok(stream::empty().boxed()),
1092 },
1093 // The stream never opened (e.g. a non-success HTTP status).
1094 Err(e) => {
1095 if attempt >= self.policy.max_retries || !self.policy.retry_on.should_retry(&e)
1096 {
1097 return Err(e);
1098 }
1099 attempt += 1;
1100 self.backoff(attempt, &e).await;
1101 }
1102 }
1103 }
1104 }
1105
1106 fn model(&self) -> Option<&str> {
1107 self.inner.model()
1108 }
1109}