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