Skip to main content

agent_framework_foundry/
lib.rs

1//! # agent-framework-foundry
2//!
3//! An Azure AI Foundry [`ChatClient`] and Prompt Agent surface for
4//! `agent-framework-rs`, built on the **Responses API**
5//! (`POST {endpoint}/openai/v1/responses`) rather than the older Agents
6//! threads/runs data plane (removed upstream, along with the `azure-ai-agents`
7//! SDK it wrapped — see this crate's own history / `UPSTREAM_DRIFT.md`).
8//!
9//! [`FoundryChatClient`] does not speak HTTP/SSE itself: it wraps an
10//! [`agent_framework_azure::responses::AzureOpenAIResponsesClient`]
11//! configured for a Foundry project endpoint (Microsoft Entra ID bearer auth
12//! or a static API key), with
13//! [`without_api_version`](agent_framework_azure::responses::AzureOpenAIResponsesClient::without_api_version)
14//! applied, since the Foundry v1 GA route is path-versioned
15//! (`{endpoint}/openai/v1/responses`, no `?api-version=` query parameter).
16//! All request/response conversion, streaming, and error classification are
17//! reused verbatim from that client (which itself reuses
18//! `agent_framework_openai::responses`), so wire fidelity comes for free
19//! rather than being re-implemented here.
20//!
21//! ```no_run
22//! use std::sync::Arc;
23//! use agent_framework_azure::AzureCliCredential;
24//! use agent_framework_core::prelude::*;
25//! use agent_framework_foundry::{FoundryChatClient, FOUNDRY_SCOPE};
26//!
27//! # async fn demo() -> Result<()> {
28//! let credential = Arc::new(AzureCliCredential::new(FOUNDRY_SCOPE));
29//! let client = FoundryChatClient::with_token_credential(
30//!     "https://my-project.services.ai.azure.com",
31//!     "gpt-4o",
32//!     credential,
33//! );
34//! let agent = Agent::builder(client).instructions("You are concise.").build();
35//! let reply = agent.run_once("Say hi").await?;
36//! println!("{}", reply.text());
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! ## Prompt Agents
42//!
43//! [`FoundryAgent`] realizes a Foundry **Prompt Agent** *client-side*: it
44//! pairs a [`FoundryChatClient`] with a [`PromptAgentDefinition`]
45//! (name/model/instructions/tools) and runs it over the Responses API through
46//! an inner [`agent_framework_core::agent::Agent`]. [`FoundryAgent::to_prompt_agent`]
47//! hands back the definition it was built from, mirroring upstream's
48//! `Agent.to_prompt_agent()` -> `PromptAgentDefinition`.
49//!
50//! **This does not bind to a *server-hosted* agent** by id/name (the Foundry
51//! Agents control plane, e.g. `AIProjectClient.agents.get(...)`) — see the
52//! [`FoundryAgent`] docs for why that's a documented extension point rather
53//! than something wired up here.
54//!
55//! ```no_run
56//! # use agent_framework_foundry::FoundryChatClient;
57//! use agent_framework_core::prelude::*;
58//! use agent_framework_foundry::FoundryAgent;
59//!
60//! # async fn demo(client: FoundryChatClient) -> Result<()> {
61//! let agent = FoundryAgent::builder(client)
62//!     .name("rust-example-agent")
63//!     .instructions("You are a helpful, concise assistant.")
64//!     .build();
65//! let reply = agent.run(vec![Message::user("Say hi")], None).await?;
66//! println!("{}", reply.text());
67//!
68//! // Round-trips the definition the agent was built from.
69//! let definition = agent.to_prompt_agent();
70//! assert_eq!(definition.name, "rust-example-agent");
71//! # Ok(())
72//! # }
73//! ```
74
75mod tool_definition_wire;
76
77use std::sync::Arc;
78
79use agent_framework_azure::responses::AzureOpenAIResponsesClient;
80use agent_framework_azure::TokenCredential;
81use agent_framework_core::agent::{Agent, AgentRunOptions, AgentRunStream, SupportsAgentRun};
82use agent_framework_core::client::{ChatClient, ChatStream};
83use agent_framework_core::error::{Error, Result};
84use agent_framework_core::session::AgentSession;
85use agent_framework_core::tools::ToolDefinition;
86use agent_framework_core::types::{AgentResponse, ChatOptions, ChatResponse, Message};
87use async_trait::async_trait;
88use serde::{Deserialize, Serialize};
89
90/// The Entra ID scope (audience) for the Azure AI Foundry data plane.
91///
92/// [`AzureOpenAIResponsesClient`] (the transport [`FoundryChatClient`]
93/// delegates to) has no per-request scope override — it calls
94/// [`TokenCredential::get_token`], trusting the credential to already be
95/// bound to the right audience. Construct the credential with this scope,
96/// e.g. `AzureCliCredential::new(FOUNDRY_SCOPE)` or
97/// `DefaultAzureCredential::new(FOUNDRY_SCOPE)`.
98pub const FOUNDRY_SCOPE: &str = "https://ai.azure.com/.default";
99
100// ---------------------------------------------------------------------------
101// FoundryChatClient
102// ---------------------------------------------------------------------------
103
104/// A [`ChatClient`] for the Azure AI Foundry project Responses API.
105///
106/// A thin wrapper around [`AzureOpenAIResponsesClient`]: constructs it
107/// against the Foundry project `endpoint` and `model` (deployment) with
108/// [`without_api_version`](AzureOpenAIResponsesClient::without_api_version)
109/// applied (see the [module docs](self)). All request building, response
110/// parsing, SSE streaming, and HTTP-error classification are reused verbatim
111/// from that client — this type adds nothing beyond Foundry-shaped
112/// constructors and a plain delegating [`ChatClient`] impl.
113#[derive(Clone)]
114pub struct FoundryChatClient {
115    inner: AzureOpenAIResponsesClient,
116    endpoint: String,
117    model: String,
118}
119
120impl std::fmt::Debug for FoundryChatClient {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("FoundryChatClient")
123            .field("endpoint", &self.endpoint)
124            .field("model", &self.model)
125            .finish_non_exhaustive()
126    }
127}
128
129impl FoundryChatClient {
130    /// Create a client authenticating with a static API key (`api-key`
131    /// header).
132    pub fn new(
133        endpoint: impl Into<String>,
134        model: impl Into<String>,
135        api_key: impl Into<String>,
136    ) -> Self {
137        let endpoint = endpoint.into();
138        let model = model.into();
139        let inner = AzureOpenAIResponsesClient::new(endpoint.clone(), model.clone(), api_key)
140            .without_api_version()
141            .without_implicit_encrypted_reasoning();
142        Self {
143            inner,
144            endpoint,
145            model,
146        }
147    }
148
149    /// Create a client authenticating via a [`TokenCredential`] (Microsoft
150    /// Entra ID `Authorization: Bearer <token>`) — the primary Foundry auth
151    /// path. The credential should already be scoped to [`FOUNDRY_SCOPE`],
152    /// e.g. `AzureCliCredential::new(FOUNDRY_SCOPE)`.
153    pub fn with_token_credential(
154        endpoint: impl Into<String>,
155        model: impl Into<String>,
156        credential: Arc<dyn TokenCredential>,
157    ) -> Self {
158        let endpoint = endpoint.into();
159        let model = model.into();
160        let inner = AzureOpenAIResponsesClient::with_token_credential(
161            endpoint.clone(),
162            model.clone(),
163            credential,
164        )
165        .without_api_version()
166        .without_implicit_encrypted_reasoning();
167        Self {
168            inner,
169            endpoint,
170            model,
171        }
172    }
173
174    /// Build a client from environment variables.
175    ///
176    /// Reads `FOUNDRY_ENDPOINT` (alias `FOUNDRY_PROJECT_ENDPOINT`) and
177    /// `FOUNDRY_MODEL` — the `FOUNDRY_*`/`model` naming mirrors upstream's
178    /// shift away from the old `AZURE_AI_*`/`model_deployment_name`
179    /// convention. When `FOUNDRY_API_KEY` is set it authenticates with that
180    /// key; otherwise it falls back to a
181    /// [`DefaultAzureCredential`](agent_framework_azure::DefaultAzureCredential)
182    /// scoped to [`FOUNDRY_SCOPE`] (a chain that tries a managed identity,
183    /// then client-secret env vars, then the Azure CLI).
184    ///
185    /// # Errors
186    /// [`Error::Configuration`] when neither endpoint variable, or
187    /// `FOUNDRY_MODEL`, is set.
188    pub fn from_env() -> Result<Self> {
189        Self::from_env_vars(|key| std::env::var(key).ok())
190    }
191
192    /// Implementation of [`from_env`](Self::from_env), parameterized over an
193    /// environment lookup function so the parsing/validation logic is
194    /// testable against an in-memory map instead of real process env vars.
195    fn from_env_vars(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
196        let endpoint = get("FOUNDRY_ENDPOINT")
197            .or_else(|| get("FOUNDRY_PROJECT_ENDPOINT"))
198            .ok_or_else(|| {
199                Error::Configuration(
200                    "FOUNDRY_ENDPOINT (or FOUNDRY_PROJECT_ENDPOINT) is not set".into(),
201                )
202            })?;
203        let model = get("FOUNDRY_MODEL")
204            .ok_or_else(|| Error::Configuration("FOUNDRY_MODEL is not set".into()))?;
205        Ok(match get("FOUNDRY_API_KEY") {
206            Some(api_key) => Self::new(endpoint, model, api_key),
207            None => {
208                let credential: Arc<dyn TokenCredential> = Arc::new(
209                    agent_framework_azure::DefaultAzureCredential::new(FOUNDRY_SCOPE),
210                );
211                Self::with_token_credential(endpoint, model, credential)
212            }
213        })
214    }
215
216    /// Override the full base URL requests are built against — see
217    /// [`AzureOpenAIResponsesClient::with_base_url`].
218    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
219        self.inner = self.inner.with_base_url(base_url);
220        self
221    }
222
223    /// The Foundry project endpoint this client targets.
224    pub fn endpoint(&self) -> &str {
225        &self.endpoint
226    }
227
228    /// The model deployment name this client targets.
229    pub fn model_name(&self) -> &str {
230        &self.model
231    }
232}
233
234#[async_trait]
235impl ChatClient for FoundryChatClient {
236    async fn get_response(
237        &self,
238        messages: Vec<Message>,
239        options: ChatOptions,
240    ) -> Result<ChatResponse> {
241        self.inner.get_response(messages, options).await
242    }
243
244    async fn get_streaming_response(
245        &self,
246        messages: Vec<Message>,
247        options: ChatOptions,
248    ) -> Result<ChatStream> {
249        self.inner.get_streaming_response(messages, options).await
250    }
251
252    fn model(&self) -> Option<&str> {
253        self.inner.model()
254    }
255}
256
257// ---------------------------------------------------------------------------
258// PromptAgentDefinition
259// ---------------------------------------------------------------------------
260
261/// The serializable definition of a Foundry **Prompt Agent**: a name, a
262/// model, optional instructions/description, and its tools.
263///
264/// Maps to upstream's `PromptAgentDefinition` (the declarative shape a Prompt
265/// Agent is created from, and that `Agent.to_prompt_agent()` emits). Used
266/// here to build a [`FoundryAgent`] ([`FoundryAgent::from_definition`]) and to
267/// read one back ([`FoundryAgent::to_prompt_agent`]).
268///
269/// [`ToolDefinition`] itself does not implement `Serialize`/`Deserialize` (a
270/// function tool carries a `dyn Tool` local executor, which isn't
271/// serializable), so `tools` round-trips through a private wire shape
272/// capturing only the declarative fields a definition needs — see
273/// `tool_definition_wire`. A tool
274/// deserialized back out of a `PromptAgentDefinition` always has
275/// `executor: None`: a definition describes what a tool *is*, not a live
276/// local implementation to call it.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct PromptAgentDefinition {
279    /// The agent's name.
280    pub name: String,
281    /// The model deployment this agent runs on.
282    pub model: String,
283    /// The system prompt / instructions.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub instructions: Option<String>,
286    /// A human-readable description of what the agent does.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub description: Option<String>,
289    /// The tools available to the agent.
290    #[serde(
291        default,
292        skip_serializing_if = "Vec::is_empty",
293        with = "tool_definition_wire::vec"
294    )]
295    pub tools: Vec<ToolDefinition>,
296}
297
298impl PromptAgentDefinition {
299    /// Create a definition with just a name and model — the minimum needed
300    /// to run a Prompt Agent.
301    pub fn new(name: impl Into<String>, model: impl Into<String>) -> Self {
302        Self {
303            name: name.into(),
304            model: model.into(),
305            instructions: None,
306            description: None,
307            tools: Vec::new(),
308        }
309    }
310}
311
312// ---------------------------------------------------------------------------
313// FoundryAgent
314// ---------------------------------------------------------------------------
315
316/// A [`SupportsAgentRun`] that runs a Foundry **Prompt Agent** over the
317/// Responses API.
318///
319/// Built from a [`FoundryChatClient`] plus a [`PromptAgentDefinition`]
320/// (name/instructions/model/tools): internally it constructs a core
321/// [`Agent`] over that client with the definition applied, and every
322/// [`SupportsAgentRun`] method delegates straight through to it.
323/// [`to_prompt_agent`](Self::to_prompt_agent) hands back the definition the
324/// agent was built from, so it round-trips through
325/// [`FoundryAgent::from_definition`].
326///
327/// This realizes a Prompt Agent **client-side**, entirely through the
328/// stateless Responses API — it does not create, fetch, or bind to a
329/// *server-hosted* agent by id/name on the Foundry Agents control plane
330/// (`AIProjectClient.agents.*`). Wiring up that control plane, so
331/// [`FoundryAgent`] could target a Prompt Agent or Hosted Agent that already
332/// exists on the service (e.g. a future `agent_id`/`with_existing_agent`
333/// constructor), is a documented extension point, not implemented here.
334#[derive(Clone)]
335pub struct FoundryAgent {
336    inner: Agent,
337    definition: PromptAgentDefinition,
338}
339
340impl std::fmt::Debug for FoundryAgent {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("FoundryAgent")
343            .field("definition", &self.definition)
344            .finish_non_exhaustive()
345    }
346}
347
348impl FoundryAgent {
349    /// Start building a [`FoundryAgent`] over `client`.
350    pub fn builder(client: FoundryChatClient) -> FoundryAgentBuilder {
351        FoundryAgentBuilder::new(client)
352    }
353
354    /// Build a [`FoundryAgent`] directly from a [`PromptAgentDefinition`].
355    ///
356    /// The inner [`Agent`]'s id and name are both set to `definition.name`
357    /// (the core [`AgentBuilder`](agent_framework_core::agent::AgentBuilder)
358    /// otherwise defaults `id` to a random UUID).
359    pub fn from_definition(client: FoundryChatClient, definition: PromptAgentDefinition) -> Self {
360        let mut builder = Agent::builder(client)
361            .id(definition.name.clone())
362            .name(definition.name.clone())
363            .model(definition.model.clone())
364            .tools(definition.tools.clone());
365        if let Some(instructions) = &definition.instructions {
366            builder = builder.instructions(instructions.clone());
367        }
368        if let Some(description) = &definition.description {
369            builder = builder.description(description.clone());
370        }
371        Self {
372            inner: builder.build(),
373            definition,
374        }
375    }
376
377    /// The [`PromptAgentDefinition`] this agent was built from — round-trips
378    /// through [`FoundryAgent::from_definition`].
379    pub fn to_prompt_agent(&self) -> PromptAgentDefinition {
380        self.definition.clone()
381    }
382
383    /// The wrapped core [`Agent`], for functionality (e.g. [`Agent::as_tool`])
384    /// not exposed directly on [`FoundryAgent`].
385    pub fn inner(&self) -> &Agent {
386        &self.inner
387    }
388}
389
390#[async_trait]
391impl SupportsAgentRun for FoundryAgent {
392    async fn run(
393        &self,
394        messages: Vec<Message>,
395        session: Option<&mut AgentSession>,
396    ) -> Result<AgentResponse> {
397        self.inner.run(messages, session).await
398    }
399
400    async fn run_with_options(
401        &self,
402        messages: Vec<Message>,
403        session: Option<&mut AgentSession>,
404        options: AgentRunOptions,
405    ) -> Result<AgentResponse> {
406        self.inner
407            .run_with_options(messages, session, options)
408            .await
409    }
410
411    async fn run_stream(
412        &self,
413        messages: Vec<Message>,
414        session: Option<AgentSession>,
415        options: Option<AgentRunOptions>,
416    ) -> Result<AgentRunStream> {
417        self.inner.run_stream(messages, session, options).await
418    }
419
420    fn id(&self) -> &str {
421        self.inner.id()
422    }
423
424    fn name(&self) -> Option<&str> {
425        self.inner.name()
426    }
427
428    fn display_name(&self) -> String {
429        self.inner.display_name()
430    }
431
432    fn create_session(&self) -> AgentSession {
433        self.inner.create_session()
434    }
435}
436
437/// Builds a [`FoundryAgent`] from a [`FoundryChatClient`] plus the pieces of
438/// a [`PromptAgentDefinition`] (name/instructions/model/tools).
439pub struct FoundryAgentBuilder {
440    client: FoundryChatClient,
441    name: Option<String>,
442    model: String,
443    instructions: Option<String>,
444    description: Option<String>,
445    tools: Vec<ToolDefinition>,
446}
447
448impl FoundryAgentBuilder {
449    fn new(client: FoundryChatClient) -> Self {
450        let model = client.model_name().to_string();
451        Self {
452            client,
453            name: None,
454            model,
455            instructions: None,
456            description: None,
457            tools: Vec::new(),
458        }
459    }
460
461    /// Set the agent's name (default: the client's configured model name).
462    pub fn name(mut self, name: impl Into<String>) -> Self {
463        self.name = Some(name.into());
464        self
465    }
466
467    /// Override the model deployment (default: the client's configured
468    /// model).
469    pub fn model(mut self, model: impl Into<String>) -> Self {
470        self.model = model.into();
471        self
472    }
473
474    /// Set the system prompt / instructions.
475    pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
476        self.instructions = Some(instructions.into());
477        self
478    }
479
480    /// Set a human-readable description of what the agent does.
481    pub fn description(mut self, description: impl Into<String>) -> Self {
482        self.description = Some(description.into());
483        self
484    }
485
486    /// Add one tool.
487    pub fn tool(mut self, tool: ToolDefinition) -> Self {
488        self.tools.push(tool);
489        self
490    }
491
492    /// Add several tools.
493    pub fn tools(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
494        self.tools.extend(tools);
495        self
496    }
497
498    /// Build the [`FoundryAgent`].
499    pub fn build(self) -> FoundryAgent {
500        let name = self.name.unwrap_or_else(|| self.model.clone());
501        let definition = PromptAgentDefinition {
502            name,
503            model: self.model,
504            instructions: self.instructions,
505            description: self.description,
506            tools: self.tools,
507        };
508        FoundryAgent::from_definition(self.client, definition)
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use agent_framework_azure::StaticTokenCredential;
516    use agent_framework_core::tools::{hosted_code_interpreter, FunctionTool};
517    use serde_json::json;
518
519    fn client() -> FoundryChatClient {
520        FoundryChatClient::with_token_credential(
521            "https://my-project.services.ai.azure.com",
522            "gpt-4o",
523            Arc::new(StaticTokenCredential::new("test-token")),
524        )
525    }
526
527    #[test]
528    fn client_reports_endpoint_and_model() {
529        let c = client();
530        assert_eq!(c.endpoint(), "https://my-project.services.ai.azure.com");
531        assert_eq!(c.model_name(), "gpt-4o");
532        assert_eq!(c.model(), Some("gpt-4o"));
533    }
534
535    // -- from_env -------------------------------------------------------
536
537    #[test]
538    fn from_env_errors_without_endpoint() {
539        let err = FoundryChatClient::from_env_vars(|key| match key {
540            "FOUNDRY_MODEL" => Some("gpt-4o".into()),
541            _ => None,
542        })
543        .unwrap_err();
544        assert!(matches!(err, Error::Configuration(_)), "{err:?}");
545        assert!(err.to_string().contains("FOUNDRY_ENDPOINT"));
546    }
547
548    #[test]
549    fn from_env_errors_without_model() {
550        let err = FoundryChatClient::from_env_vars(|key| match key {
551            "FOUNDRY_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
552            _ => None,
553        })
554        .unwrap_err();
555        assert!(matches!(err, Error::Configuration(_)), "{err:?}");
556        assert!(err.to_string().contains("FOUNDRY_MODEL"));
557    }
558
559    #[test]
560    fn from_env_accepts_project_endpoint_alias() {
561        let c = FoundryChatClient::from_env_vars(|key| match key {
562            "FOUNDRY_PROJECT_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
563            "FOUNDRY_MODEL" => Some("gpt-4o".into()),
564            "FOUNDRY_API_KEY" => Some("key-123".into()),
565            _ => None,
566        })
567        .unwrap();
568        assert_eq!(c.endpoint(), "https://proj.services.ai.azure.com");
569        assert_eq!(c.model_name(), "gpt-4o");
570    }
571
572    #[test]
573    fn from_env_with_api_key_authenticates_by_key() {
574        let c = FoundryChatClient::from_env_vars(|key| match key {
575            "FOUNDRY_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
576            "FOUNDRY_MODEL" => Some("gpt-4o".into()),
577            "FOUNDRY_API_KEY" => Some("key-123".into()),
578            _ => None,
579        })
580        .unwrap();
581        assert_eq!(c.model_name(), "gpt-4o");
582    }
583
584    #[test]
585    fn from_env_without_api_key_falls_back_to_default_azure_credential() {
586        // No FOUNDRY_API_KEY: this must still construct successfully (via
587        // DefaultAzureCredential) rather than erroring.
588        let c = FoundryChatClient::from_env_vars(|key| match key {
589            "FOUNDRY_ENDPOINT" => Some("https://proj.services.ai.azure.com".into()),
590            "FOUNDRY_MODEL" => Some("gpt-4o".into()),
591            _ => None,
592        })
593        .unwrap();
594        assert_eq!(c.endpoint(), "https://proj.services.ai.azure.com");
595    }
596
597    // -- PromptAgentDefinition serde round trip --------------------------
598
599    #[test]
600    fn prompt_agent_definition_round_trips_through_json() {
601        let tool = FunctionTool::new(
602            "get_weather",
603            "Get the weather",
604            json!({"type": "object", "properties": {}}),
605            |_| async { Ok(json!("ok")) },
606        )
607        .into_definition();
608        let definition = PromptAgentDefinition {
609            name: "weather-bot".into(),
610            model: "gpt-4o".into(),
611            instructions: Some("Be terse.".into()),
612            description: Some("Answers weather questions.".into()),
613            tools: vec![tool, hosted_code_interpreter()],
614        };
615
616        let json = serde_json::to_string(&definition).unwrap();
617        let back: PromptAgentDefinition = serde_json::from_str(&json).unwrap();
618
619        assert_eq!(back.name, "weather-bot");
620        assert_eq!(back.model, "gpt-4o");
621        assert_eq!(back.instructions.as_deref(), Some("Be terse."));
622        assert_eq!(
623            back.description.as_deref(),
624            Some("Answers weather questions.")
625        );
626        assert_eq!(back.tools.len(), 2);
627        assert_eq!(back.tools[0].name, "get_weather");
628        assert!(back.tools[0].executor.is_none());
629    }
630
631    #[test]
632    fn prompt_agent_definition_omits_empty_optional_fields() {
633        let definition = PromptAgentDefinition::new("bare", "gpt-4o");
634        let json = serde_json::to_value(&definition).unwrap();
635        assert!(json.get("instructions").is_none());
636        assert!(json.get("description").is_none());
637        assert!(json.get("tools").is_none());
638    }
639
640    // -- FoundryAgent -----------------------------------------------------
641
642    #[test]
643    fn from_definition_to_prompt_agent_round_trips() {
644        let definition = PromptAgentDefinition {
645            name: "rust-example-agent".into(),
646            model: "gpt-4o".into(),
647            instructions: Some("You are concise.".into()),
648            description: Some("An example agent.".into()),
649            tools: vec![hosted_code_interpreter()],
650        };
651        let agent = FoundryAgent::from_definition(client(), definition.clone());
652
653        let round_tripped = agent.to_prompt_agent();
654        assert_eq!(round_tripped.name, definition.name);
655        assert_eq!(round_tripped.model, definition.model);
656        assert_eq!(round_tripped.instructions, definition.instructions);
657        assert_eq!(round_tripped.description, definition.description);
658        assert_eq!(round_tripped.tools.len(), 1);
659
660        assert_eq!(agent.id(), "rust-example-agent");
661        assert_eq!(agent.name(), Some("rust-example-agent"));
662    }
663
664    #[test]
665    fn builder_defaults_name_to_the_client_model() {
666        let agent = FoundryAgent::builder(client()).build();
667        assert_eq!(agent.to_prompt_agent().name, "gpt-4o");
668        assert_eq!(agent.to_prompt_agent().model, "gpt-4o");
669    }
670
671    #[test]
672    fn builder_collects_instructions_description_and_tools() {
673        let agent = FoundryAgent::builder(client())
674            .name("named-agent")
675            .instructions("Be helpful.")
676            .description("A test agent.")
677            .tool(hosted_code_interpreter())
678            .build();
679        let definition = agent.to_prompt_agent();
680        assert_eq!(definition.name, "named-agent");
681        assert_eq!(definition.instructions.as_deref(), Some("Be helpful."));
682        assert_eq!(definition.description.as_deref(), Some("A test agent."));
683        assert_eq!(definition.tools.len(), 1);
684    }
685}