agent_framework_core/agent.rs
1//! Agents: the [`SupportsAgentRun`] trait and the concrete [`Agent`].
2//!
3//! Rust equivalent of `agent_framework._agents`.
4
5use async_trait::async_trait;
6use futures::stream::{Stream, StreamExt};
7use serde_json::Value;
8use std::pin::Pin;
9use std::sync::Arc;
10use tracing::Instrument;
11use uuid::Uuid;
12
13use crate::client::{ChatClient, FunctionInvokingChatClient};
14use crate::compaction::{CompactionProvider, CompactionStrategy};
15use crate::error::{Error, Result};
16use crate::history::ensure_history_provider;
17use crate::memory::{ContextProvider, SessionContext};
18use crate::middleware::{AgentContext, ChatContext, MiddlewarePipeline, Terminal};
19use crate::session::AgentSession;
20use crate::tools::{ToolDefinition, ToolSource};
21use crate::types::{
22 prepare_messages, AgentResponse, AgentResponseUpdate, ChatOptions, ChatResponse, IntoMessages,
23 Message, ResponseFormat,
24};
25
26/// A boxed stream of agent run updates.
27pub type AgentRunStream = Pin<Box<dyn Stream<Item = Result<AgentResponseUpdate>> + Send>>;
28
29/// Per-run option overrides for a single [`SupportsAgentRun::run_with_options`] /
30/// [`SupportsAgentRun::run_stream`] call, merged over the agent's build-time defaults.
31///
32/// Mirrors upstream `run`/`run_stream` per-call keyword arguments and .NET
33/// `AgentRunOptions`: the per-run [`ChatOptions`] take precedence over the
34/// agent's defaults (via [`ChatOptions::merge`], matching Python's
35/// `run_chat_options & ChatOptions(...)`), and
36/// [`additional_tools`](Self::additional_tools) are appended to the tool list
37/// for that call only.
38#[derive(Debug, Clone, Default)]
39pub struct AgentRunOptions {
40 /// Chat-option overrides merged over the agent's defaults (per-run wins).
41 pub chat_options: Option<ChatOptions>,
42 /// Extra tools available only for this run, appended to the agent's tools.
43 ///
44 /// Declaration-only tools (no executor) surface their calls back to the
45 /// caller instead of being executed locally — this is how a hosting
46 /// frontend injects client-side tools (see the AG-UI router).
47 pub additional_tools: Vec<ToolDefinition>,
48}
49
50impl AgentRunOptions {
51 /// Empty options (no overrides).
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// Set the per-run chat-option overrides (merged over the agent defaults,
57 /// per-run winning).
58 pub fn with_chat_options(mut self, options: ChatOptions) -> Self {
59 self.chat_options = Some(options);
60 self
61 }
62
63 /// Append a tool available only for this run.
64 pub fn with_tool(mut self, tool: ToolDefinition) -> Self {
65 self.additional_tools.push(tool);
66 self
67 }
68
69 /// Append multiple tools available only for this run.
70 pub fn with_tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
71 self.additional_tools.extend(tools);
72 self
73 }
74
75 /// Whether these options carry no overrides at all. Used by the default
76 /// [`SupportsAgentRun::run_with_options`] to decide whether to warn about ignoring
77 /// options it cannot honor.
78 pub fn is_empty(&self) -> bool {
79 self.chat_options.is_none() && self.additional_tools.is_empty()
80 }
81}
82
83/// Map a completed run into buffered agent updates — one update per message,
84/// each with a distinct `message_id` so that re-aggregation via
85/// [`AgentResponse::from_updates`] keeps the message boundaries. Shared by
86/// the default [`SupportsAgentRun::run_stream`] and [`Agent`]'s middleware-path
87/// replay.
88///
89/// Response-level metadata survives the replay: `response_id` and
90/// `conversation_id` ride on every update, and `usage_details` rides the
91/// final update as a [`Content::Usage`] item (aggregation folds it back into
92/// [`AgentResponse::usage_details`], never into message contents) — the
93/// same contract as the tool-loop replay in `FunctionInvokingChatClient`.
94pub(crate) fn response_to_updates(response: AgentResponse) -> Vec<Result<AgentResponseUpdate>> {
95 let AgentResponse {
96 messages,
97 response_id,
98 conversation_id,
99 usage_details,
100 ..
101 } = response;
102 let last = messages.len().saturating_sub(1);
103 // Keep provider message ids only when all present and distinct; otherwise
104 // positional ids for every message. A service (e.g. Assistants) can reuse
105 // one run id across the tool-call and final assistant messages, and
106 // `AgentResponse::from_updates` keys by id — a duplicate would merge
107 // the final answer into the tool-call message, so streamed and
108 // non-streamed responses would differ.
109 let keep_provider_ids = {
110 let mut seen = std::collections::HashSet::new();
111 messages.iter().all(|m| {
112 m.message_id
113 .as_ref()
114 .is_some_and(|id| !id.is_empty() && seen.insert(id.as_str()))
115 })
116 };
117 let mut updates: Vec<Result<AgentResponseUpdate>> = messages
118 .into_iter()
119 .enumerate()
120 .map(|(i, m)| {
121 let message_id = if keep_provider_ids {
122 m.message_id.clone()
123 } else {
124 Some(format!("msg-{i}"))
125 };
126 let mut contents = m.contents;
127 if i == last {
128 if let Some(usage) = usage_details.clone() {
129 contents.push(crate::types::Content::Usage(crate::types::UsageContent {
130 details: usage,
131 }));
132 }
133 }
134 Ok(AgentResponseUpdate {
135 contents,
136 role: Some(m.role),
137 author_name: m.author_name,
138 message_id,
139 response_id: response_id.clone(),
140 conversation_id: conversation_id.clone(),
141 ..Default::default()
142 })
143 })
144 .collect();
145 if updates.is_empty() && (usage_details.is_some() || response_id.is_some()) {
146 let contents = usage_details
147 .map(|u| {
148 vec![crate::types::Content::Usage(crate::types::UsageContent {
149 details: u,
150 })]
151 })
152 .unwrap_or_default();
153 updates.push(Ok(AgentResponseUpdate {
154 contents,
155 role: Some(crate::types::Role::assistant()),
156 response_id,
157 conversation_id,
158 ..Default::default()
159 }));
160 }
161 updates
162}
163
164/// Sanitize an agent name into a valid tool/function identifier, mirroring
165/// Python's `_sanitize_agent_name` (`_agents.py:53-87`).
166///
167/// Every character that is not ASCII alphanumeric or `_` is replaced with `_`;
168/// runs of `_` are collapsed to one; leading/trailing `_` are trimmed. An
169/// all-invalid name (e.g. `"@@@"`) becomes `"agent"`, and a name that would
170/// start with a digit is prefixed with `_`. `None` maps to `None`.
171fn sanitize_agent_name(agent_name: Option<&str>) -> Option<String> {
172 let name = agent_name?;
173 let replaced: String = name
174 .chars()
175 .map(|c| {
176 if c.is_ascii_alphanumeric() || c == '_' {
177 c
178 } else {
179 '_'
180 }
181 })
182 .collect();
183 // Collapse consecutive underscores into one.
184 let mut collapsed = String::with_capacity(replaced.len());
185 let mut prev_underscore = false;
186 for c in replaced.chars() {
187 if c == '_' {
188 if !prev_underscore {
189 collapsed.push('_');
190 }
191 prev_underscore = true;
192 } else {
193 collapsed.push(c);
194 prev_underscore = false;
195 }
196 }
197 let trimmed = collapsed.trim_matches('_');
198 if trimmed.is_empty() {
199 return Some("agent".to_string());
200 }
201 let mut result = trimmed.to_string();
202 if result.starts_with(|c: char| c.is_ascii_digit()) {
203 result.insert(0, '_');
204 }
205 Some(result)
206}
207
208/// The common interface implemented by all agents.
209#[async_trait]
210pub trait SupportsAgentRun: Send + Sync {
211 /// Run the agent to completion.
212 async fn run(
213 &self,
214 messages: Vec<Message>,
215 session: Option<&mut AgentSession>,
216 ) -> Result<AgentResponse>;
217
218 /// Run the agent to completion, applying per-run [`AgentRunOptions`] over
219 /// the agent's build-time defaults.
220 ///
221 /// The default implementation ignores `options` and delegates to
222 /// [`SupportsAgentRun::run`], emitting a `tracing::warn!` when non-empty options are
223 /// supplied — mirroring upstream agents that silently drop kwargs they do
224 /// not understand. Agents that support per-run overrides (notably
225 /// [`Agent`]) override this.
226 async fn run_with_options(
227 &self,
228 messages: Vec<Message>,
229 session: Option<&mut AgentSession>,
230 options: AgentRunOptions,
231 ) -> Result<AgentResponse> {
232 if !options.is_empty() {
233 tracing::warn!(
234 agent = %self.id(),
235 "agent does not support per-run options; ignoring them"
236 );
237 }
238 self.run(messages, session).await
239 }
240
241 /// Run the agent and stream incremental [`AgentResponseUpdate`]s.
242 ///
243 /// The default implementation is a **buffered fallback**: it runs to
244 /// completion via [`SupportsAgentRun::run_with_options`] and yields the response's
245 /// messages as updates. Agents with a real streaming backend (notably
246 /// [`Agent`], [`WorkflowAgent`](crate::workflow::WorkflowAgent), and the
247 /// A2A client agent) override this to stream incrementally.
248 ///
249 /// `session` is taken **by value**: the returned stream owns it and
250 /// drives its context providers (including any history provider) once
251 /// the stream is fully consumed. When a provider's storage is shared (as
252 /// [`InMemoryHistoryProvider`](crate::history::InMemoryHistoryProvider)'s
253 /// is, via `Arc`), the write-back is
254 /// observable through a clone taken before streaming.
255 async fn run_stream(
256 &self,
257 messages: Vec<Message>,
258 session: Option<AgentSession>,
259 options: Option<AgentRunOptions>,
260 ) -> Result<AgentRunStream> {
261 let mut owned = session;
262 let response = self
263 .run_with_options(messages, owned.as_mut(), options.unwrap_or_default())
264 .await?;
265 Ok(futures::stream::iter(response_to_updates(response)).boxed())
266 }
267
268 /// A stable identifier for this agent.
269 fn id(&self) -> &str;
270
271 /// The optional human-readable name.
272 fn name(&self) -> Option<&str> {
273 None
274 }
275
276 /// The display name: `name` if set, else `id`.
277 fn display_name(&self) -> String {
278 self.name()
279 .map(str::to_string)
280 .unwrap_or_else(|| self.id().to_string())
281 }
282
283 /// A fresh session for a new conversation.
284 fn create_session(&self) -> AgentSession {
285 AgentSession::new()
286 }
287}
288
289/// The primary concrete agent: pairs a chat client with instructions, default
290/// options, tools, context providers, and middleware.
291///
292/// Cheaply cloneable (the client, context providers, and middleware are shared
293/// via `Arc`), which is what makes [`Agent::as_tool`] possible.
294#[derive(Clone)]
295pub struct Agent {
296 id: String,
297 name: Option<String>,
298 description: Option<String>,
299 client: Arc<dyn ChatClient>,
300 chat_options: ChatOptions,
301 context_providers: Vec<Arc<dyn ContextProvider>>,
302 agent_middleware: MiddlewarePipeline<AgentContext>,
303 /// Middleware run around the underlying chat-client call (mirrors
304 /// Python's `use_chat_middleware`). See [`Agent::call_chat_client`].
305 chat_middleware: MiddlewarePipeline<ChatContext>,
306 /// Dynamic tool sources (e.g. MCP servers), resolved fresh on every run
307 /// and appended after the agent's static/context/per-run tools. See
308 /// [`ToolSource`] and [`Agent::prepare_request`].
309 tool_sources: Vec<Arc<dyn ToolSource>>,
310}
311
312/// A callback receiving each [`AgentResponseUpdate`] streamed by an agent
313/// running as a tool — see [`AsToolOptions::stream_callback`].
314pub type AgentToolStreamCallback = Arc<dyn Fn(&AgentResponseUpdate) + Send + Sync>;
315
316/// Options for [`Agent::as_tool`].
317#[derive(Clone, Default)]
318pub struct AsToolOptions {
319 /// The tool name. Defaults to the agent's name (else its id).
320 pub name: Option<String>,
321 /// The tool description. Defaults to the agent's description (else empty).
322 pub description: Option<String>,
323 /// The single string argument's name. Defaults to `"task"`.
324 pub arg_name: Option<String>,
325 /// The argument's description. Defaults to `"Task for {tool_name}"`.
326 pub arg_description: Option<String>,
327 /// Whether calls to this delegated tool require human approval before
328 /// executing (default: no). Mirrors upstream `as_tool(approval_mode=…)`.
329 pub approval_mode: crate::tools::ApprovalMode,
330 /// Observe the sub-agent's streamed updates as they arrive. When set,
331 /// the wrapper runs the sub-agent via `run_stream` and invokes the
332 /// callback on every update before aggregating the final response.
333 /// Mirrors upstream `as_tool(stream_callback=…)`.
334 pub stream_callback: Option<AgentToolStreamCallback>,
335 /// Forward the **parent** agent's session to the sub-agent, so both
336 /// share the same session identity and state bag.
337 ///
338 /// The sub-agent receives a [`AgentSession::child`] of the parent's
339 /// session: same `session_id`, shared `state`, but an **isolated**
340 /// `service_session_id` — the parent's server-side conversation pointer
341 /// (whose tool call is still pending mid-run) must not leak into the
342 /// sub-agent's own service calls. Mirrors upstream
343 /// `as_tool(propagate_session=True)` with the child-session isolation
344 /// fix (microsoft/agent-framework#5875). Defaults to `false` (a fresh
345 /// session per call).
346 pub propagate_session: bool,
347}
348
349impl std::fmt::Debug for AsToolOptions {
350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 f.debug_struct("AsToolOptions")
352 .field("name", &self.name)
353 .field("description", &self.description)
354 .field("arg_name", &self.arg_name)
355 .field("arg_description", &self.arg_description)
356 .field("approval_mode", &self.approval_mode)
357 .field("stream_callback", &self.stream_callback.is_some())
358 .field("propagate_session", &self.propagate_session)
359 .finish()
360 }
361}
362
363impl AsToolOptions {
364 pub fn new() -> Self {
365 Self::default()
366 }
367 /// Set the tool name.
368 pub fn name(mut self, name: impl Into<String>) -> Self {
369 self.name = Some(name.into());
370 self
371 }
372 /// Set the tool description.
373 pub fn description(mut self, description: impl Into<String>) -> Self {
374 self.description = Some(description.into());
375 self
376 }
377 /// Set the argument name (default `"task"`).
378 pub fn arg_name(mut self, arg_name: impl Into<String>) -> Self {
379 self.arg_name = Some(arg_name.into());
380 self
381 }
382 /// Set the argument description.
383 pub fn arg_description(mut self, arg_description: impl Into<String>) -> Self {
384 self.arg_description = Some(arg_description.into());
385 self
386 }
387 /// Require human approval before every call to the delegated tool.
388 pub fn approval_mode(mut self, mode: crate::tools::ApprovalMode) -> Self {
389 self.approval_mode = mode;
390 self
391 }
392 /// Observe the sub-agent's streamed updates (see
393 /// [`AsToolOptions::stream_callback`]).
394 pub fn stream_callback(mut self, callback: AgentToolStreamCallback) -> Self {
395 self.stream_callback = Some(callback);
396 self
397 }
398 /// Forward the parent agent's session to the sub-agent (see
399 /// [`AsToolOptions::propagate_session`]).
400 pub fn propagate_session(mut self, propagate: bool) -> Self {
401 self.propagate_session = propagate;
402 self
403 }
404}
405
406impl Agent {
407 /// Start building an agent from a chat client. The client is automatically
408 /// wrapped with [`FunctionInvokingChatClient`] so local tools are executed.
409 pub fn builder(client: impl ChatClient + 'static) -> AgentBuilder {
410 AgentBuilder::new(client)
411 }
412
413 /// The agent's default instructions.
414 pub fn instructions(&self) -> Option<&str> {
415 self.chat_options.instructions.as_deref()
416 }
417
418 /// Run and stream incremental updates — an ergonomic wrapper over the
419 /// object-safe [`SupportsAgentRun::run_stream`] trait method (the real streaming
420 /// implementation), accepting `impl IntoMessages`.
421 ///
422 /// The session's context providers (including any history provider) are
423 /// driven when the stream completes; because provider storage is shared
424 /// via `Arc`, updates are visible on the original session once the
425 /// returned stream is fully consumed. Pass per-run [`AgentRunOptions`] to
426 /// override the agent's defaults for this call only.
427 pub async fn run_stream(
428 &self,
429 messages: impl IntoMessages,
430 session: Option<AgentSession>,
431 options: Option<AgentRunOptions>,
432 ) -> Result<AgentRunStream> {
433 SupportsAgentRun::run_stream(self, messages.into_messages(), session, options).await
434 }
435
436 /// Ergonomic streaming run with a fresh session and no per-run options
437 /// (mirrors [`Agent::run_once`]).
438 pub async fn run_stream_once(&self, messages: impl IntoMessages) -> Result<AgentRunStream> {
439 SupportsAgentRun::run_stream(self, messages.into_messages(), None, None).await
440 }
441
442 /// Ergonomic run without an explicit session.
443 pub async fn run_once(&self, messages: impl IntoMessages) -> Result<AgentResponse> {
444 self.run(messages.into_messages(), None).await
445 }
446
447 /// The real streaming implementation, shared by the [`SupportsAgentRun::run_stream`]
448 /// trait impl. Kept as an inherent helper so the trait method stays a thin
449 /// forwarder.
450 async fn run_stream_impl(
451 &self,
452 input: Vec<Message>,
453 session: Option<AgentSession>,
454 run_options: AgentRunOptions,
455 ) -> Result<AgentRunStream> {
456 let mut session = session.unwrap_or_else(|| self.create_session());
457 let (final_messages, options) = self
458 .prepare_request(&input, &mut session, &run_options)
459 .await?;
460
461 // When agent middleware is configured, route the run through the same
462 // pipeline as `run` (so guardrails/rewrites/termination apply) and then
463 // emit the resulting messages as updates. Token-level streaming is only
464 // used when there is no agent middleware to honor.
465 if self.has_middleware() {
466 let response = match self.run_core(final_messages, options, true).await {
467 Ok(r) => r,
468 Err(e) => {
469 for cp in self.combined_providers(&session) {
470 let _ = cp.after_run(&input, &[], Some(&e)).await;
471 }
472 return Err(e);
473 }
474 };
475 self.update_session_conversation_id(&mut session, response.conversation_id.as_deref())?;
476 for cp in self.combined_providers(&session) {
477 cp.after_run(&input, &response.messages, None).await?;
478 }
479 // Distinct message ids keep boundaries when re-aggregated; the
480 // response's conversation/response ids and usage ride along so
481 // service-managed continuity survives the middleware replay.
482 return Ok(futures::stream::iter(response_to_updates(response)).boxed());
483 }
484
485 let (final_messages, options) = self
486 .apply_chat_middleware_pre_call(final_messages, options)
487 .await?;
488 // Capture the structured-output format before `options` is consumed, so
489 // the stream's terminal aggregation can auto-populate `value` (task 3).
490 let response_format = options.response_format.clone();
491 let agent_name = self.name.clone();
492 let providers = self.combined_providers(&session);
493 let inner = match self
494 .client
495 .get_streaming_response(final_messages, options)
496 .await
497 {
498 Ok(s) => s,
499 Err(e) => {
500 // Failure before the stream opens: let providers observe it.
501 for cp in &providers {
502 let _ = cp.after_run(&input, &[], Some(&e)).await;
503 }
504 return Err(e);
505 }
506 };
507
508 // Wrap the inner stream: forward mapped updates, then update the session.
509 let stream = async_stream_forward(
510 inner,
511 agent_name,
512 session,
513 input,
514 providers,
515 response_format,
516 );
517 Ok(stream.boxed())
518 }
519
520 /// Assemble the final message list and options for a request, applying
521 /// context providers (including history) over the session.
522 async fn prepare_request(
523 &self,
524 input: &[Message],
525 session: &mut AgentSession,
526 run_options: &AgentRunOptions,
527 ) -> Result<(Vec<Message>, ChatOptions)> {
528 // Auto-attach a fresh `InMemoryHistoryProvider` to a non-service-managed
529 // session that doesn't already carry a history provider, so local
530 // multi-turn conversations keep accumulating history the way the old
531 // `AgentThread` message store used to.
532 ensure_history_provider(session);
533 let service_session_id = session.service_session_id().map(str::to_string);
534
535 // Merge per-run chat-option overrides over the agent's defaults, with
536 // the per-run side winning (mirrors Python's `run_chat_options &
537 // ChatOptions(...)`, whose right-hand side takes precedence — see
538 // `ChatOptions::merge`).
539 let mut options = match &run_options.chat_options {
540 Some(overrides) => self.chat_options.clone().merge(overrides.clone()),
541 None => self.chat_options.clone(),
542 };
543 // A service-managed session's id drives continuity and wins; on a
544 // local session, a per-run / agent-default `conversation_id` override
545 // survives (previously it was unconditionally cleared here, silently
546 // starting a new service conversation despite the documented per-run
547 // precedence).
548 options.conversation_id = service_session_id
549 .clone()
550 .or(options.conversation_id.take());
551
552 // Context provider injection: run every provider's `before_run` over a
553 // shared `SessionContext`, then fold the result into the request
554 // (provider instructions AFTER the agent's own; provider messages —
555 // including, via the auto-attached/explicit `HistoryProvider`, thread
556 // history — PREPENDED ahead of the run's own input; provider tools
557 // appended, deduplicated by name).
558 let providers = self.combined_providers(session);
559 let mut ctx = SessionContext::new(input.to_vec());
560 ctx.session_id = Some(session.session_id().to_string());
561 ctx.service_session_id = service_session_id.clone();
562 for provider in &providers {
563 provider.before_run(&mut ctx).await?;
564 }
565 if let Some(instr) = ctx.instructions {
566 options.instructions = Some(match options.instructions.take() {
567 Some(base) => format!("{base}\n{instr}"),
568 None => instr,
569 });
570 }
571 let mut history = ctx.messages;
572
573 // Deduplicate by name: a tool may be defined on the agent and also
574 // injected by a context provider. Providers rejecting duplicate
575 // tool names would otherwise fail the request.
576 for t in ctx.tools {
577 if !options.tools.iter().any(|existing| existing.name == t.name) {
578 options.tools.push(t);
579 }
580 }
581
582 // Append per-run additional tools (deduplicated by name), available for
583 // this call only. Declaration-only tools (no executor) surface their
584 // calls back to the caller (frontend-tool pattern) via the
585 // function-invocation loop's declaration-only handling in `client.rs`.
586 for t in &run_options.additional_tools {
587 if !options.tools.iter().any(|existing| existing.name == t.name) {
588 options.tools.push(t.clone());
589 }
590 }
591
592 // Resolve dynamic tool sources (e.g. MCP servers) fresh for this run,
593 // appended after every tool assembled above (dedup by name against
594 // those tools plus any earlier source already appended in this same
595 // loop; first registrant wins) — mirrors the Python reference's
596 // `existing_names` skip when (re)loading MCP tools/prompts
597 // (`_mcp.py:654,696`). A source's failure propagates out of the whole
598 // run; see [`ToolSource::resolve_tools`].
599 for source in &self.tool_sources {
600 let resolved = source.resolve_tools().await?;
601 for t in resolved {
602 if options.tools.iter().any(|existing| existing.name == t.name) {
603 tracing::warn!(
604 source = source.source_name(),
605 tool = %t.name,
606 "tool source produced a tool whose name collides with an existing \
607 tool; skipping"
608 );
609 continue;
610 }
611 options.tools.push(t);
612 }
613 }
614
615 history.extend(input.iter().cloned());
616 let instructions = options.instructions.take();
617 let final_messages = prepare_messages(history, instructions.as_deref());
618
619 // Hand the run's session to the function-invocation loop (which pops
620 // it before the wire client sees the options), so invoked tools can
621 // read it from `FunctionInvocationContext::session` — the channel
622 // behind `as_tool` + `propagate_session`. The clone shares the
623 // session's state bag by reference (see `SessionState`).
624 options.session = Some(session.clone());
625 Ok((final_messages, options))
626 }
627
628 /// Run the agent middleware pipeline with a terminal that calls the chat
629 /// client, returning the aggregated response. Shared by `run` and the
630 /// middleware path of `run_stream` (thread updates are handled by callers).
631 ///
632 /// Wrapped in an `invoke_agent` span (OTel GenAI semconv). The plain
633 /// token-streaming path does not go through here; that path is observed at
634 /// the chat-client decorator level (see [`crate::observability`]).
635 async fn run_core(
636 &self,
637 final_messages: Vec<Message>,
638 options: ChatOptions,
639 is_streaming: bool,
640 ) -> Result<AgentResponse> {
641 let span = crate::observability::agent_span(
642 self.name.as_deref().unwrap_or(self.id.as_str()),
643 &self.id,
644 );
645 async move {
646 let result = self
647 .run_core_inner(final_messages, options, is_streaming)
648 .await;
649 let span = tracing::Span::current();
650 match &result {
651 Ok(response) => {
652 if let Some(usage) = &response.usage_details {
653 if let Some(input) = usage.input_token_count {
654 span.record(crate::observability::attr::INPUT_TOKENS, input);
655 }
656 if let Some(output) = usage.output_token_count {
657 span.record(crate::observability::attr::OUTPUT_TOKENS, output);
658 }
659 }
660 }
661 Err(err) => {
662 crate::observability::record_error(&span, err);
663 }
664 }
665 result
666 }
667 .instrument(span)
668 .await
669 }
670
671 async fn run_core_inner(
672 &self,
673 final_messages: Vec<Message>,
674 options: ChatOptions,
675 is_streaming: bool,
676 ) -> Result<AgentResponse> {
677 let client = self.client.clone();
678 let chat_middleware = self.chat_middleware.clone();
679 let terminal: Terminal<AgentContext> = Box::new(move |mut ctx: AgentContext| {
680 let client = client.clone();
681 let options = options.clone();
682 let chat_middleware = chat_middleware.clone();
683 Box::pin(async move {
684 if ctx.terminate {
685 return Ok(ctx);
686 }
687 let response = Self::call_chat_client(
688 &client,
689 &chat_middleware,
690 ctx.messages.clone(),
691 options,
692 ctx.is_streaming,
693 )
694 .await?;
695 ctx.result = Some(AgentResponse::from_chat_response(response));
696 Ok(ctx)
697 }) as crate::tools::BoxFuture<Result<AgentContext>>
698 });
699
700 let ctx = AgentContext::new(final_messages, is_streaming);
701 let ctx = self.agent_middleware.execute(ctx, terminal).await?;
702 let mut response = ctx.result.ok_or_else(|| {
703 crate::error::Error::AgentExecution("agent produced no result".into())
704 })?;
705
706 if let Some(name) = &self.name {
707 for m in &mut response.messages {
708 if m.author_name.is_none() {
709 m.author_name = Some(name.clone());
710 }
711 }
712 }
713 Ok(response)
714 }
715
716 /// Invoke the chat client once, routed through the chat-middleware
717 /// pipeline (mirrors Python's `use_chat_middleware`).
718 ///
719 /// Middleware may mutate `messages`/`chat_options` before the call, then
720 /// observe (or override, via [`ChatContext::result`]) the response after
721 /// calling `next.run(...)`. A middleware that sets `terminate = true`
722 /// without invoking `next` short-circuits the call entirely: the
723 /// underlying client is never invoked, and [`ChatContext::result`] (if
724 /// set) becomes the returned response.
725 async fn call_chat_client(
726 client: &Arc<dyn ChatClient>,
727 chat_middleware: &MiddlewarePipeline<ChatContext>,
728 messages: Vec<Message>,
729 options: ChatOptions,
730 is_streaming: bool,
731 ) -> Result<ChatResponse> {
732 if chat_middleware.is_empty() {
733 return client.get_response(messages, options).await;
734 }
735 let client = client.clone();
736 let terminal: Terminal<ChatContext> = Box::new(move |mut ctx: ChatContext| {
737 let client = client.clone();
738 Box::pin(async move {
739 if ctx.terminate {
740 return Ok(ctx);
741 }
742 let response = client
743 .get_response(ctx.messages.clone(), ctx.chat_options.clone())
744 .await?;
745 ctx.result = Some(response);
746 Ok(ctx)
747 }) as crate::tools::BoxFuture<Result<ChatContext>>
748 });
749 let ctx = ChatContext::new(messages, options, is_streaming);
750 let ctx = chat_middleware.execute(ctx, terminal).await?;
751 ctx.result.ok_or_else(|| {
752 crate::error::Error::AgentExecution("chat middleware produced no result".into())
753 })
754 }
755
756 /// Apply chat middleware to a *streaming* call's `messages`/`chat_options`
757 /// before the real network call.
758 ///
759 /// Unlike [`Agent::call_chat_client`], this only honors *pre-call*
760 /// mutation: a real token stream can't flow back through
761 /// [`ChatContext::result`] (typed for a complete [`ChatResponse`]), so any
762 /// middleware logic placed *after* `next.run(...)` observes
763 /// `ctx.result == None` and cannot post-process individual streamed
764 /// tokens, and `terminate`/`result` short-circuiting is not honored here.
765 /// This mirrors upstream Python's `use_chat_middleware`, whose streaming
766 /// path likewise hands middleware an unconsumed async generator rather
767 /// than driving it through the pipeline. Full interception (including
768 /// short-circuiting) for chat middleware is available via
769 /// [`Agent::run`]/[`Agent::run_once`], and via `run_stream` too
770 /// when at least one agent middleware is also configured (that path
771 /// funnels through [`Agent::run_core`] and replays the result as
772 /// updates).
773 async fn apply_chat_middleware_pre_call(
774 &self,
775 messages: Vec<Message>,
776 options: ChatOptions,
777 ) -> Result<(Vec<Message>, ChatOptions)> {
778 if self.chat_middleware.is_empty() {
779 return Ok((messages, options));
780 }
781 let terminal: Terminal<ChatContext> = Box::new(|ctx| Box::pin(async move { Ok(ctx) }));
782 let ctx = ChatContext::new(messages, options, true);
783 let ctx = self.chat_middleware.execute(ctx, terminal).await?;
784 Ok((ctx.messages, ctx.chat_options))
785 }
786
787 /// Whether this agent has any agent-level middleware configured.
788 fn has_middleware(&self) -> bool {
789 !self.agent_middleware.is_empty()
790 }
791
792 /// The effective context providers for a run: the session's, combined
793 /// with the agent's own. There is no aggregate wrapper any more — callers
794 /// iterate this list directly.
795 fn combined_providers(&self, session: &AgentSession) -> Vec<Arc<dyn ContextProvider>> {
796 let mut providers = session.context_providers.clone();
797 providers.extend(self.context_providers.iter().cloned());
798 providers
799 }
800
801 /// Reconcile a run's conversation id with the session, mirroring Python's
802 /// `_update_thread_with_type_and_conversation_id` (`_agents.py:1204-1234`).
803 ///
804 /// * No id returned while the session *is* service-managed → the service
805 /// doesn't support service-managed sessions for this request, so surface
806 /// an [`Error::AgentExecution`] (matches Python raising
807 /// `AgentExecutionException`, GAP item 14).
808 /// * An id returned that the session newly adopts is simply recorded on
809 /// the session; there is no `thread_created` hook any more (upstream
810 /// removed it — see [`crate::memory::ContextProvider`]).
811 fn update_session_conversation_id(
812 &self,
813 session: &mut AgentSession,
814 response_conversation_id: Option<&str>,
815 ) -> Result<()> {
816 match response_conversation_id {
817 None => {
818 if session.service_session_id().is_some() {
819 return Err(Error::AgentExecution(
820 "Service did not return a valid conversation id when using a service \
821 managed thread."
822 .into(),
823 ));
824 }
825 Ok(())
826 }
827 Some(cid) => {
828 session.try_adopt_service_session_id(cid);
829 Ok(())
830 }
831 }
832 }
833}
834
835/// State carried while forwarding a chat stream as agent updates.
836type ForwardFinish = Option<(
837 AgentSession,
838 Vec<Message>,
839 Vec<Arc<dyn ContextProvider>>,
840 Option<ResponseFormat>,
841)>;
842
843/// Forward an inner chat stream as agent updates and update the session on end.
844fn async_stream_forward(
845 inner: crate::client::ChatStream,
846 agent_name: Option<String>,
847 session: AgentSession,
848 input: Vec<Message>,
849 providers: Vec<Arc<dyn ContextProvider>>,
850 response_format: Option<ResponseFormat>,
851) -> impl Stream<Item = Result<AgentResponseUpdate>> + Send {
852 let finish: ForwardFinish = Some((session, input, providers, response_format));
853 futures::stream::unfold(
854 (
855 inner,
856 Vec::<crate::types::ChatResponseUpdate>::new(),
857 false,
858 finish,
859 ),
860 move |(mut inner, mut collected, done, mut finish)| {
861 let agent_name = agent_name.clone();
862 async move {
863 if done {
864 return None;
865 }
866 match inner.next().await {
867 Some(Ok(update)) => {
868 collected.push(update.clone());
869 let mut au = AgentResponseUpdate::from_chat_update(&update);
870 if au.author_name.is_none() {
871 au.author_name = agent_name.clone();
872 }
873 Some((Ok(au), (inner, collected, false, finish)))
874 }
875 Some(Err(e)) => {
876 // Failure mid-stream: let context providers observe the
877 // error before surfacing it. The stream error takes
878 // precedence, so the hooks' results are discarded.
879 if let Some((_session, input, providers, _rf)) = finish.take() {
880 for cp in &providers {
881 let _ = cp.after_run(&input, &[], Some(&e)).await;
882 }
883 }
884 Some((Err(e), (inner, collected, true, None)))
885 }
886 None => {
887 // Stream finished: reconcile the conversation id and fire
888 // the context providers' completion hook (which records
889 // history, for any attached `HistoryProvider`). Surface
890 // any failure as the final item rather than dropping it.
891 if let Some((mut session, input, providers, response_format)) =
892 finish.take()
893 {
894 let response = ChatResponse::from_updates_with_format(
895 collected.clone(),
896 response_format.as_ref(),
897 );
898 match response.conversation_id.as_deref() {
899 None => {
900 if session.service_session_id().is_some() {
901 return Some((
902 Err(Error::AgentExecution(
903 "Service did not return a valid conversation id \
904 when using a service managed thread."
905 .into(),
906 )),
907 (inner, collected, true, None),
908 ));
909 }
910 }
911 Some(cid) => {
912 session.try_adopt_service_session_id(cid);
913 }
914 }
915 for cp in providers {
916 if let Err(e) = cp.after_run(&input, &response.messages, None).await
917 {
918 return Some((Err(e), (inner, collected, true, None)));
919 }
920 }
921 }
922 None
923 }
924 }
925 }
926 },
927 )
928}
929
930#[async_trait]
931impl SupportsAgentRun for Agent {
932 async fn run(
933 &self,
934 messages: Vec<Message>,
935 session: Option<&mut AgentSession>,
936 ) -> Result<AgentResponse> {
937 self.run_with_options(messages, session, AgentRunOptions::default())
938 .await
939 }
940
941 async fn run_with_options(
942 &self,
943 messages: Vec<Message>,
944 session: Option<&mut AgentSession>,
945 options: AgentRunOptions,
946 ) -> Result<AgentResponse> {
947 let mut owned_session;
948 let session: &mut AgentSession = match session {
949 Some(s) => s,
950 None => {
951 owned_session = self.create_session();
952 &mut owned_session
953 }
954 };
955
956 let (final_messages, chat_options) =
957 self.prepare_request(&messages, session, &options).await?;
958 let response = match self.run_core(final_messages, chat_options, false).await {
959 Ok(r) => r,
960 Err(e) => {
961 // Failure path: let context providers observe the error.
962 // The run's error takes precedence over any hook failure, so
963 // the hook result is intentionally discarded.
964 for cp in self.combined_providers(session) {
965 let _ = cp.after_run(&messages, &[], Some(&e)).await;
966 }
967 return Err(e);
968 }
969 };
970
971 // Persist / validate the service-managed conversation id before
972 // firing the completion hooks (tasks: service-session adoption +
973 // missing-id error).
974 self.update_session_conversation_id(session, response.conversation_id.as_deref())?;
975
976 // Fire the context providers' success completion hook (this is what
977 // records history, for any attached `HistoryProvider`).
978 for cp in self.combined_providers(session) {
979 cp.after_run(&messages, &response.messages, None).await?;
980 }
981
982 Ok(response)
983 }
984
985 async fn run_stream(
986 &self,
987 messages: Vec<Message>,
988 session: Option<AgentSession>,
989 options: Option<AgentRunOptions>,
990 ) -> Result<AgentRunStream> {
991 self.run_stream_impl(messages, session, options.unwrap_or_default())
992 .await
993 }
994
995 fn id(&self) -> &str {
996 &self.id
997 }
998 fn name(&self) -> Option<&str> {
999 self.name.as_deref()
1000 }
1001
1002 fn create_session(&self) -> AgentSession {
1003 // A service-managed conversation id yields a service session;
1004 // otherwise eagerly attach a fresh `InMemoryHistoryProvider` (rather
1005 // than relying solely on `prepare_request`'s auto-attach) so history
1006 // is observable across clones (e.g. during streaming): the only way a
1007 // caller observes the post-stream write-back through a clone taken
1008 // beforehand is if the provider (and therefore its `Arc`) already
1009 // exists at clone time.
1010 //
1011 // The agent's own context providers are NOT copied onto the session
1012 // here: [`Agent::combined_providers`] merges the session's providers
1013 // with the agent's own at request time, so copying them here would
1014 // double-invoke the agent's providers for every run against this
1015 // session.
1016 match &self.chat_options.conversation_id {
1017 Some(id) => AgentSession::service(id.clone()),
1018 None => {
1019 let mut session = AgentSession::new();
1020 ensure_history_provider(&mut session);
1021 session
1022 }
1023 }
1024 }
1025}
1026
1027/// Builder for [`Agent`].
1028pub struct AgentBuilder {
1029 id: Option<String>,
1030 name: Option<String>,
1031 description: Option<String>,
1032 instructions: Option<String>,
1033 /// The raw, caller-supplied client. Wrapping in [`FunctionInvokingChatClient`]
1034 /// is deferred to [`AgentBuilder::build`] so that builder-collected
1035 /// function middleware can be threaded into the wrapper's constructor.
1036 client: Arc<dyn ChatClient>,
1037 chat_options: ChatOptions,
1038 context_providers: Vec<Arc<dyn ContextProvider>>,
1039 agent_middleware: Vec<Arc<crate::middleware::AgentMiddleware>>,
1040 chat_middleware: Vec<Arc<crate::middleware::ChatMiddleware>>,
1041 function_middleware: Vec<Arc<crate::middleware::FunctionMiddleware>>,
1042 /// Governs the tool-loop spans; `None` leaves the wrapper reading the
1043 /// environment. See [`AgentBuilder::observability_config`].
1044 observability: Option<crate::observability::ObservabilityConfig>,
1045 tool_sources: Vec<Arc<dyn ToolSource>>,
1046}
1047
1048impl AgentBuilder {
1049 fn new(client: impl ChatClient + 'static) -> Self {
1050 Self {
1051 id: None,
1052 name: None,
1053 description: None,
1054 instructions: None,
1055 client: Arc::new(client),
1056 chat_options: ChatOptions::new(),
1057 context_providers: Vec::new(),
1058 agent_middleware: Vec::new(),
1059 chat_middleware: Vec::new(),
1060 function_middleware: Vec::new(),
1061 observability: None,
1062 tool_sources: Vec::new(),
1063 }
1064 }
1065
1066 pub fn id(mut self, id: impl Into<String>) -> Self {
1067 self.id = Some(id.into());
1068 self
1069 }
1070 pub fn name(mut self, name: impl Into<String>) -> Self {
1071 self.name = Some(name.into());
1072 self
1073 }
1074 pub fn description(mut self, description: impl Into<String>) -> Self {
1075 self.description = Some(description.into());
1076 self
1077 }
1078 pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
1079 self.instructions = Some(instructions.into());
1080 self
1081 }
1082 pub fn model(mut self, model: impl Into<String>) -> Self {
1083 self.chat_options.model = Some(model.into());
1084 self
1085 }
1086 pub fn temperature(mut self, temperature: f32) -> Self {
1087 self.chat_options.temperature = Some(temperature);
1088 self
1089 }
1090 pub fn max_tokens(mut self, max_tokens: u32) -> Self {
1091 self.chat_options.max_tokens = Some(max_tokens);
1092 self
1093 }
1094 /// Request a structured-output response format (e.g. a JSON schema).
1095 pub fn response_format(mut self, format: ResponseFormat) -> Self {
1096 self.chat_options.response_format = Some(format);
1097 self
1098 }
1099 /// Add a tool available to the agent.
1100 pub fn tool(mut self, tool: ToolDefinition) -> Self {
1101 self.chat_options.tools.push(tool);
1102 self
1103 }
1104 /// Add multiple tools.
1105 pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
1106 self.chat_options.tools.extend(tools);
1107 self
1108 }
1109 /// Register a dynamic tool source (e.g. an MCP server wrapper), resolved
1110 /// fresh on every run and appended after the agent's static/context/
1111 /// per-run tools (dedup by name against those and any earlier-registered
1112 /// source; first registrant wins). Call repeatedly to register more than
1113 /// one source. See [`ToolSource`], resolved internally by every
1114 /// [`Agent`] run.
1115 pub fn tool_source(mut self, source: Arc<dyn ToolSource>) -> Self {
1116 self.tool_sources.push(source);
1117 self
1118 }
1119 /// Add a single context provider (repeatable; providers run in
1120 /// registration order, agent providers after any thread-level ones).
1121 pub fn context_provider(mut self, provider: Arc<dyn ContextProvider>) -> Self {
1122 self.context_providers.push(provider);
1123 self
1124 }
1125 /// Set the context provider list, replacing any previously registered.
1126 pub fn context_providers(mut self, providers: Vec<Arc<dyn ContextProvider>>) -> Self {
1127 self.context_providers = providers;
1128 self
1129 }
1130 /// Attach conversation-history compaction, via a
1131 /// [`CompactionProvider`] wrapping
1132 /// `strategy` (with the default `ApproxTokenizer`; use
1133 /// [`AgentBuilder::context_provider`] with
1134 /// [`CompactionProvider::with_tokenizer`](crate::compaction::CompactionProvider::with_tokenizer)
1135 /// for a custom tokenizer).
1136 ///
1137 /// Registered as one of the agent's own context providers, which
1138 /// `Agent::combined_providers` always runs *after* the session's —
1139 /// including the auto-attached (or explicitly attached)
1140 /// [`HistoryProvider`](crate::history::HistoryProvider), which lives on
1141 /// the session — so compaction sees, and can shrink, the full
1142 /// history-prepended message list for the run. Not calling this leaves
1143 /// the default behavior unchanged: the full history is sent every run.
1144 pub fn with_compaction(self, strategy: impl CompactionStrategy + 'static) -> Self {
1145 self.context_provider(Arc::new(CompactionProvider::new(strategy)))
1146 }
1147 /// Add an agent middleware.
1148 pub fn middleware(mut self, mw: Arc<crate::middleware::AgentMiddleware>) -> Self {
1149 self.agent_middleware.push(mw);
1150 self
1151 }
1152 /// Add a chat middleware, run around the underlying chat-client call on
1153 /// every request (repeatable, like [`AgentBuilder::middleware`]).
1154 /// See the chat-client call pipeline for exactly what it can observe
1155 /// and mutate.
1156 pub fn chat_middleware(mut self, mw: Arc<crate::middleware::ChatMiddleware>) -> Self {
1157 self.chat_middleware.push(mw);
1158 self
1159 }
1160 /// Add a function-invocation middleware, run around every local tool call
1161 /// (repeatable). Plumbed down into the [`FunctionInvokingChatClient`]
1162 /// this builder wraps the underlying client with.
1163 pub fn function_middleware(mut self, mw: Arc<crate::middleware::FunctionMiddleware>) -> Self {
1164 self.function_middleware.push(mw);
1165 self
1166 }
1167 /// Set the [`ObservabilityConfig`](crate::observability::ObservabilityConfig)
1168 /// for the `execute_tool` spans this agent's tool loop emits — content
1169 /// capture and the GenAI semantic-convention version.
1170 ///
1171 /// The builder wraps the caller's client in a
1172 /// [`FunctionInvokingChatClient`] itself, so this is the only way to reach
1173 /// that wrapper's own config. Pass the same value given to an
1174 /// [`ObservableChatClient`](crate::observability::ObservableChatClient)
1175 /// around the same client, so one trace reports one convention version
1176 /// across its chat and tool spans. Left unset, the tool loop reads the
1177 /// environment, as it always has.
1178 pub fn observability_config(
1179 mut self,
1180 config: crate::observability::ObservabilityConfig,
1181 ) -> Self {
1182 self.observability = Some(config);
1183 self
1184 }
1185
1186 /// Override the whole chat options object (advanced).
1187 pub fn chat_options(mut self, options: ChatOptions) -> Self {
1188 // Preserve tools/instructions collected so far by merging.
1189 self.chat_options = options.merge(self.chat_options);
1190 self
1191 }
1192
1193 /// Build the agent.
1194 pub fn build(mut self) -> Agent {
1195 if let Some(instr) = self.instructions.take() {
1196 self.chat_options.instructions = Some(match self.chat_options.instructions.take() {
1197 Some(existing) => format!("{instr}\n{existing}"),
1198 None => instr,
1199 });
1200 }
1201 if self.chat_options.model.is_none() {
1202 self.chat_options.model = self.client.model().map(str::to_string);
1203 }
1204 // Wrap the raw client in `FunctionInvokingChatClient` now that all
1205 // builder-collected function middleware is known.
1206 let mut invoking = FunctionInvokingChatClient::new(self.client)
1207 .with_function_middleware(self.function_middleware);
1208 if let Some(config) = self.observability {
1209 invoking = invoking.with_observability_config(config);
1210 }
1211 let client: Arc<dyn ChatClient> = Arc::new(invoking);
1212 Agent {
1213 id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
1214 name: self.name,
1215 description: self.description,
1216 client,
1217 chat_options: self.chat_options,
1218 context_providers: self.context_providers,
1219 agent_middleware: MiddlewarePipeline::new(self.agent_middleware),
1220 chat_middleware: MiddlewarePipeline::new(self.chat_middleware),
1221 tool_sources: self.tool_sources,
1222 }
1223 }
1224}
1225
1226impl Agent {
1227 /// The agent description, if any.
1228 pub fn description(&self) -> Option<&str> {
1229 self.description.as_deref()
1230 }
1231
1232 /// Create a new **service-managed** session bound to `service_session_id`,
1233 /// mirroring Python's `get_new_thread(service_thread_id=…)`
1234 /// (`_agents.py:1078-1082`).
1235 ///
1236 /// The agent's own context providers are NOT copied onto the returned
1237 /// session; see the note on [`Agent::create_session`].
1238 pub fn create_session_with_service_id(
1239 &self,
1240 service_session_id: impl Into<String>,
1241 ) -> AgentSession {
1242 AgentSession::service(service_session_id)
1243 }
1244
1245 /// Reconstruct a session from state (as produced by
1246 /// [`AgentSession::to_dict`]), mirroring Python's
1247 /// `BaseAgent.deserialize_thread` (`_agents.py:378-392`).
1248 ///
1249 /// Conversation history is **not** part of this state (see
1250 /// [`AgentSession::to_dict`]); reattach a [`crate::history::HistoryProvider`]
1251 /// (e.g. via [`crate::history::InMemoryHistoryProvider::from_dict`]) to
1252 /// `context_providers` separately when restoring a conversation. The
1253 /// agent's own context providers are NOT copied onto the returned
1254 /// session; see the note on [`Agent::create_session`].
1255 pub fn session_from_dict(&self, state: &Value) -> Result<AgentSession> {
1256 AgentSession::from_dict(state)
1257 }
1258
1259 /// Wrap this agent as a [`ToolDefinition`] usable by another agent's
1260 /// `.tool(...)`. Mirrors Python `BaseAgent.as_tool`.
1261 ///
1262 /// The tool takes a single string argument (default name `"task"`) and,
1263 /// on each call, runs this agent and returns the response text. By
1264 /// default each call runs **statelessly** (a fresh session per call);
1265 /// with [`AsToolOptions::propagate_session`] the parent agent's session
1266 /// is forwarded instead (as an [`AgentSession::child`]). Set
1267 /// [`AsToolOptions::stream_callback`] to observe the sub-agent's
1268 /// streamed updates, and [`AsToolOptions::approval_mode`] to gate calls
1269 /// behind human approval.
1270 ///
1271 /// A run that ends with pending user-input requests (function-approval
1272 /// requests from the sub-agent's own tools) cannot be satisfied from
1273 /// within a tool call and surfaces as a tool error — mirroring
1274 /// upstream's `UserInputRequiredException`.
1275 ///
1276 /// ```no_run
1277 /// # use agent_framework_core::prelude::*;
1278 /// # use agent_framework_core::agent::AsToolOptions;
1279 /// # fn demo(researcher: Agent, coordinator_client: impl ChatClient + 'static) {
1280 /// let research_tool = researcher.as_tool(AsToolOptions::new().name("research"));
1281 /// let coordinator = Agent::builder(coordinator_client)
1282 /// .tool(research_tool)
1283 /// .build();
1284 /// # let _ = coordinator;
1285 /// # }
1286 /// ```
1287 pub fn as_tool(&self, options: AsToolOptions) -> ToolDefinition {
1288 // Mirror Python `name or _sanitize_agent_name(self.name)`: an explicit
1289 // name is used verbatim; a derived name is sanitized into a valid tool
1290 // identifier. Falls back to the agent id when no name is available.
1291 let tool_name = options
1292 .name
1293 .or_else(|| sanitize_agent_name(self.name.as_deref()))
1294 .unwrap_or_else(|| self.id.clone());
1295 let description = options
1296 .description
1297 .or_else(|| self.description.clone())
1298 .unwrap_or_default();
1299 let arg_name = options.arg_name.unwrap_or_else(|| "task".to_string());
1300 let arg_description = options
1301 .arg_description
1302 .unwrap_or_else(|| format!("Task for {tool_name}"));
1303 let schema = serde_json::json!({
1304 "type": "object",
1305 "properties": {
1306 arg_name.clone(): { "type": "string", "description": arg_description }
1307 },
1308 "required": [arg_name.clone()],
1309 "additionalProperties": false,
1310 });
1311 ToolDefinition {
1312 name: tool_name.clone(),
1313 description: description.clone(),
1314 parameters: schema.clone(),
1315 kind: crate::tools::ToolKind::Function,
1316 approval_mode: options.approval_mode,
1317 executor: Some(Arc::new(AgentAsTool {
1318 agent: Arc::new(self.clone()),
1319 name: tool_name,
1320 description,
1321 parameters: schema,
1322 arg_key: arg_name,
1323 propagate_session: options.propagate_session,
1324 stream_callback: options.stream_callback,
1325 })),
1326 }
1327 }
1328}
1329
1330/// The [`Tool`] behind [`Agent::as_tool`]: delegates each call to the wrapped
1331/// agent. Reads the parent run's session from the invocation context (via
1332/// [`Tool::invoke_in_context`]) when `propagate_session` is enabled.
1333///
1334/// [`Tool`]: crate::tools::Tool
1335struct AgentAsTool {
1336 agent: Arc<Agent>,
1337 name: String,
1338 description: String,
1339 parameters: Value,
1340 arg_key: String,
1341 propagate_session: bool,
1342 stream_callback: Option<AgentToolStreamCallback>,
1343}
1344
1345impl AgentAsTool {
1346 async fn run_task(
1347 &self,
1348 arguments: Value,
1349 parent_session: Option<&AgentSession>,
1350 ) -> Result<Value> {
1351 let task = arguments
1352 .get(&self.arg_key)
1353 .and_then(Value::as_str)
1354 .unwrap_or_default()
1355 .to_string();
1356
1357 // With `propagate_session`, run the sub-agent on a *child* of the
1358 // parent's session: shared identity + state, isolated server-side
1359 // conversation pointer (see `AgentSession::child`). Without it (or
1360 // when the call arrives without a session — e.g. a direct
1361 // `Tool::invoke`), the sub-agent runs on a fresh session per call.
1362 let mut child = if self.propagate_session {
1363 parent_session.map(AgentSession::child)
1364 } else {
1365 None
1366 };
1367
1368 let response = match &self.stream_callback {
1369 Some(callback) => {
1370 let mut stream = SupportsAgentRun::run_stream(
1371 self.agent.as_ref(),
1372 task.into_messages(),
1373 child.clone(),
1374 None,
1375 )
1376 .await?;
1377 let mut updates = Vec::new();
1378 while let Some(update) = stream.next().await {
1379 let update = update?;
1380 callback(&update);
1381 updates.push(update);
1382 }
1383 AgentResponse::from_updates(updates)
1384 }
1385 None => {
1386 SupportsAgentRun::run(self.agent.as_ref(), task.into_messages(), child.as_mut())
1387 .await?
1388 }
1389 };
1390
1391 // Pending user-input (approval) requests cannot be answered from
1392 // within a tool call; surface them as a tool error (upstream raises
1393 // `UserInputRequiredException` here).
1394 if !response.user_input_requests().is_empty() {
1395 return Err(Error::tool(format!(
1396 "agent tool '{}' ended its run with pending user-input requests, \
1397 which cannot be satisfied from within a tool call",
1398 self.name
1399 )));
1400 }
1401 Ok(Value::String(response.text()))
1402 }
1403}
1404
1405#[async_trait]
1406impl crate::tools::Tool for AgentAsTool {
1407 fn name(&self) -> &str {
1408 &self.name
1409 }
1410 fn description(&self) -> &str {
1411 &self.description
1412 }
1413 fn parameters_schema(&self) -> Value {
1414 self.parameters.clone()
1415 }
1416
1417 async fn invoke(&self, arguments: Value) -> Result<Value> {
1418 self.run_task(arguments, None).await
1419 }
1420
1421 async fn invoke_in_context(
1422 &self,
1423 arguments: Value,
1424 ctx: &crate::middleware::FunctionInvocationContext,
1425 ) -> Result<Value> {
1426 self.run_task(arguments, ctx.session.as_ref()).await
1427 }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use super::*;
1433 use crate::client::ChatStream;
1434 use crate::compaction::Truncation;
1435 use crate::types::{ChatResponse, ChatResponseUpdate};
1436 use futures::stream;
1437 use std::sync::Mutex;
1438
1439 /// A chat client that records the full message list of every request it
1440 /// receives and always replies with the same canned text.
1441 #[derive(Clone, Default)]
1442 struct RecordingClient {
1443 received: Arc<Mutex<Vec<Vec<Message>>>>,
1444 }
1445
1446 impl RecordingClient {
1447 fn requests(&self) -> Vec<Vec<Message>> {
1448 self.received.lock().unwrap().clone()
1449 }
1450 }
1451
1452 #[async_trait]
1453 impl ChatClient for RecordingClient {
1454 async fn get_response(
1455 &self,
1456 messages: Vec<Message>,
1457 _options: ChatOptions,
1458 ) -> Result<ChatResponse> {
1459 self.received.lock().unwrap().push(messages);
1460 Ok(ChatResponse::from_text("ok"))
1461 }
1462
1463 async fn get_streaming_response(
1464 &self,
1465 messages: Vec<Message>,
1466 options: ChatOptions,
1467 ) -> Result<ChatStream> {
1468 let resp = self.get_response(messages, options).await?;
1469 let updates: Vec<Result<ChatResponseUpdate>> = resp
1470 .messages
1471 .into_iter()
1472 .map(|m| {
1473 Ok(ChatResponseUpdate {
1474 contents: m.contents,
1475 role: Some(m.role),
1476 ..Default::default()
1477 })
1478 })
1479 .collect();
1480 Ok(Box::pin(stream::iter(updates)))
1481 }
1482 }
1483
1484 #[tokio::test]
1485 async fn without_compaction_sends_the_full_accumulated_history() {
1486 let client = RecordingClient::default();
1487 let agent = Agent::builder(client.clone()).build();
1488 let mut session = agent.create_session();
1489
1490 agent
1491 .run(vec![Message::user("turn 1")], Some(&mut session))
1492 .await
1493 .unwrap();
1494 agent
1495 .run(vec![Message::user("turn 2")], Some(&mut session))
1496 .await
1497 .unwrap();
1498 agent
1499 .run(vec![Message::user("turn 3")], Some(&mut session))
1500 .await
1501 .unwrap();
1502
1503 let requests = client.requests();
1504 assert_eq!(requests.len(), 3);
1505
1506 // Third request: the full history accumulated by turns 1 and 2 (2
1507 // user + 2 assistant messages) plus this turn's own input.
1508 let last = requests.last().unwrap();
1509 assert_eq!(last.len(), 5);
1510 assert_eq!(last[0].text(), "turn 1");
1511 assert_eq!(last[2].text(), "turn 2");
1512 assert_eq!(last.last().unwrap().text(), "turn 3");
1513 }
1514
1515 #[tokio::test]
1516 async fn with_compaction_sends_only_the_compacted_message_set() {
1517 let client = RecordingClient::default();
1518 let agent = Agent::builder(client.clone())
1519 .with_compaction(Truncation::new(2))
1520 .build();
1521 let mut session = agent.create_session();
1522
1523 agent
1524 .run(vec![Message::user("turn 1")], Some(&mut session))
1525 .await
1526 .unwrap();
1527 agent
1528 .run(vec![Message::user("turn 2")], Some(&mut session))
1529 .await
1530 .unwrap();
1531 agent
1532 .run(vec![Message::user("turn 3")], Some(&mut session))
1533 .await
1534 .unwrap();
1535
1536 let requests = client.requests();
1537 assert_eq!(requests.len(), 3);
1538
1539 // Third request: compaction caps the *stored history* (4 messages
1540 // by then) at 2 before this turn's own input is appended, so the
1541 // outgoing request is 3 messages, and the oldest turn is gone.
1542 let last = requests.last().unwrap();
1543 assert_eq!(last.len(), 3);
1544 assert!(last.iter().all(|m| m.text() != "turn 1"));
1545 assert_eq!(last[0].text(), "turn 2");
1546 assert_eq!(last.last().unwrap().text(), "turn 3");
1547 }
1548
1549 #[tokio::test]
1550 async fn with_compaction_runs_after_the_history_provider_in_combined_providers() {
1551 // Direct check on `combined_providers` ordering: the agent-level
1552 // provider attached by `with_compaction` must come after the
1553 // session's auto-attached history provider.
1554 let client = RecordingClient::default();
1555 let agent = Agent::builder(client)
1556 .with_compaction(Truncation::new(2))
1557 .build();
1558 let session = agent.create_session();
1559
1560 let providers = agent.combined_providers(&session);
1561 assert_eq!(providers.len(), 2);
1562 assert!(providers[0].is_history_provider());
1563 assert!(!providers[1].is_history_provider());
1564 }
1565}