From 1e527a328cff67445035fbc77978991d88c1be1d Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Tue, 31 Mar 2026 17:57:23 -0400 Subject: [PATCH 01/20] Python: Remove unsupported memory scoping params from mem0/redis samples and docs (#4367) * Python: Remove unsupported memory scoping params from samples and docs Fixes #4353 The `Mem0ContextProvider` and `RedisContextProvider` no longer support `thread_id` or `scope_to_per_operation_thread_id` parameters. This commit updates the affected samples and READMEs to use only the currently supported API (`user_id`, `agent_id`, `application_id`). Changes: - mem0_sessions.py: Remove `thread_id` and `scope_to_per_operation_thread_id` from examples 1 and 2, rewrite to demonstrate user-scoped and agent-scoped memory patterns - redis_sessions.py: Update module docstring to remove references to removed thread scoping params - mem0/README.md: Update Memory Scoping docs to reflect current API - redis/README.md: Remove `thread_id` and `scope_to_per_operation_thread_id` references from docs * Address Copilot review: rename thread_scope functions, fix docstring - Rename `example_global_thread_scope` -> `example_global_memory_scope` - Rename `example_per_operation_thread_scope` -> `example_agent_scoped_memory` - Update example 2 docstring to mention `application_id` alongside `user_id` and `agent_id` since it's set in the provider config - Update module docstring scenario 2 to include `application_id` * fix: rebase onto main, address giles17 review feedback - Resolve merge conflicts by rebasing all 4 original files onto current main - Address giles17's agent review suggestions: - mem0_basic.py: update comment to remove thread_id from scoping list - mem0_oss.py: update comment to remove thread_id from scoping list - redis_sessions.py: rename Example 2 from "Agent-Scoped Memory" to "Hybrid Vector Search" to accurately describe what it demonstrates - redis/README.md: update Example 2 description to match renamed example --------- Co-authored-by: Tao Chen Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com> --- .../context_providers/mem0/README.md | 18 ++--- .../context_providers/mem0/mem0_basic.py | 2 +- .../context_providers/mem0/mem0_oss.py | 2 +- .../context_providers/mem0/mem0_sessions.py | 75 +++++++++---------- .../context_providers/redis/README.md | 11 ++- .../context_providers/redis/redis_sessions.py | 72 +++++++++--------- 6 files changed, 81 insertions(+), 99 deletions(-) diff --git a/python/samples/02-agents/context_providers/mem0/README.md b/python/samples/02-agents/context_providers/mem0/README.md index 2a7e3416d6..c4a3cdbc61 100644 --- a/python/samples/02-agents/context_providers/mem0/README.md +++ b/python/samples/02-agents/context_providers/mem0/README.md @@ -9,7 +9,7 @@ This folder contains examples demonstrating how to use the Mem0 context provider | File | Description | |------|-------------| | [`mem0_basic.py`](mem0_basic.py) | Basic example of using Mem0 context provider to store and retrieve user preferences across different conversation threads. | -| [`mem0_sessions.py`](mem0_sessions.py) | Advanced example demonstrating different thread scoping strategies with Mem0. Covers global thread scope (memories shared across all operations), per-operation thread scope (memories isolated per thread), and multiple agents with different memory configurations for personal vs. work contexts. | +| [`mem0_sessions.py`](mem0_sessions.py) | Example demonstrating different memory scoping strategies with Mem0. Covers user-scoped memory (memories shared across all sessions for the same user), agent-scoped memory (memories isolated per agent), and multiple agents with different memory configurations for personal vs. work contexts. | | [`mem0_oss.py`](mem0_oss.py) | Example of using the Mem0 Open Source self-hosted version as the context provider. Demonstrates setup and configuration for local deployment. | ## Prerequisites @@ -40,16 +40,8 @@ Set the following environment variables: ### Memory Scoping -The Mem0 context provider supports different scoping strategies: +The Mem0 context provider supports scoping via identifiers: -- **Global Scope** (`scope_to_per_operation_thread_id=False`): Memories are shared across all conversation threads -- **Thread Scope** (`scope_to_per_operation_thread_id=True`): Memories are isolated per conversation thread - -### Memory Association - -Mem0 records can be associated with different identifiers: - -- `user_id`: Associate memories with a specific user -- `agent_id`: Associate memories with a specific agent -- `thread_id`: Associate memories with a specific conversation thread -- `application_id`: Associate memories with an application context +- **User scope** (`user_id`): Associate memories with a specific user, shared across all sessions +- **Agent scope** (`agent_id`): Isolate memories per agent persona +- **Application scope** (`application_id`): Associate memories with an application context diff --git a/python/samples/02-agents/context_providers/mem0/mem0_basic.py b/python/samples/02-agents/context_providers/mem0/mem0_basic.py index 46b185573b..489e5bfcae 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_basic.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_basic.py @@ -31,7 +31,7 @@ def retrieve_company_report(company_code: str, detailed: bool) -> str: async def main() -> None: """Example of memory usage with Mem0 context provider.""" print("=== Mem0 Context Provider Example ===") - # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. + # Each record in Mem0 should be associated with agent_id or user_id or application_id. # In this example, we associate Mem0 records with user_id. user_id = str(uuid.uuid4()) # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred diff --git a/python/samples/02-agents/context_providers/mem0/mem0_oss.py b/python/samples/02-agents/context_providers/mem0/mem0_oss.py index 9126ab4bdc..a0c4bbb372 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_oss.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_oss.py @@ -32,7 +32,7 @@ def retrieve_company_report(company_code: str, detailed: bool) -> str: async def main() -> None: """Example of memory usage with local Mem0 OSS context provider.""" print("=== Mem0 Context Provider Example ===") - # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. + # Each record in Mem0 should be associated with agent_id or user_id or application_id. # In this example, we associate Mem0 records with user_id. user_id = str(uuid.uuid4()) # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred diff --git a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py index 80976fa8f3..d402b34baf 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import uuid from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient @@ -27,52 +26,49 @@ def get_user_preferences(user_id: str) -> str: return preferences.get(user_id, "No specific preferences found") -async def example_global_thread_scope() -> None: - """Example 1: Global thread_id scope (memories shared across all operations).""" - print("1. Global Thread Scope Example:") +async def example_user_scoped_memory() -> None: + """Example 1: User-scoped memory (memories shared across all sessions for the same user).""" + print("1. User-Scoped Memory Example:") print("-" * 40) - global_thread_id = str(uuid.uuid4()) user_id = "user123" async with ( AzureCliCredential() as credential, Agent( client=FoundryChatClient(credential=credential), - name="GlobalMemoryAssistant", + name="UserMemoryAssistant", instructions="You are an assistant that remembers user preferences across conversations.", tools=get_user_preferences, context_providers=[ Mem0ContextProvider( source_id="mem0", user_id=user_id, - thread_id=global_thread_id, - scope_to_per_operation_thread_id=False, # Share memories across all sessions ) ], - ) as global_agent, + ) as user_agent, ): - # Store some preferences in the global scope + # Store some preferences query = "Remember that I prefer technical responses with code examples when discussing programming." print(f"User: {query}") - result = await global_agent.run(query) + result = await user_agent.run(query) print(f"Agent: {result}\n") - # Create a new session - but memories should still be accessible due to global scope - new_session = global_agent.create_session() + # Create a new session - memories should still be accessible via user_id scoping + new_session = user_agent.create_session() query = "What do you know about my preferences?" print(f"User (new session): {query}") - result = await global_agent.run(query, session=new_session) + result = await user_agent.run(query, session=new_session) print(f"Agent: {result}\n") -async def example_per_operation_thread_scope() -> None: - """Example 2: Per-operation thread scope (memories isolated per session). +async def example_agent_scoped_memory() -> None: + """Example 2: Agent-scoped memory (memories isolated per agent_id). - Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session - throughout its lifetime. Use the same session object for all operations with that provider. + Note: Use different agent_id values to isolate memories between different + agent personas, even when the user_id is the same. """ - print("2. Per-Operation Thread Scope Example:") + print("2. Agent-Scoped Memory Example:") print("-" * 40) user_id = "user123" @@ -82,48 +78,45 @@ async def example_per_operation_thread_scope() -> None: Agent( client=FoundryChatClient(credential=credential), name="ScopedMemoryAssistant", - instructions="You are an assistant with thread-scoped memory.", + instructions="You are an assistant with agent-scoped memory.", tools=get_user_preferences, context_providers=[ Mem0ContextProvider( source_id="mem0", user_id=user_id, - scope_to_per_operation_thread_id=True, # Isolate memories per session + agent_id="scoped_assistant", ) ], ) as scoped_agent, ): - # Create a specific session for this scoped provider - dedicated_session = scoped_agent.create_session() - - # Store some information in the dedicated session + # Store some information query = "Remember that for this conversation, I'm working on a Python project about data analysis." - print(f"User (dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await scoped_agent.run(query) print(f"Agent: {result}\n") - # Test memory retrieval in the same dedicated session + # Test memory retrieval query = "What project am I working on?" - print(f"User (same dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await scoped_agent.run(query) print(f"Agent: {result}\n") - # Store more information in the same session + # Store more information query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User (same dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await scoped_agent.run(query) print(f"Agent: {result}\n") # Test comprehensive memory retrieval query = "What do you know about my current project and preferences?" - print(f"User (same dedicated session): {query}") - result = await scoped_agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await scoped_agent.run(query) print(f"Agent: {result}\n") async def example_multiple_agents() -> None: - """Example 3: Multiple agents with different thread configurations.""" - print("3. Multiple Agents with Different Thread Configurations:") + """Example 3: Multiple agents with different memory configurations.""" + print("3. Multiple Agents with Different Memory Configurations:") print("-" * 40) agent_id_1 = "agent_personal" @@ -178,11 +171,11 @@ async def example_multiple_agents() -> None: async def main() -> None: - """Run all Mem0 thread management examples.""" - print("=== Mem0 Thread Management Example ===\n") + """Run all Mem0 memory management examples.""" + print("=== Mem0 Memory Management Example ===\n") - await example_global_thread_scope() - await example_per_operation_thread_scope() + await example_user_scoped_memory() + await example_agent_scoped_memory() await example_multiple_agents() diff --git a/python/samples/02-agents/context_providers/redis/README.md b/python/samples/02-agents/context_providers/redis/README.md index 097aa6b4eb..104ea3a5b8 100644 --- a/python/samples/02-agents/context_providers/redis/README.md +++ b/python/samples/02-agents/context_providers/redis/README.md @@ -11,7 +11,7 @@ This folder contains an example demonstrating how to use the Redis context provi | [`azure_redis_conversation.py`](azure_redis_conversation.py) | Demonstrates conversation persistence with RedisHistoryProvider and Azure Redis with Azure AD (Entra ID) authentication using credential provider. | | [`redis_basics.py`](redis_basics.py) | Shows standalone provider usage and agent integration. Demonstrates writing messages to Redis, retrieving context via full‑text or hybrid vector search, and persisting preferences across threads. Also includes a simple tool example whose outputs are remembered. | | [`redis_conversation.py`](redis_conversation.py) | Simple example showing conversation persistence with RedisContextProvider using traditional connection string authentication. | -| [`redis_sessions.py`](redis_sessions.py) | Demonstrates thread scoping. Includes: (1) global thread scope with a fixed `thread_id` shared across operations; (2) per‑operation thread scope where `scope_to_per_operation_thread_id=True` binds memory to a single thread for the provider's lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. | +| [`redis_sessions.py`](redis_sessions.py) | Demonstrates memory scoping strategies. Includes: (1) global memory scope with `application_id`, `agent_id`, and `user_id` shared across operations; (2) hybrid vector search using a custom OpenAI vectorizer for richer context retrieval; and (3) multiple agents with isolated memory via different `agent_id` values. | ## Prerequisites @@ -61,8 +61,7 @@ The provider supports both full‑text only and hybrid vector search: - Set `vectorizer_choice` to `"openai"` or `"hf"` to enable embeddings and hybrid search. - When using a vectorizer, also set `vector_field_name` (e.g., `"vector"`). -- Partition fields for scoping memory: `application_id`, `agent_id`, `user_id`, `thread_id`. -- Thread scoping: `scope_to_per_operation_thread_id=True` isolates memory per operation thread. +- Partition fields for scoping memory: `application_id`, `agent_id`, `user_id`. - Index management: `index_name`, `overwrite_redis_index`, `drop_redis_index`. ## What the example does @@ -104,8 +103,8 @@ You should see the agent responses and, when using embeddings, context retrieved ### Memory scoping -- Global scope: set `application_id`, `agent_id`, `user_id`, or `thread_id` on the provider to filter memory. -- Per‑operation thread scope: set `scope_to_per_operation_thread_id=True` to isolate memory to the current thread created by the framework. +- Global scope: set `application_id`, `agent_id`, or `user_id` on the provider to filter memory. +- Agent isolation: use different `agent_id` values to keep memories separated for different agent personas. ### Hybrid vector search (optional) @@ -118,7 +117,7 @@ You should see the agent responses and, when using embeddings, context retrieved ## Troubleshooting -- Ensure at least one of `application_id`, `agent_id`, `user_id`, or `thread_id` is set; the provider requires a scope. +- Ensure at least one of `application_id`, `agent_id`, or `user_id` is set; the provider requires a scope. - Verify `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` are set for the chat client. - If using embeddings, verify `OPENAI_API_KEY` is set and reachable. - Make sure Redis exposes RediSearch (Redis Stack image or managed service with search enabled). diff --git a/python/samples/02-agents/context_providers/redis/redis_sessions.py b/python/samples/02-agents/context_providers/redis/redis_sessions.py index 92635a5326..7819894f7b 100644 --- a/python/samples/02-agents/context_providers/redis/redis_sessions.py +++ b/python/samples/02-agents/context_providers/redis/redis_sessions.py @@ -1,17 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. -"""Redis Context Provider: Thread scoping examples +"""Redis Context Provider: Memory scoping examples This sample demonstrates how conversational memory can be scoped when using the Redis context provider. It covers three scenarios: -1) Global thread scope - - Provide a fixed thread_id to share memories across operations/threads. +1) Global memory scope + - Use application_id, agent_id, and user_id to share memories across + all operations/sessions. -2) Per-operation thread scope - - Enable scope_to_per_operation_thread_id to bind the provider to a single - thread for the lifetime of that provider instance. Use the same thread - object for reads/writes with that provider. +2) Hybrid vector search + - Use a custom OpenAI vectorizer with the provider for hybrid vector search. + Demonstrates combining full-text and semantic search for richer context + retrieval. 3) Multiple agents with isolated memory - Use different agent_id values to keep memories separated for different @@ -23,7 +24,7 @@ Requirements: - Optionally an OpenAI API key for the chat client in this demo Run: - python redis_threads.py + python redis_sessions.py """ import asyncio @@ -55,9 +56,9 @@ def create_chat_client() -> FoundryChatClient: ) -async def example_global_thread_scope() -> None: - """Example 1: Global thread_id scope (memories shared across all operations).""" - print("1. Global Thread Scope Example:") +async def example_global_memory_scope() -> None: + """Example 1: Global memory scope (memories shared across all operations).""" + print("1. Global Memory Scope Example:") print("-" * 40) client = create_chat_client() @@ -99,13 +100,13 @@ async def example_global_thread_scope() -> None: await provider.redis_index.delete() -async def example_per_operation_thread_scope() -> None: - """Example 2: Per-operation thread scope (memories isolated per session). +async def example_hybrid_vector_search() -> None: + """Example 2: Hybrid vector search with custom vectorizer. - Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session - throughout its lifetime. Use the same session object for all operations with that provider. + Demonstrates using a custom OpenAI vectorizer for hybrid vector search, + combining full-text and semantic search for richer context retrieval. """ - print("2. Per-Operation Thread Scope Example:") + print("2. Hybrid Vector Search Example:") print("-" * 40) client = create_chat_client() @@ -131,36 +132,33 @@ async def example_per_operation_thread_scope() -> None: agent = Agent( client=client, - name="ScopedMemoryAssistant", - instructions="You are an assistant with thread-scoped memory.", + name="HybridSearchAssistant", + instructions="You are an assistant with hybrid vector search for richer context retrieval.", context_providers=[provider], ) - # Create a specific session for this scoped provider - dedicated_session = agent.create_session() - - # Store some information in the dedicated session + # Store some information query = "Remember that for this conversation, I'm working on a Python project about data analysis." - print(f"User (dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") - # Test memory retrieval in the same dedicated session + # Test memory retrieval via hybrid search query = "What project am I working on?" - print(f"User (same dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") - # Store more information in the same session + # Store more information query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User (same dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") # Test comprehensive memory retrieval query = "What do you know about my current project and preferences?" - print(f"User (same dedicated session): {query}") - result = await agent.run(query, session=dedicated_session) + print(f"User: {query}") + result = await agent.run(query) print(f"Agent: {result}\n") # Clean up the Redis index @@ -168,8 +166,8 @@ async def example_per_operation_thread_scope() -> None: async def example_multiple_agents() -> None: - """Example 3: Multiple agents with different thread configurations (isolated via agent_id) but within 1 index.""" - print("3. Multiple Agents with Different Thread Configurations:") + """Example 3: Multiple agents with different memory configurations (isolated via agent_id) but within 1 index.""" + print("3. Multiple Agents with Different Memory Configurations:") print("-" * 40) client = create_chat_client() @@ -247,9 +245,9 @@ async def example_multiple_agents() -> None: async def main() -> None: - print("=== Redis Thread Scoping Examples ===\n") - await example_global_thread_scope() - await example_per_operation_thread_scope() + print("=== Redis Memory Scoping Examples ===\n") + await example_global_memory_scope() + await example_hybrid_vector_search() await example_multiple_agents() From 651e317907f5b65b10bc7382fcfaf3fa82c4b72f Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:58:02 -0700 Subject: [PATCH 02/20] Python: Fix `_add_text_reasoning_content` to preserve `id` during coalescing (#4862) * Fix _add_text_reasoning_content dropping id during coalescing (#4852) Preserve the id field (rs_* identifier) when coalescing text_reasoning Content objects by passing id=self.id or other.id to the Content constructor. This fixes the encrypted reasoning round-trip where the missing id prevented _prepare_content_for_openai from including it in the serialized reasoning item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix `_add_text_reasoning_content` to preserve `id` during coalescing Fixes #4852 * Raise AdditionItemMismatch on conflicting text_reasoning ids (#4852) Detect when both operands have different non-empty ids during text_reasoning Content coalescing and raise AdditionItemMismatch instead of silently keeping one. This prevents mis-associating encrypted_content during round-trips. Also adds tests for conflicting ids and the neither-has-id edge case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4852: Python: [Bug]: Content._add_text_reasoning_content drops id during coalescing, breaking encrypted reasoning round-trip * test fix --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 15 +++- python/packages/core/tests/core/test_types.py | 84 ++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 14c3050952..6d6bc58068 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1380,6 +1380,13 @@ class Content: def _add_text_reasoning_content(self, other: Content) -> Content: """Add two TextReasoningContent instances.""" + # Ensure we do not silently merge contents with conflicting ids + if self.id and other.id and self.id != other.id: + raise AdditionItemMismatch( + f"Cannot add text_reasoning content with different ids: {self.id!r} != {other.id!r}" + ) + combined_id = self.id or other.id + # Concatenate text, handling None values self_text = self.text or "" # type: ignore[attr-defined] other_text = other.text or "" # type: ignore[attr-defined] @@ -1390,6 +1397,7 @@ class Content: return Content( "text_reasoning", + id=combined_id, text=combined_text, protected_data=protected_data, annotations=_combine_annotations(self.annotations, other.annotations), @@ -1880,7 +1888,12 @@ def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "t if first_new_content is None: first_new_content = deepcopy(content) else: - first_new_content += content + try: + first_new_content += content + except AdditionItemMismatch: + # Different IDs means a new logical segment; flush the current one + coalesced_contents.append(first_new_content) + first_new_content = deepcopy(content) else: # skip this content, it is not of the right type # so write the existing one to the list and start a new one, diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 8e5a0dc7d8..9d1b39f29e 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -43,7 +43,7 @@ from agent_framework._types import ( add_usage_details, validate_tool_mode, ) -from agent_framework.exceptions import ContentError +from agent_framework.exceptions import AdditionItemMismatch, ContentError @fixture @@ -1526,6 +1526,88 @@ def test_text_reasoning_content_iadd_coverage(): assert t1.text == "Thinking 1 Thinking 2" +def test_text_reasoning_content_add_preserves_id(): + """Test that coalescing text_reasoning Content preserves the id field.""" + + t1 = Content.from_text_reasoning(id="rs_abc123", text="Thinking part 1") + t2 = Content.from_text_reasoning(id="rs_abc123", text=" part 2") + + result = t1 + t2 + assert result.text == "Thinking part 1 part 2" + assert result.id == "rs_abc123" + + +def test_text_reasoning_content_add_id_fallback_to_other(): + """Test that coalescing falls back to other's id when self has no id.""" + + t1 = Content.from_text_reasoning(text="Thinking part 1") + t2 = Content.from_text_reasoning(id="rs_abc123", text=" part 2") + + result = t1 + t2 + assert result.id == "rs_abc123" + + +def test_text_reasoning_content_add_preserves_id_with_encrypted_content(): + """Test that id and encrypted_content both survive coalescing for round-trip.""" + + t1 = Content.from_text_reasoning( + id="rs_abc123", + text="Thinking", + additional_properties={"encrypted_content": "enc_blob_data"}, + ) + t2 = Content.from_text_reasoning(id="rs_abc123", text=" more") + + result = t1 + t2 + assert result.text == "Thinking more" + assert result.id == "rs_abc123" + assert result.additional_properties.get("encrypted_content") == "enc_blob_data" + + +def test_text_reasoning_content_add_conflicting_ids_raises(): + """Test that coalescing text_reasoning Content with different ids raises AdditionItemMismatch.""" + + t1 = Content.from_text_reasoning(id="rs_abc123", text="Thinking part 1") + t2 = Content.from_text_reasoning(id="rs_xyz789", text=" part 2") + + with pytest.raises(AdditionItemMismatch, match="different ids"): + t1 + t2 + + +def test_text_reasoning_content_add_neither_has_id(): + """Test that coalescing text_reasoning Content when neither has an id results in None id.""" + + t1 = Content.from_text_reasoning(text="Thinking part 1") + t2 = Content.from_text_reasoning(text=" part 2") + + result = t1 + t2 + assert result.text == "Thinking part 1 part 2" + assert result.id is None + + +def test_coalesce_text_reasoning_with_different_ids(): + """Test that _coalesce_text_content keeps separate text_reasoning items when IDs differ. + + Regression test: streaming responses can produce multiple text_reasoning + segments with distinct IDs. These must not be merged into one. + """ + from agent_framework._types import _coalesce_text_content + + contents = [ + Content.from_text_reasoning(id="rs_aaa", text="Thinking A1"), + Content.from_text_reasoning(id="rs_aaa", text=" A2"), + Content.from_text_reasoning(id="rs_bbb", text="Thinking B1"), + Content.from_text_reasoning(id="rs_bbb", text=" B2"), + ] + + _coalesce_text_content(contents, "text_reasoning") + + assert len(contents) == 2 + assert contents[0].id == "rs_aaa" + assert contents[0].text == "Thinking A1 A2" + assert contents[1].id == "rs_bbb" + assert contents[1].text == "Thinking B1 B2" + + def test_comprehensive_to_dict_exclude_options(): """Test to_dict methods with various exclude options for better coverage.""" From d992febe9ba10fe54f6aa08bb416504001d41567 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:04:54 -0700 Subject: [PATCH 03/20] Python: Fix `agent_with_hosted_mcp` sample to use Foundry client for MCP tools (#4867) * Fix agent_with_hosted_mcp sample to use AzureOpenAIResponsesClient (#4861) The agent_with_hosted_mcp sample used AzureOpenAIChatClient with an MCP tool dict, but the Chat Completions API only supports 'function' and 'custom' tool types, not 'mcp'. This caused a 400 error at runtime. Switch the sample to AzureOpenAIResponsesClient which natively supports MCP tools via the Responses API. Use get_mcp_tool() to construct the tool config. Changes: - main.py: Replace AzureOpenAIChatClient with AzureOpenAIResponsesClient - requirements.txt: Update azure-ai-agentserver-agentframework to 1.0.0b16 and use agent-framework-azure-ai package - agent.yaml: Use AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME env var - Add regression test documenting chat client MCP tool passthrough behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix agent_with_hosted_mcp sample to use Responses API client for MCP tools Fixes #4861 * Remove REPRODUCTION_REPORT.md investigation artifact (#4861) Remove the reproduction report markdown file from the test directory. Investigation notes belong in the GitHub issue or PR description, not as committed files in the source tree. The regression test in test_openai_chat_client.py already provides automated verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add MCP tool API rejection regression test (#4861) Add test_mcp_tool_dict_causes_api_rejection to verify that MCP tool dicts passed through to the Chat Completions API result in a clear ChatClientException rather than being silently dropped. This completes the regression test coverage requested in code review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * small fix * Revert deletion of dotnet local.settings.json files Restore the two local.settings.json files that were accidentally deleted in this PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test_openai_chat_completion_client.py | 57 +++++++++++++++++++ .../agent_with_hosted_mcp/main.py | 16 +++--- .../agent_with_hosted_mcp/requirements.txt | 4 +- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 18eff3a54f..8318d164bb 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -237,6 +237,63 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None assert result["tools"] == [dict_tool] +def test_mcp_tool_dict_passed_through_to_chat_api(openai_unit_test_env: dict[str, str]) -> None: + """Test that MCP tool dicts are passed through unchanged by the chat client. + + The Chat Completions API does not support "type": "mcp" tools. MCP tools + should be used with the Responses API client instead. This test documents + that the chat client passes dict-based tools through without filtering, + so callers must use the correct client for MCP tools. + """ + client = OpenAIChatCompletionClient() + + mcp_tool = { + "type": "mcp", + "server_label": "Microsoft_Learn_MCP", + "server_url": "https://learn.microsoft.com/api/mcp", + } + + result = client._prepare_tools_for_openai(mcp_tool) + assert "tools" in result + assert len(result["tools"]) == 1 + # The chat client passes dict tools through unchanged, including unsupported types + assert result["tools"][0]["type"] == "mcp" + + +@pytest.mark.asyncio +async def test_mcp_tool_dict_causes_api_rejection(openai_unit_test_env: dict[str, str]) -> None: + """Test that MCP tool dicts passed to the Chat Completions API cause a rejection. + + The Chat Completions API only supports "type": "function" tools. + When an MCP tool dict reaches the API, it returns a 400 error. + This regression test for #4861 verifies the chat client does not + silently drop or transform MCP dicts, so callers get a clear error + rather than a silent no-op. + """ + client = OpenAIChatCompletionClient() + messages = [Message(role="user", text="test message")] + + mcp_tool = { + "type": "mcp", + "server_label": "Microsoft_Learn_MCP", + "server_url": "https://learn.microsoft.com/api/mcp", + } + + mock_response = MagicMock() + mock_error = BadRequestError( + message="Invalid tool type: mcp", + response=mock_response, + body={"error": {"code": "invalid_request", "message": "Invalid tool type: mcp"}}, + ) + mock_error.code = "invalid_request" + + with ( + patch.object(client.client.chat.completions, "create", side_effect=mock_error), + pytest.raises(ChatClientException), + ): + await client._inner_get_response(messages=messages, options={"tools": mcp_tool}) # type: ignore + + def test_prepare_tools_with_single_function_tool( openai_unit_test_env: dict[str, str], ) -> None: diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py index fe6d4648c9..8d87046fa3 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py @@ -11,18 +11,20 @@ load_dotenv() def main(): + client = FoundryChatClient(credential=AzureCliCredential()) + # Create MCP tool configuration as dict - mcp_tool = { - "type": "mcp", - "server_label": "Microsoft_Learn_MCP", - "server_url": "https://learn.microsoft.com/api/mcp", - } + mcp_tool = client.get_mcp_tool( + name="Microsoft_Learn_MCP", + url="https://learn.microsoft.com/api/mcp", + ) + # Create an Agent using the Azure OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP agent = Agent( - client=FoundryChatClient(credential=AzureCliCredential()), + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=mcp_tool, + tools=[mcp_tool], ) # Run the agent as a hosted agent from_agent_framework(agent).run() diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt index d05845588a..250c059d77 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt @@ -1,2 +1,2 @@ -azure-ai-agentserver-agentframework==1.0.0b3 -agent-framework \ No newline at end of file +azure-ai-agentserver-agentframework==1.0.0b16 +agent-framework From e43fc8ccece565810f0789b79bf8ad88bb2851cd Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Tue, 31 Mar 2026 23:32:30 -0700 Subject: [PATCH 04/20] Python: Fix migration samples (#5015) * Fix migration samples * Fix migration samples 2 * Fix formatting * Comments --- python/samples/autogen-migration/README.md | 8 +-- .../01_round_robin_group_chat.py | 4 +- .../orchestrations/04_magentic_one.py | 2 +- ...c_assistant_agent.py => 01_basic_agent.py} | 0 ...ent_with_tool.py => 02_agent_with_tool.py} | 0 ...tream.py => 03_agent_thread_and_stream.py} | 0 .../02_chat_completion_with_tool.py | 15 +---- .../01_basic_responses_agent.py | 5 +- .../02_responses_agent_with_tool.py | 5 +- .../03_responses_agent_structured_output.py | 7 +- .../orchestrations/concurrent_basic.py | 8 ++- .../orchestrations/group_chat.py | 66 +++++++++++-------- .../orchestrations/handoff.py | 18 ++--- .../orchestrations/magentic.py | 46 ++++++++----- 14 files changed, 100 insertions(+), 84 deletions(-) rename python/samples/autogen-migration/single_agent/{01_basic_assistant_agent.py => 01_basic_agent.py} (100%) rename python/samples/autogen-migration/single_agent/{02_assistant_agent_with_tool.py => 02_agent_with_tool.py} (100%) rename python/samples/autogen-migration/single_agent/{03_assistant_agent_thread_and_stream.py => 03_agent_thread_and_stream.py} (100%) diff --git a/python/samples/autogen-migration/README.md b/python/samples/autogen-migration/README.md index 2bfa229183..30f03e3fea 100644 --- a/python/samples/autogen-migration/README.md +++ b/python/samples/autogen-migration/README.md @@ -6,9 +6,9 @@ This gallery helps AutoGen developers move to the Microsoft Agent Framework (AF) ### Single-Agent Parity -- [01_basic_assistant_agent.py](single_agent/01_basic_assistant_agent.py) — Minimal AutoGen `AssistantAgent` and AF `Agent` comparison. -- [02_assistant_agent_with_tool.py](single_agent/02_assistant_agent_with_tool.py) — Function tool integration in both SDKs. -- [03_assistant_agent_thread_and_stream.py](single_agent/03_assistant_agent_thread_and_stream.py) — Session management and streaming responses. +- [01_basic_agent.py](single_agent/01_basic_agent.py) — Minimal AutoGen `AssistantAgent` and AF `Agent` comparison. +- [02_agent_with_tool.py](single_agent/02_agent_with_tool.py) — Function tool integration in both SDKs. +- [03_agent_thread_and_stream.py](single_agent/03_agent_thread_and_stream.py) — Session management and streaming responses. - [04_agent_as_tool.py](single_agent/04_agent_as_tool.py) — Using agents as tools (hierarchical agent pattern) and streaming with tools. ### Multi-Agent Orchestration @@ -35,7 +35,7 @@ Each script is fully async and the `main()` routine runs both implementations ba From the repository root: ```bash -python samples/autogen-migration/single_agent/01_basic_assistant_agent.py +python samples/autogen-migration/single_agent/01_basic_agent.py ``` Every script accepts no CLI arguments and will first call the AutoGen implementation, followed by the AF version. Adjust the prompt or credentials inside the file as necessary before running. diff --git a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py index ae78061260..ec2a2adb60 100644 --- a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py @@ -65,7 +65,7 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import SequentialBuilder - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents researcher = Agent( @@ -112,7 +112,7 @@ async def run_agent_framework_with_cycle() -> None: ) from agent_framework.openai import OpenAIChatClient - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents researcher = Agent( diff --git a/python/samples/autogen-migration/orchestrations/04_magentic_one.py b/python/samples/autogen-migration/orchestrations/04_magentic_one.py index e10636b10f..9a7dec30ee 100644 --- a/python/samples/autogen-migration/orchestrations/04_magentic_one.py +++ b/python/samples/autogen-migration/orchestrations/04_magentic_one.py @@ -76,7 +76,7 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import MagenticBuilder - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents researcher = Agent( diff --git a/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py b/python/samples/autogen-migration/single_agent/01_basic_agent.py similarity index 100% rename from python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py rename to python/samples/autogen-migration/single_agent/01_basic_agent.py diff --git a/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py b/python/samples/autogen-migration/single_agent/02_agent_with_tool.py similarity index 100% rename from python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py rename to python/samples/autogen-migration/single_agent/02_agent_with_tool.py diff --git a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py b/python/samples/autogen-migration/single_agent/03_agent_thread_and_stream.py similarity index 100% rename from python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py rename to python/samples/autogen-migration/single_agent/03_agent_thread_and_stream.py diff --git a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py index d84e560eb0..d5b1203518 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py +++ b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py @@ -23,7 +23,7 @@ load_dotenv() async def run_semantic_kernel() -> None: - from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread + from semantic_kernel.agents import ChatCompletionAgent from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion from semantic_kernel.functions import kernel_function @@ -39,11 +39,7 @@ async def run_semantic_kernel() -> None: instructions="Answer menu questions accurately.", plugins=[SpecialsPlugin()], ) - thread = ChatHistoryAgentThread() - response = await agent.get_response( - messages="What soup can I order today?", - thread=thread, - ) + response = await agent.get_response("What soup can I order today?") print("[SK]", response.message.content) @@ -62,12 +58,7 @@ async def run_agent_framework() -> None: instructions="Answer menu questions accurately.", tools=[specials], ) - session = chat_agent.create_session() - reply = await chat_agent.run( - "What soup can I order today?", - session=session, - tool_choice="auto", - ) + reply = await chat_agent.run("What soup can I order today?") print("[AF]", reply.text) diff --git a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py index 036d793c3e..d74487d1e8 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py +++ b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py @@ -22,10 +22,13 @@ async def run_semantic_kernel() -> None: from semantic_kernel.agents import OpenAIResponsesAgent from semantic_kernel.connectors.ai.open_ai import OpenAISettings + openai_settings = OpenAISettings() + assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + client = OpenAIResponsesAgent.create_client() # SK response agents wrap OpenAI's hosted Responses API. agent = OpenAIResponsesAgent( - ai_model=OpenAISettings().responses_model_id, + ai_model_id=openai_settings.responses_model_id, client=client, instructions="Answer in one concise sentence.", name="Expert", diff --git a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py index 4145d74e31..01b783aff9 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py +++ b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py @@ -28,10 +28,13 @@ async def run_semantic_kernel() -> None: def add(self, a: float, b: float) -> float: return a + b + openai_settings = OpenAISettings() + assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + client = OpenAIResponsesAgent.create_client() # Plugins advertise callable tools to the Responses agent. agent = OpenAIResponsesAgent( - ai_model=OpenAISettings().responses_model_id, + ai_model_id=openai_settings.responses_model_id, client=client, instructions="Use the add tool when math is required.", name="MathExpert", diff --git a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py index e1caec599c..cbfbf470a0 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py +++ b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py @@ -29,14 +29,17 @@ async def run_semantic_kernel() -> None: from semantic_kernel.agents import OpenAIResponsesAgent from semantic_kernel.connectors.ai.open_ai import OpenAISettings + openai_settings = OpenAISettings() + assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + client = OpenAIResponsesAgent.create_client() # response_format requests schema-constrained output from the model. agent = OpenAIResponsesAgent( - ai_model=OpenAISettings().responses_model_id, + ai_model_id=openai_settings.responses_model_id, client=client, instructions="Return launch briefs as structured JSON.", name="ProductMarketer", - text=OpenAIResponsesAgent.configure_response_format(ReleaseBrief), + text=OpenAIResponsesAgent.configure_response_format(ReleaseBrief), # type: ignore ) response = await agent.get_response( "Draft a launch brief for the Contoso Note app.", diff --git a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py index fb60da9f3d..11140aa875 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py @@ -55,7 +55,7 @@ def build_semantic_kernel_agents() -> list[ChatCompletionAgent]: async def run_semantic_kernel_example(prompt: str) -> Sequence[ChatMessageContent]: - concurrent_orchestration = ConcurrentOrchestration(members=build_semantic_kernel_agents()) + concurrent_orchestration = ConcurrentOrchestration(members=build_semantic_kernel_agents()) # type: ignore runtime = InProcessRuntime() runtime.start() @@ -91,12 +91,14 @@ def _print_semantic_kernel_outputs(outputs: Sequence[ChatMessageContent]) -> Non async def run_agent_framework_example(prompt: str) -> Sequence[list[Message]]: client = OpenAIChatCompletionClient(credential=AzureCliCredential()) - physics = Agent(client=client, + physics = Agent( + client=client, instructions=("You are an expert in physics. Answer questions from a physics perspective."), name="physics", ) - chemistry = Agent(client=client, + chemistry = Agent( + client=client, instructions=("You are an expert in chemistry. Answer questions from a chemistry perspective."), name="chemistry", ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py index fa539a98b4..51252a1786 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -16,8 +16,8 @@ import sys from collections.abc import Sequence from typing import Any, cast -from agent_framework import Agent, Message -from agent_framework.foundry import FoundryChatClient +from agent_framework import Agent, AgentResponseUpdate, Message +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -82,9 +82,6 @@ def build_semantic_kernel_agents() -> list[ChatCompletionAgent]: class ChatCompletionGroupChatManager(GroupChatManager): """Group chat manager that delegates orchestration decisions to an Azure OpenAI deployment.""" - service: ChatCompletionClientBase - topic: str - termination_prompt: str = ( "You are coordinating a conversation about '{{$topic}}'. " "Decide if the discussion has produced a solid answer. " @@ -104,8 +101,11 @@ class ChatCompletionGroupChatManager(GroupChatManager): ) def __init__(self, *, topic: str, service: ChatCompletionClientBase, max_rounds: int | None = None) -> None: - super().__init__(topic=topic, service=service, max_rounds=max_rounds) + super().__init__(max_rounds=max_rounds) + self._round_robin_index = 0 + self._topic = topic + self._service = service async def _render_prompt(self, template: str, **kwargs: Any) -> str: prompt_template = KernelPromptTemplate(prompt_template_config=PromptTemplateConfig(template=template)) @@ -117,7 +117,7 @@ class ChatCompletionGroupChatManager(GroupChatManager): @override async def should_terminate(self, chat_history: ChatHistory) -> BooleanResult: - rendered_prompt = await self._render_prompt(self.termination_prompt, topic=self.topic) + rendered_prompt = await self._render_prompt(self.termination_prompt, topic=self._topic) chat_history.messages.insert( 0, ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), @@ -126,11 +126,11 @@ class ChatCompletionGroupChatManager(GroupChatManager): ChatMessageContent(role=AuthorRole.USER, content="Decide if the discussion is complete."), ) - response = await self.service.get_chat_message_content( + response = await self._service.get_chat_message_content( chat_history, settings=PromptExecutionSettings(response_format=BooleanResult), ) - return BooleanResult.model_validate_json(response.content) + return BooleanResult.model_validate_json(response.content) # type: ignore @override async def select_next_agent( @@ -140,7 +140,7 @@ class ChatCompletionGroupChatManager(GroupChatManager): ) -> StringResult: rendered_prompt = await self._render_prompt( self.selection_prompt, - topic=self.topic, + topic=self._topic, participants=", ".join(participant_descriptions.keys()), ) chat_history.messages.insert( @@ -151,18 +151,18 @@ class ChatCompletionGroupChatManager(GroupChatManager): ChatMessageContent(role=AuthorRole.USER, content="Pick the next participant to speak."), ) - response = await self.service.get_chat_message_content( + response = await self._service.get_chat_message_content( chat_history, settings=PromptExecutionSettings(response_format=StringResult), ) - result = StringResult.model_validate_json(response.content) + result = StringResult.model_validate_json(response.content) # type: ignore if result.result not in participant_descriptions: raise RuntimeError(f"Unknown participant selected: {result.result}") return result @override async def filter_results(self, chat_history: ChatHistory) -> MessageResult: - rendered_prompt = await self._render_prompt(self.summary_prompt, topic=self.topic) + rendered_prompt = await self._render_prompt(self.summary_prompt, topic=self._topic) chat_history.messages.insert( 0, ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), @@ -171,11 +171,11 @@ class ChatCompletionGroupChatManager(GroupChatManager): ChatMessageContent(role=AuthorRole.USER, content="Summarize the plan."), ) - response = await self.service.get_chat_message_content( + response = await self._service.get_chat_message_content( chat_history, settings=PromptExecutionSettings(response_format=StringResult), ) - string_result = StringResult.model_validate_json(response.content) + string_result = StringResult.model_validate_json(response.content) # type: ignore return MessageResult( result=ChatMessageContent(role=AuthorRole.ASSISTANT, content=string_result.result), reason=string_result.reason, @@ -197,7 +197,7 @@ async def sk_agent_response_callback(message: ChatMessageContent | Sequence[Chat async def run_semantic_kernel_example(task: str) -> str: credential = AzureCliCredential() orchestration = GroupChatOrchestration( - members=build_semantic_kernel_agents(), + members=build_semantic_kernel_agents(), # type: ignore manager=ChatCompletionGroupChatManager( topic=DISCUSSION_TOPIC, service=AzureChatCompletion(credential=credential), @@ -225,7 +225,7 @@ async def run_semantic_kernel_example(task: str) -> str: async def run_agent_framework_example(task: str) -> str: - credential = AzureCliCredential() + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) researcher = Agent( name="Researcher", @@ -234,32 +234,42 @@ async def run_agent_framework_example(task: str) -> str: "Gather concise facts or considerations that help plan a community hackathon. " "Keep your responses factual and scannable." ), - client=FoundryChatClient(credential=credential), + client=client, ) planner = Agent( name="Planner", description="Turns the collected notes into a concrete action plan.", instructions=("Propose a structured action plan that accounts for logistics, roles, and timeline."), - client=FoundryChatClient(credential=credential), + client=client, ) workflow = GroupChatBuilder( participants=[researcher, planner], - orchestrator_agent=Agent(client=FoundryChatClient(credential=credential)), + orchestrator_agent=Agent(client=client), + max_rounds=8, + intermediate_outputs=True, ).build() - final_response = "" + output_messages: list[Message] = [] + last_message_id: str | None = None async for event in workflow.run(task, stream=True): if event.type == "output": - data = event.data - if isinstance(data, list) and len(data) > 0: - # Get the final message from the conversation - final_message = data[-1] - final_response = final_message.text or "" if isinstance(final_message, Message) else str(data) + if isinstance(event.data, AgentResponseUpdate): + if event.data.message_id != last_message_id: + last_message_id = event.data.message_id + print(f"{event.data.author_name}: {event.data.text}", end="") + else: + print(event.data.text, end="") else: - final_response = str(data) - return final_response + output_messages.extend(cast(list[Message], event.data)) + for message in output_messages: + print(f"[{message.author_name}] {message.text}") + + if output_messages: + return output_messages[-1].text + + return "" async def main() -> None: diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py index 689de88bde..f506664c60 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/handoff.py +++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py @@ -11,15 +11,14 @@ """Side-by-side handoff orchestrations for Semantic Kernel and Agent Framework.""" import asyncio -import sys -from collections.abc import AsyncIterable, Iterator, Sequence +from collections.abc import AsyncIterable, Callable, Iterator, Sequence from agent_framework import ( Agent, Message, WorkflowEvent, ) -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -36,11 +35,6 @@ from semantic_kernel.contents import ( ) from semantic_kernel.functions import kernel_function -if sys.version_info >= (3, 12): - pass # pragma: no cover -else: - pass # pragma: no cover - # Load environment variables from .env file load_dotenv() @@ -149,7 +143,7 @@ def _sk_streaming_callback(message: StreamingChatMessageContent, is_final: bool) _sk_new_message = True -def _make_sk_human_responder(script: Iterator[str]) -> callable: +def _make_sk_human_responder(script: Iterator[str]) -> Callable[[], ChatMessageContent]: def _responder() -> ChatMessageContent: try: user_text = next(script) @@ -190,7 +184,7 @@ async def run_semantic_kernel_example(initial_task: str, scripted_responses: Seq ###################################################################### -def _create_af_agents(client: FoundryChatClient): +def _create_af_agents(client: OpenAIChatCompletionClient): triage = Agent( client=client, name="triage_agent", @@ -245,7 +239,7 @@ def _extract_final_conversation(events: list[WorkflowEvent]) -> list[Message]: async def run_agent_framework_example(initial_task: str, scripted_responses: Sequence[str]) -> str: - client = FoundryChatClient(credential=AzureCliCredential()) + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) triage, refund, status, returns = _create_af_agents(client) workflow = ( @@ -281,7 +275,7 @@ async def run_agent_framework_example(initial_task: str, scripted_responses: Seq return "" # Render final transcript succinctly. - lines = [] + lines: list[str] = [] for message in conversation: text = message.text or "" if not text.strip(): diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py index 314ce04271..b5360e2130 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py @@ -13,8 +13,9 @@ import asyncio from collections.abc import Sequence +from typing import cast -from agent_framework import Agent +from agent_framework import Agent, AgentResponseUpdate, Message from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import MagenticBuilder from dotenv import load_dotenv @@ -46,21 +47,21 @@ PROMPT = ( ###################################################################### -async def build_semantic_kernel_agents() -> list: +async def build_semantic_kernel_agents() -> list[ChatCompletionAgent | OpenAIAssistantAgent]: research_agent = ChatCompletionAgent( name="ResearchAgent", description="A helpful assistant with access to web search. Ask it to perform web searches.", instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - service=OpenAIChatCompletion(ai_model_id="gpt-4o-search-preview"), + service=OpenAIChatCompletion(ai_model_id="gpt-4o-mini-search-preview"), ) client = OpenAIAssistantAgent.create_client() code_interpreter_tool, code_interpreter_tool_resources = OpenAIAssistantAgent.configure_code_interpreter_tool() openai_settings = OpenAISettings() model_id = openai_settings.chat_model_id if openai_settings.chat_model_id else "gpt-5" - definition = await client.beta.assistants.create( + definition = await client.beta.assistants.create( # pyright: ignore[reportDeprecated] model=model_id, name="CoderAgent", description="A helpful assistant that writes and executes code to process and analyze data.", @@ -94,7 +95,7 @@ def sk_agent_response_callback( async def run_semantic_kernel_example(prompt: str) -> Sequence[ChatMessageContent]: agents = await build_semantic_kernel_agents() magentic_orchestration = MagenticOrchestration( - members=agents, + members=agents, # type: ignore manager=StandardMagenticManager(chat_completion_service=OpenAIChatCompletion()), agent_response_callback=sk_agent_response_callback, ) @@ -137,7 +138,7 @@ async def run_agent_framework_example(prompt: str) -> str | None: instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - client=OpenAIChatClient(model="gpt-4o-search-preview"), + client=OpenAIChatClient(model="gpt-4o-mini-search-preview"), ) # Create code interpreter tool using static method @@ -160,22 +161,31 @@ async def run_agent_framework_example(prompt: str) -> str | None: client=OpenAIChatClient(), ) - workflow = MagenticBuilder(participants=[researcher, coder], manager_agent=manager_agent).build() + workflow = MagenticBuilder( + participants=[researcher, coder], + manager_agent=manager_agent, # type: ignore + intermediate_outputs=True, + ).build() - final_text: str | None = None + output_messages: list[Message] = [] + last_message_id: str | None = None async for event in workflow.run(prompt, stream=True): if event.type == "output": - data = event.data - if isinstance(data, str): - final_text = data - elif isinstance(data, list): - # Extract text from the last assistant message - for msg in reversed(data): - if hasattr(msg, "text") and msg.text: - final_text = msg.text - break + if isinstance(event.data, AgentResponseUpdate): + if event.data.message_id != last_message_id: + last_message_id = event.data.message_id + print(f"{event.data.author_name}: {event.data.text}", end="") + else: + print(event.data.text, end="") + else: + output_messages.extend(cast(list[Message], event.data)) + for message in output_messages: + print(f"[{message.author_name}] {message.text}") - return final_text + if output_messages: + return output_messages[-1].text + + return None def _print_agent_framework_output(result: str | None) -> None: From 2a8c3e2dcf4b7c90df844c18c5b8190b63e48b82 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 11:59:52 +0200 Subject: [PATCH 05/20] fixes to azure ai search init, samples (#5021) --- .../_context_provider.py | 287 +++++++++++++++++- .../tests/test_aisearch_context_provider.py | 31 ++ .../packages/core/agent_framework/_clients.py | 13 +- .../core/agent_framework/_evaluation.py | 9 +- .../openai/test_openai_embedding_client.py | 2 + .../chat_client/built_in_chat_clients.py | 18 +- .../compaction/tiktoken_tokenizer.py | 2 +- .../azure_ai_search/search_context_agentic.py | 13 +- .../search_context_semantic.py | 11 +- 9 files changed, 333 insertions(+), 53 deletions(-) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index 0338b13416..adda863c5a 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -11,11 +11,21 @@ from __future__ import annotations import logging import sys from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Annotation, Content, Message, SupportsGetEmbeddings -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext -from agent_framework._settings import SecretString, load_settings +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentSession, + Annotation, + BaseContextProvider, + Content, + Message, + SecretString, + SessionContext, + SupportsGetEmbeddings, + load_settings, +) +from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential, TokenCredential from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError @@ -111,6 +121,9 @@ except ImportError: _agentic_retrieval_available = False AzureCredentialTypes = TokenCredential | AsyncTokenCredential +EmbeddingFunction = Callable[[str], Awaitable[list[float]]] | SupportsGetEmbeddings[str, list[float], Any] +KnowledgeBaseOutputModeLiteral = Literal["extractive_data", "answer_synthesis"] +RetrievalReasoningEffortLiteral = Literal["minimal", "medium", "low"] logger = logging.getLogger("agent_framework.azure_ai_search") @@ -151,6 +164,230 @@ class AzureAISearchContextProvider(BaseContextProvider): _DEFAULT_SEARCH_CONTEXT_PROMPT: ClassVar[str] = "Use the following context to answer the question:" DEFAULT_SOURCE_ID: ClassVar[str] = "azure_ai_search" + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: str | None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["semantic"] = "semantic", + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model_deployment_name: str | None = None, + model_name: str | None = None, + knowledge_base_name: None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a semantic Azure AI Search context provider. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Name of the search index to query. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"semantic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Name of the semantic configuration in the index. + vector_field_name: Name of the vector field in the index. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Unused in semantic mode. + model_deployment_name: Unused in semantic mode. + model_name: Unused in semantic mode. + knowledge_base_name: Must be ``None`` for this overload. + retrieval_instructions: Unused in semantic mode. + azure_openai_api_key: Unused in semantic mode. + knowledge_base_output_mode: Unused in semantic mode. + retrieval_reasoning_effort: Unused in semantic mode. + agentic_message_history_count: Unused in semantic mode. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: str | None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["agentic"], + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str, + model_deployment_name: str, + model_name: str | None = None, + knowledge_base_name: None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an agentic provider that creates a Knowledge Base from an index. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Name of the search index used to create the Knowledge Base. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"agentic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Semantic configuration name used by hybrid search operations. + vector_field_name: Vector field name used by hybrid search operations. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base creation. + model_deployment_name: Azure OpenAI deployment used by the generated Knowledge Base. + model_name: Underlying model name for the Knowledge Base model configuration. + knowledge_base_name: Must be ``None`` for this overload. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Optional Azure OpenAI API key for Knowledge Base creation. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for query planning. + agentic_message_history_count: Number of recent messages included in retrieval. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["agentic"], + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model_deployment_name: str | None = None, + model_name: str | None = None, + knowledge_base_name: str, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an agentic provider that connects to an existing Knowledge Base. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Must be ``None`` for this overload. + knowledge_base_name: Name of the existing Knowledge Base to use. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"agentic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Semantic configuration name used by hybrid search operations. + vector_field_name: Vector field name used by hybrid search operations. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Unused when connecting to an existing Knowledge Base. + model_deployment_name: Unused when connecting to an existing Knowledge Base. + model_name: Unused when connecting to an existing Knowledge Base. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Unused when connecting to an existing Knowledge Base. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for query planning. + agentic_message_history_count: Number of recent messages included in retrieval. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + endpoint: str | None = None, + index_name: None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AzureCredentialTypes | None = None, + *, + mode: Literal["agentic"], + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: EmbeddingFunction | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model_deployment_name: str | None = None, + model_name: str | None = None, + knowledge_base_name: None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an agentic provider using environment-resolved setup. + + This overload is for agentic initialization where ``index_name`` or + ``knowledge_base_name`` is supplied by ``env_file_path`` or the + ``AZURE_SEARCH_*`` environment variables. + + Keyword Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Resolved from ``env_file_path`` or ``AZURE_SEARCH_INDEX_NAME``. + api_key: API key for authentication. + credential: Azure credential for managed identity authentication. + mode: Must be ``"agentic"`` for this overload. + top_k: Maximum number of documents to retrieve. + semantic_configuration_name: Semantic configuration name used by hybrid search operations. + vector_field_name: Vector field name used by hybrid search operations. + embedding_function: Embedding provider used for vector search. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Azure OpenAI resource URL when creating a Knowledge Base from an index. + model_deployment_name: Azure OpenAI deployment when creating a Knowledge Base from an index. + model_name: Underlying model name for Knowledge Base model configuration. + knowledge_base_name: Resolved from ``env_file_path`` or ``AZURE_SEARCH_KNOWLEDGE_BASE_NAME``. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Optional Azure OpenAI API key for Knowledge Base creation. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for query planning. + agentic_message_history_count: Number of recent messages included in retrieval. + env_file_path: Optional ``.env`` file checked before process environment variables. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + def __init__( self, source_id: str = DEFAULT_SOURCE_ID, @@ -163,9 +400,7 @@ class AzureAISearchContextProvider(BaseContextProvider): top_k: int = 5, semantic_configuration_name: str | None = None, vector_field_name: str | None = None, - embedding_function: Callable[[str], Awaitable[list[float]]] - | SupportsGetEmbeddings[str, list[float], Any] - | None = None, + embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str | None = None, model_deployment_name: str | None = None, @@ -173,8 +408,8 @@ class AzureAISearchContextProvider(BaseContextProvider): knowledge_base_name: str | None = None, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, - knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data", - retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal", + knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", + retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, env_file_path: str | None = None, env_file_encoding: str | None = None, @@ -184,7 +419,9 @@ class AzureAISearchContextProvider(BaseContextProvider): Args: source_id: Unique identifier for this provider instance. endpoint: Azure AI Search endpoint URL. - index_name: Name of the search index to query. + index_name: Name of the search index to query. In agentic mode, providing this + explicitly selects the index-backed setup and ignores any environment-provided + knowledge base name. api_key: API key for authentication. credential: Azure credential for managed identity authentication. Accepts a TokenCredential, AsyncTokenCredential, or a callable token provider. @@ -197,7 +434,9 @@ class AzureAISearchContextProvider(BaseContextProvider): azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base. model_deployment_name: Model deployment name in Azure OpenAI. model_name: The underlying model name. - knowledge_base_name: Name of an existing Knowledge Base to use. + knowledge_base_name: Name of an existing Knowledge Base to use. In agentic mode, + providing this explicitly selects the Knowledge Base-backed setup and ignores any + environment-provided index name. retrieval_instructions: Custom instructions for Knowledge Base retrieval. azure_openai_api_key: Azure OpenAI API key. knowledge_base_output_mode: Output mode for Knowledge Base retrieval. @@ -208,12 +447,26 @@ class AzureAISearchContextProvider(BaseContextProvider): """ super().__init__(source_id) - # Determine which fields are required based on mode - required: list[str | tuple[str, ...]] = ["endpoint"] + required: list[str | tuple[str, ...]] + ignored_agentic_field: Literal["index_name", "knowledge_base_name"] | None = None + explicit_index_name = index_name is not None + explicit_knowledge_base_name = knowledge_base_name is not None + if mode == "semantic": - required.append("index_name") - elif mode == "agentic": - required.append(("index_name", "knowledge_base_name")) + required = ["endpoint", "index_name"] + elif explicit_index_name and explicit_knowledge_base_name: + raise SettingNotFoundError( + "Only one of 'index_name', 'knowledge_base_name' may be provided, " + "but multiple were set: 'index_name', 'knowledge_base_name'." + ) + elif explicit_index_name: + required = ["endpoint", "index_name"] + ignored_agentic_field = "knowledge_base_name" + elif explicit_knowledge_base_name: + required = ["endpoint", "knowledge_base_name"] + ignored_agentic_field = "index_name" + else: + required = ["endpoint", ("index_name", "knowledge_base_name")] # Load settings from environment/file settings = load_settings( @@ -227,6 +480,8 @@ class AzureAISearchContextProvider(BaseContextProvider): env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) + if ignored_agentic_field is not None: + settings[ignored_agentic_field] = None if mode == "agentic" and settings.get("index_name") and not model_deployment_name: raise ValueError( diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 9972f1301d..6b87b502d9 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -283,6 +283,37 @@ class TestInitAgenticValidation: assert provider._use_existing_knowledge_base is False assert provider.knowledge_base_name == "idx-kb" + def test_agentic_explicit_kb_ignores_env_index_name(self) -> None: + with patch.dict(os.environ, {"AZURE_SEARCH_INDEX_NAME": "env-index"}, clear=False): + provider = AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + knowledge_base_name="my-kb", + api_key="key", + mode="agentic", + ) + + assert provider.index_name is None + assert provider.knowledge_base_name == "my-kb" + assert provider._use_existing_knowledge_base is True + assert provider._search_client is None + + def test_agentic_explicit_index_ignores_env_kb_name(self) -> None: + with patch.dict(os.environ, {"AZURE_SEARCH_KNOWLEDGE_BASE_NAME": "env-kb"}, clear=False): + provider = AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + index_name="idx", + api_key="key", + mode="agentic", + model_deployment_name="deploy", + azure_openai_resource_url="https://aoai.openai.azure.com", + ) + + assert provider.index_name == "idx" + assert provider.knowledge_base_name == "idx-kb" + assert provider._use_existing_knowledge_base is False + # -- __aenter__ / __aexit__ --------------------------------------------------- diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 15ce27ce33..d1394a6733 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -808,22 +808,21 @@ class SupportsFileSearchTool(Protocol): # region SupportsGetEmbeddings Protocol -# Contravariant TypeVars for the Protocol +# TypeVars for the Protocol EmbeddingInputContraT = TypeVar( "EmbeddingInputContraT", default="str", contravariant=True, ) -EmbeddingOptionsContraT = TypeVar( - "EmbeddingOptionsContraT", +EmbeddingProtocolOptionsT = TypeVar( + "EmbeddingProtocolOptionsT", bound=TypedDict, # type: ignore[valid-type] default="EmbeddingGenerationOptions", - contravariant=True, ) @runtime_checkable -class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, EmbeddingOptionsContraT]): +class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, EmbeddingProtocolOptionsT]): """Protocol for an embedding client that can generate embeddings. This protocol enables duck-typing for embedding generation. Any class that @@ -850,8 +849,8 @@ class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, Embeddin self, values: Sequence[EmbeddingInputContraT], *, - options: EmbeddingOptionsContraT | None = None, - ) -> Awaitable[GeneratedEmbeddings[EmbeddingT]]: + options: EmbeddingProtocolOptionsT | None = None, + ) -> Awaitable[GeneratedEmbeddings[EmbeddingT, EmbeddingProtocolOptionsT]]: """Generate embeddings for the given values. Args: diff --git a/python/packages/core/agent_framework/_evaluation.py b/python/packages/core/agent_framework/_evaluation.py index 92a694cc36..80bc1ecffb 100644 --- a/python/packages/core/agent_framework/_evaluation.py +++ b/python/packages/core/agent_framework/_evaluation.py @@ -96,6 +96,7 @@ class ConversationSplitter(Protocol): # Fallback: split at last user message return EvalItem._split_last_turn_static(conversation) + item.split_messages(split=split_before_memory) """ @@ -468,10 +469,7 @@ class EvalResults: """ if not self.all_passed: errored = (self.result_counts or {}).get("errored", 0) - detail = msg or ( - f"Eval run {self.run_id} {self.status}: " - f"{self.passed} passed, {self.failed} failed." - ) + detail = msg or (f"Eval run {self.run_id} {self.status}: {self.passed} passed, {self.failed} failed.") if errored: detail += f" {errored} errored." if self.report_url: @@ -1188,8 +1186,7 @@ def _coerce_result(value: Any, check_name: str) -> CheckResult: score = float(d["score"]) except (TypeError, ValueError) as exc: raise TypeError( - f"Function evaluator '{check_name}' returned dict with non-numeric 'score' value:" - f" {d['score']!r}" + f"Function evaluator '{check_name}' returned dict with non-numeric 'score' value: {d['score']!r}" ) from exc # Honour an explicit 'passed' override; otherwise threshold-based. passed = bool(d["passed"]) if "passed" in d else score >= float(d.get("threshold", 0.5)) diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index cf2ff4f60f..3244f9620b 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -7,6 +7,7 @@ import os from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework import SupportsGetEmbeddings from agent_framework.exceptions import SettingNotFoundError from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding @@ -44,6 +45,7 @@ def test_openai_construction_with_explicit_params() -> None: api_key="test-key", ) assert client.model == "text-embedding-3-small" + assert isinstance(client, SupportsGetEmbeddings) def test_raw_openai_embedding_client_init_uses_explicit_parameters() -> None: diff --git a/python/samples/02-agents/chat_client/built_in_chat_clients.py b/python/samples/02-agents/chat_client/built_in_chat_clients.py index d0a14bc9ff..6da647b1eb 100644 --- a/python/samples/02-agents/chat_client/built_in_chat_clients.py +++ b/python/samples/02-agents/chat_client/built_in_chat_clients.py @@ -22,31 +22,29 @@ This sample demonstrates how to run the same prompt flow against different built chat clients using a single `get_client` factory. Select one of these client names: -- openai_responses +- openai_chat - openai_chat_completion - anthropic - ollama - bedrock -- azure_openai_responses +- azure_openai_chat - azure_openai_chat_completion - foundry_chat """ ClientName = Literal[ - "openai_responses", + "openai_chat", "openai_chat_completion", "anthropic", "ollama", "bedrock", - "azure_openai_responses", + "azure_openai_chat", "azure_openai_chat_completion", "foundry_chat", ] # NOTE: approval_mode="never_require" is for sample brevity. -# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -62,7 +60,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: from agent_framework.anthropic import AnthropicClient from agent_framework.ollama import OllamaChatClient - if client_name == "openai_responses": + if client_name == "openai_chat": return OpenAIChatClient() if client_name == "openai_chat_completion": return OpenAIChatCompletionClient() @@ -72,7 +70,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: return OllamaChatClient() if client_name == "bedrock": return BedrockChatClient() - if client_name == "azure_openai_responses": + if client_name == "azure_openai_chat": return OpenAIChatClient(credential=AzureCliCredential()) if client_name == "azure_openai_chat_completion": return OpenAIChatCompletionClient(credential=AzureCliCredential()) @@ -86,7 +84,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: raise ValueError(f"Unsupported client name: {client_name}") -async def main(client_name: ClientName = "openai_responses") -> None: +async def main(client_name: ClientName = "openai_chat") -> None: """Run a basic prompt using a selected built-in client.""" client = get_client(client_name) @@ -122,7 +120,7 @@ async def main(client_name: ClientName = "openai_responses") -> None: if __name__ == "__main__": - asyncio.run(main("openai_responses")) + asyncio.run(main("openai_chat")) """ diff --git a/python/samples/02-agents/compaction/tiktoken_tokenizer.py b/python/samples/02-agents/compaction/tiktoken_tokenizer.py index 7e2a5a7665..c0ba93c806 100644 --- a/python/samples/02-agents/compaction/tiktoken_tokenizer.py +++ b/python/samples/02-agents/compaction/tiktoken_tokenizer.py @@ -11,7 +11,7 @@ import asyncio from typing import Any -import tiktoken +import tiktoken # type: ignore from agent_framework import ( Message, TokenizerProtocol, diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py index 298b748dc4..2d57a2906b 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py @@ -100,7 +100,7 @@ async def main() -> None: credential=AzureCliCredential() if not search_key else None, mode="agentic", azure_openai_resource_url=azure_openai_resource_url, - model_model=model_deployment, + model_deployment_name=model_deployment, # Optional: Configure retrieval behavior knowledge_base_output_mode="extractive_data", # or "answer_synthesis" retrieval_reasoning_effort="minimal", # or "medium", "low" @@ -110,13 +110,12 @@ async def main() -> None: # Create agent with search context provider async with ( search_provider, - FoundryChatClient( - project_endpoint=project_endpoint, - model_model=model_deployment, - credential=AzureCliCredential(), - ) as client, Agent( - client=client, + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model_deployment, + credential=AzureCliCredential(), + ), name="SearchAgent", instructions=( "You are a helpful assistant with advanced reasoning capabilities. " diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index cacc9556e9..d12b7df849 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -85,13 +85,12 @@ async def main() -> None: # Create agent with search context provider async with ( search_provider, - FoundryChatClient( - project_endpoint=project_endpoint, - model_model=model_deployment, - credential=credential, - ) as client, Agent( - client=client, + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model_deployment, + credential=credential, + ), name="SearchAgent", instructions=( "You are a helpful assistant. Use the provided context from the " From 34329840e1db8bb67c5bc010fedca2426dddacae Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 1 Apr 2026 12:23:04 +0200 Subject: [PATCH 06/20] Add Neo4j GraphRAG samples (#4994) * Add Neo4j GraphRAG samples * Fix sample CI issues * Address sample review feedback * Move Neo4j Python sample to end-to-end * Make Neo4j GraphRAG sample self-contained * Remove unused central package versions --- dotnet/agent-framework-dotnet.slnx | 1 + .../AgentWithRAG_Step05_Neo4jGraphRAG.csproj | 54 +++++++++ .../Program.cs | 77 ++++++++++++ .../README.md | 32 +++++ .../samples/02-agents/AgentWithRAG/README.md | 1 + .../05-end-to-end/neo4j_graphrag/README.md | 43 +++++++ .../05-end-to-end/neo4j_graphrag/main.py | 112 ++++++++++++++++++ 7 files changed, 320 insertions(+) create mode 100644 dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj create mode 100644 dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md create mode 100644 python/samples/05-end-to-end/neo4j_graphrag/README.md create mode 100644 python/samples/05-end-to-end/neo4j_graphrag/main.py diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b9755dac83..9f0356fb87 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -171,6 +171,7 @@ + diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj new file mode 100644 index 0000000000..a25c626323 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj @@ -0,0 +1,54 @@ + + + + Exe + net10.0 + + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs new file mode 100644 index 0000000000..5bf0918ab3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Neo4j.AgentFramework.GraphRAG; +using Neo4j.Driver; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var neo4jUri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? throw new InvalidOperationException("NEO4J_URI is not set."); +var neo4jUsername = Environment.GetEnvironmentVariable("NEO4J_USERNAME") ?? "neo4j"; +var neo4jPassword = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? throw new InvalidOperationException("NEO4J_PASSWORD is not set."); +var fulltextIndex = Environment.GetEnvironmentVariable("NEO4J_FULLTEXT_INDEX_NAME") ?? "search_chunks"; + +const string RetrievalQuery = """ + MATCH (node)-[:FROM_DOCUMENT]->(doc:Document)<-[:FILED]-(company:Company) + OPTIONAL MATCH (company)-[:FACES_RISK]->(risk:RiskFactor) + WITH node, score, company, doc, collect(DISTINCT risk.name)[0..5] AS risks + OPTIONAL MATCH (company)-[:MENTIONS]->(product:Product) + WITH node, score, company, doc, risks, collect(DISTINCT product.name)[0..5] AS products + RETURN + node.text AS text, + score, + company.name AS company, + company.ticker AS ticker, + doc.title AS title, + risks, + products + ORDER BY score DESC + """; + +await using var driver = GraphDatabase.Driver(new Uri(neo4jUri), AuthTokens.Basic(neo4jUsername, neo4jPassword)); +await driver.VerifyConnectivityAsync(); + +await using var provider = new Neo4jContextProvider( + driver, + new Neo4jContextProviderOptions + { + IndexName = fulltextIndex, + IndexType = IndexType.Fulltext, + RetrievalQuery = RetrievalQuery, + TopK = 5, + ContextPrompt = "Use the retrieved Neo4j graph context to answer accurately and call out when context is missing." + }); + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsIChatClient() + .AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new() + { + Instructions = "You are a helpful assistant that answers questions using Neo4j graph context." + }, + AIContextProviders = [provider] + }); + +AgentSession session = await agent.CreateSessionAsync(); + +foreach (var question in new[] +{ + "What products does Microsoft offer?", + "What risks does Apple face?", + "Tell me about NVIDIA's AI business and risk factors." +}) +{ + Console.WriteLine($">> {question}\n"); + Console.WriteLine(await agent.RunAsync(question, session)); + Console.WriteLine(); +} diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md new file mode 100644 index 0000000000..418407e831 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md @@ -0,0 +1,32 @@ +# Agent Framework Retrieval Augmented Generation (RAG) with Neo4j GraphRAG + +This sample demonstrates how to create and run an agent that uses the [Neo4j GraphRAG context provider](https://github.com/neo4j-labs/neo4j-maf-provider) with Microsoft Agent Framework for .NET. + +The sample uses a Neo4j fulltext index for retrieval and a Cypher `RetrievalQuery` to enrich results with related companies, products, and risk factors. + +## Prerequisites + +- .NET 10 SDK or later +- Azure OpenAI endpoint and chat deployment +- Azure CLI installed and authenticated +- A Neo4j database with chunked documents and a fulltext index such as `search_chunks` + +## Environment variables + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +$env:NEO4J_URI="neo4j+s://your-instance.databases.neo4j.io" +$env:NEO4J_USERNAME="neo4j" +$env:NEO4J_PASSWORD="your-password" +$env:NEO4J_FULLTEXT_INDEX_NAME="search_chunks" +``` + +## Build and run + +```powershell +dotnet build +dotnet run --framework net10.0 --no-build +``` + +The sample issues a few questions against the graph-backed retrieval provider and prints the responses to the console. diff --git a/dotnet/samples/02-agents/AgentWithRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/README.md index d606ac767c..9633220bf1 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/README.md +++ b/dotnet/samples/02-agents/AgentWithRAG/README.md @@ -8,3 +8,4 @@ These samples show how to create an agent with the Agent Framework that uses Ret |[RAG with Vector Store and custom schema](./AgentWithRAG_Step02_CustomVectorStoreRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a vector store. It also uses a custom schema for the documents stored in the vector store.| |[RAG with custom RAG data source](./AgentWithRAG_Step03_CustomRAGDataSource/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a custom RAG data source.| |[RAG with Foundry VectorStore service](./AgentWithRAG_Step04_FoundryServiceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with the Foundry VectorStore service.| +|[RAG with Neo4j GraphRAG](./AgentWithRAG_Step05_Neo4jGraphRAG/)|This sample demonstrates how to create and run an agent that uses a Neo4j-backed GraphRAG context provider with graph-enriched retrieval.| diff --git a/python/samples/05-end-to-end/neo4j_graphrag/README.md b/python/samples/05-end-to-end/neo4j_graphrag/README.md new file mode 100644 index 0000000000..ab7f5e590e --- /dev/null +++ b/python/samples/05-end-to-end/neo4j_graphrag/README.md @@ -0,0 +1,43 @@ +# Neo4j GraphRAG Context Provider + +The [Neo4j GraphRAG context provider](https://github.com/neo4j-labs/neo4j-maf-provider) adds read-only retrieval from a Neo4j knowledge graph to an Agent Framework agent. It supports vector, fulltext, and hybrid retrieval, and can enrich search results by traversing graph relationships with a Cypher `retrieval_query`. + +This sample keeps setup lightweight by using a pre-built Neo4j fulltext index plus a graph-enrichment query. + +## Example + +| File | Description | +|---|---| +| [`main.py`](main.py) | Runnable GraphRAG sample using a Neo4j fulltext index and a Cypher enrichment query to surface related companies, products, and risk factors. | + +## Prerequisites + +1. A Neo4j database with document chunks already loaded +2. A Neo4j fulltext index over chunk text, such as `search_chunks` +3. An Azure AI Foundry project endpoint and chat deployment +4. Azure CLI authentication via `az login` + +## Environment variables + +This sample expects: + +- `FOUNDRY_PROJECT_ENDPOINT` +- `FOUNDRY_MODEL` +- `NEO4J_URI` +- `NEO4J_USERNAME` +- `NEO4J_PASSWORD` +- `NEO4J_FULLTEXT_INDEX_NAME` (optional, defaults to `search_chunks`) + +## Run with uv + +From the `python/` directory: + +```bash +uv run samples/05-end-to-end/neo4j_graphrag/main.py +``` + +## Notes + +- This sample uses the published `agent-framework-neo4j` package rather than code from this repository. +- The package also supports vector and hybrid retrieval when you configure embeddings and indexes in Neo4j. +- For memory-oriented scenarios, the Neo4j project also maintains companion examples in the external provider repository. diff --git a/python/samples/05-end-to-end/neo4j_graphrag/main.py b/python/samples/05-end-to-end/neo4j_graphrag/main.py new file mode 100644 index 0000000000..51e1154c69 --- /dev/null +++ b/python/samples/05-end-to-end/neo4j_graphrag/main.py @@ -0,0 +1,112 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-neo4j", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_neo4j import Neo4jContextProvider, Neo4jSettings +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +This sample demonstrates how to use the Neo4j GraphRAG context provider with +Agent Framework and Azure AI Foundry. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4o) + NEO4J_URI — Neo4j connection URI + NEO4J_USERNAME — Neo4j username + NEO4J_PASSWORD — Neo4j password + NEO4J_FULLTEXT_INDEX_NAME — Optional fulltext index name (defaults to search_chunks) +""" + +USER_INPUTS = [ + "What products does Microsoft offer?", + "What risks does Apple face?", + "Tell me about NVIDIA's AI business and risk factors.", +] + +# Optional graph-enrichment query: retrieval works without this, but supplying +# a query lets the sample attach related company, product, and risk metadata to +# each retrieved chunk. +RETRIEVAL_QUERY = """ +MATCH (node)-[:FROM_DOCUMENT]->(doc:Document)<-[:FILED]-(company:Company) +OPTIONAL MATCH (company)-[:FACES_RISK]->(risk:RiskFactor) +WITH node, score, company, doc, collect(DISTINCT risk.name)[0..5] AS risks +OPTIONAL MATCH (company)-[:MENTIONS]->(product:Product) +WITH node, score, company, doc, risks, collect(DISTINCT product.name)[0..5] AS products +RETURN + node.text AS text, + score, + company.name AS company, + company.ticker AS ticker, + doc.title AS title, + risks, + products +ORDER BY score DESC +""" + + +async def main() -> None: + # 1. Load and validate the Neo4j connection settings. + settings = Neo4jSettings() + if not settings.is_configured: + raise RuntimeError("Set NEO4J_URI, NEO4J_USERNAME, and NEO4J_PASSWORD before running this sample.") + + # 2. Read the Azure AI Foundry project endpoint and model configuration. + project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") + if not project_endpoint: + raise RuntimeError("Set FOUNDRY_PROJECT_ENDPOINT before running this sample.") + + model = os.environ.get("FOUNDRY_MODEL") or "gpt-4o" + + # 3. Create the Neo4j context provider and Foundry-backed agent, then ask sample questions. + async with ( + AzureCliCredential() as credential, + Neo4jContextProvider( + source_id="neo4j_graphrag", + uri=settings.uri, + username=settings.username, + password=settings.get_password(), + index_name=settings.fulltext_index_name, + index_type="fulltext", + retrieval_query=RETRIEVAL_QUERY, + top_k=5, + ) as provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="Neo4jGraphRAGAgent", + instructions=( + "You are a helpful assistant. Use the Neo4j context provider results to answer accurately. " + "If the retrieved context is insufficient, say so plainly." + ), + context_providers=[provider], + ) as agent, + ): + session = agent.create_session() + print("=== Neo4j GraphRAG Context Provider ===\n") + + for user_input in USER_INPUTS: + print(f"User: {user_input}") + result = await agent.run(user_input, session=session) + print(f"Agent: {getattr(result, 'text', result)}\n") + + +if __name__ == "__main__": + asyncio.run(main()) From acaadc9c45702325b0ffb71009f8a558b114a9c9 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:34:41 +0100 Subject: [PATCH 07/20] .NET: Add a verify-samples tool and skill (#5005) * Add a verify-samples tool and skill * Address PR comments * Move verify-samples to eng folder and improve definitions --- .../skills/verify-samples-tool/SKILL.md | 213 +++ dotnet/agent-framework-dotnet.slnx | 1 + dotnet/eng/verify-samples/AgentsSamples.cs | 1252 +++++++++++++++++ dotnet/eng/verify-samples/ConsoleReporter.cs | 95 ++ dotnet/eng/verify-samples/CsvResultWriter.cs | 56 + .../eng/verify-samples/GetStartedSamples.cs | 105 ++ dotnet/eng/verify-samples/LogFileWriter.cs | 153 ++ dotnet/eng/verify-samples/Program.cs | 98 ++ dotnet/eng/verify-samples/SampleDefinition.cs | 79 ++ dotnet/eng/verify-samples/SampleRunner.cs | 132 ++ dotnet/eng/verify-samples/SampleVerifier.cs | 202 +++ .../VerificationOrchestrator.cs | 197 +++ .../eng/verify-samples/VerificationResult.cs | 31 + dotnet/eng/verify-samples/VerifyOptions.cs | 124 ++ dotnet/eng/verify-samples/WorkflowSamples.cs | 525 +++++++ .../eng/verify-samples/verify-samples.csproj | 24 + 16 files changed, 3287 insertions(+) create mode 100644 dotnet/.github/skills/verify-samples-tool/SKILL.md create mode 100644 dotnet/eng/verify-samples/AgentsSamples.cs create mode 100644 dotnet/eng/verify-samples/ConsoleReporter.cs create mode 100644 dotnet/eng/verify-samples/CsvResultWriter.cs create mode 100644 dotnet/eng/verify-samples/GetStartedSamples.cs create mode 100644 dotnet/eng/verify-samples/LogFileWriter.cs create mode 100644 dotnet/eng/verify-samples/Program.cs create mode 100644 dotnet/eng/verify-samples/SampleDefinition.cs create mode 100644 dotnet/eng/verify-samples/SampleRunner.cs create mode 100644 dotnet/eng/verify-samples/SampleVerifier.cs create mode 100644 dotnet/eng/verify-samples/VerificationOrchestrator.cs create mode 100644 dotnet/eng/verify-samples/VerificationResult.cs create mode 100644 dotnet/eng/verify-samples/VerifyOptions.cs create mode 100644 dotnet/eng/verify-samples/WorkflowSamples.cs create mode 100644 dotnet/eng/verify-samples/verify-samples.csproj diff --git a/dotnet/.github/skills/verify-samples-tool/SKILL.md b/dotnet/.github/skills/verify-samples-tool/SKILL.md new file mode 100644 index 0000000000..49878467d7 --- /dev/null +++ b/dotnet/.github/skills/verify-samples-tool/SKILL.md @@ -0,0 +1,213 @@ +--- +name: verify-samples-tool +description: How to use the verify-samples tool to run, verify, and manage sample definitions in the Agent Framework repository. Use this when adding, updating, or running sample verification. +--- + +# verify-samples Tool + +The `verify-samples` project (`dotnet/eng/verify-samples/`) is an automated tool that runs sample projects and verifies their output using deterministic checks and AI-powered verification. + +## Running verify-samples + +```bash +cd dotnet + +# Run all samples across all categories +dotnet run --project eng/verify-samples -- --log results.log --csv results.csv + +# Run a specific category +dotnet run --project eng/verify-samples -- --category 02-agents --log results.log + +# Run specific samples by name +dotnet run --project eng/verify-samples -- Agent_Step02_StructuredOutput Agent_Step09_AsFunctionTool + +# Control parallelism (default 8) +dotnet run --project eng/verify-samples -- --parallel 8 --log results.log + +# Combine options +dotnet run --project eng/verify-samples -- --category 03-workflows --parallel 4 --log results.log --csv results.csv +``` + +### Required Environment Variables + +The tool itself needs: +- `AZURE_OPENAI_ENDPOINT` — for the AI verification agent +- `AZURE_OPENAI_DEPLOYMENT_NAME` (optional, defaults to `gpt-5-mini`) + +Individual samples require their own env vars (e.g., `AZURE_AI_PROJECT_ENDPOINT`). The tool automatically checks and skips samples with missing env vars. + +### Output Files + +- `--log results.log` — detailed per-sample log with stdout/stderr, AI reasoning, and a summary +- `--csv results.csv` — tabular summary with Sample, ProjectPath, Status, FailedChecks, and Failures columns + +## Sample Categories + +Definitions are in the `dotnet/eng/verify-samples/` directory: + +| Category | Config File | Registered Key | +|----------|-------------|----------------| +| 01-get-started | `GetStartedSamples.cs` | `01-get-started` | +| 02-agents | `AgentsSamples.cs` | `02-agents` | +| 03-workflows | `WorkflowSamples.cs` | `03-workflows` | + +Categories are registered in `VerifyOptions.cs` in the `s_sampleSets` dictionary. + +## SampleDefinition Properties + +Each sample is defined as a `SampleDefinition` in the appropriate config file. Key properties: + +```csharp +new SampleDefinition +{ + // Required: Display name for the sample + Name = "Agent_Step02_StructuredOutput", + + // Required: Relative path from dotnet/ to the sample project directory + ProjectPath = "samples/02-agents/Agents/Agent_Step02_StructuredOutput", + + // Environment variables the sample requires (throws if missing) + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + + // Environment variables with defaults that would prompt on console if unset + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + + // Skip this sample with a reason (for structural issues only) + SkipReason = null, // or "Requires external service X." + + // Deterministic checks: substrings that must appear in stdout + MustContain = ["=== Section Header ==="], + + // Substrings that must NOT appear in stdout + MustNotContain = [], + + // If true, only MustContain checks are used (no AI verification) + IsDeterministic = false, + + // AI verification: natural-language descriptions of expected output + // Each entry describes one aspect to verify independently + ExpectedOutputDescription = + [ + "The output should show structured person information with Name, Age, and Occupation fields.", + "The output should not contain error messages or stack traces.", + ], + + // Stdin inputs to feed to the sample (for interactive samples) + Inputs = ["Y", "Y", "Y"], + + // Delay between stdin inputs in ms (default 2000, increase for LLM calls between inputs) + InputDelayMs = 3000, +} +``` + +## How to Add a New Sample Definition + +1. **Check the sample's Program.cs** to understand: + - What environment variables it reads (look for `GetEnvironmentVariable`) + - Whether it needs stdin input (look for `Console.ReadLine`, `Application.GetInput`) + - Whether it has an external loop (look for `EXIT` patterns in YAML workflows) + - What output it produces (section headers, markers, expected behavior) + - Whether it exits on its own or runs as a server + +2. **Choose the right verification strategy:** + - **Deterministic** (`IsDeterministic = true`): Use `MustContain` for samples with fixed output strings. No AI verification. + - **AI-verified** (default): Use `ExpectedOutputDescription` with semantic descriptions. Write expectations that are flexible enough for non-deterministic LLM output. + - **Both**: Use `MustContain` for fixed markers AND `ExpectedOutputDescription` for LLM-generated content. + +3. **Set `SkipReason` only for structural issues:** + - Web servers that don't exit + - Multi-process client/server architectures + - Samples requiring external infrastructure (MCP servers you can't reach, Docker, etc.) + - Do NOT skip for missing env vars — the tool checks those dynamically. + +4. **For interactive samples, provide `Inputs`:** + - Samples using `Application.GetInput(args)` need one initial input + - Samples with `Console.ReadLine()` approval loops need `"Y"` inputs + - YAML workflows with `externalLoop` need `"EXIT"` as the last input + - Set `InputDelayMs` to 3000-8000ms for samples with LLM calls between inputs + +5. **Add the definition** to the appropriate config file (e.g., `AgentsSamples.cs`) in the `All` list. + +6. **Register new categories** (if needed) in `VerifyOptions.cs` `s_sampleSets` dictionary. + +### Writing Good ExpectedOutputDescription + +- Write descriptions that are **semantically flexible** — LLM output varies between runs +- Each array entry should describe **one independent aspect** to verify +- Always include `"The output should not contain error messages or stack traces."` as the last entry +- Avoid exact wording expectations — use "should mention", "should contain information about", "should show" +- Bad: `"The output should say 'The weather in Amsterdam is cloudy with a high of 15°C'"` +- Good: `"The output should contain weather information about Amsterdam mentioning cloudy weather with a high of 15°C."` + +### Example: Simple LLM Sample + +```csharp +new SampleDefinition +{ + Name = "Agent_With_AzureOpenAIChatCompletion", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], +}, +``` + +### Example: Deterministic Sample + +```csharp +new SampleDefinition +{ + Name = "Workflow_Declarative_GenerateCode", + ProjectPath = "samples/03-workflows/Declarative/GenerateCode", + IsDeterministic = true, + MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"], + ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."], +}, +``` + +### Example: Interactive Sample with Approval Loop + +```csharp +new SampleDefinition +{ + Name = "FoundryAgent_Hosted_MCP", + ProjectPath = "samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y", "Y", "Y"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should show an agent using the Microsoft Learn MCP tool with approval prompts."], +}, +``` + +### Example: Declarative Workflow with External Loop + +```csharp +new SampleDefinition +{ + Name = "Workflow_Declarative_FunctionTools", + ProjectPath = "samples/03-workflows/Declarative/FunctionTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What are today's specials?", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow calling function tools to answer a question about restaurant specials."], +}, +``` + +### Example: Skipped Sample + +```csharp +new SampleDefinition +{ + Name = "Agent_MCP_Server", + ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", +}, +``` diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 9f0356fb87..f16a580519 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -7,6 +7,7 @@ + diff --git a/dotnet/eng/verify-samples/AgentsSamples.cs b/dotnet/eng/verify-samples/AgentsSamples.cs new file mode 100644 index 0000000000..87707a384e --- /dev/null +++ b/dotnet/eng/verify-samples/AgentsSamples.cs @@ -0,0 +1,1252 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Defines the expected behavior for each sample in 02-agents. +/// +internal static class AgentsSamples +{ + public static IReadOnlyList All { get; } = + [ + // ── AgentProviders ────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Agent_With_CustomImplementation", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_CustomImplementation", + RequiredEnvironmentVariables = [], + ExpectedOutputDescription = + [ + "The output should contain uppercased text, because the custom agent converts all text to uppercase.", + "There should be two outputs — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureOpenAIChatCompletion", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureOpenAIResponses", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain two separate joke responses about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureAIAgentsPersistent", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureAIProject", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureAIProject", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = ["Latest agent version id:"], + ExpectedOutputDescription = + [ + "The output should show a 'Latest agent version id:' line, then joke responses from the agent.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_AzureFoundryModel", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_API_KEY", "AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── Agents ────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Agent_Step01_UsingFunctionToolsWithApprovals", + ProjectPath = "samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["Tell me a joke about a pirate", ""], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should show the agent responding to user input. The response may be about any topic — jokes, weather, or tool call results are all acceptable.", + "The output should not contain unhandled exception stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step02_StructuredOutput", + ProjectPath = "samples/02-agents/Agents/Agent_Step02_StructuredOutput", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = + [ + "=== Structured Output with ResponseFormat ===", + "Assistant Output (JSON):", + "Assistant Output (Deserialized):", + "=== Structured Output with RunAsync ===", + "=== Structured Output with RunStreamingAsync ===", + "=== Structured Output with UseStructuredOutput Middleware ===", + "Name:", + ], + ExpectedOutputDescription = + [ + "The output should have four clearly separated sections for different structured output approaches.", + "The first section should include raw JSON output and then deserialized fields including 'Name:' with a city name.", + "Each subsequent section should also show 'Name:' followed by a city name.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step03_PersistedConversations", + ProjectPath = "samples/02-agents/Agents/Agent_Step03_PersistedConversations", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["--- Serialized session ---"], + ExpectedOutputDescription = + [ + "The output should start with a joke about a pirate.", + "After the joke there should be a '--- Serialized session ---' separator followed by a JSON block representing the serialized session state.", + "After the JSON block there should be a second response that retells the same joke in a pirate voice with emojis, demonstrating that context was preserved across serialization.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step04_3rdPartyChatHistoryStorage", + ProjectPath = "samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["--- Serialized session ---"], + ExpectedOutputDescription = + [ + "The output should contain a pirate joke response and a '--- Serialized session ---' separator with session JSON.", + "It should show that the session was stored in a vector store, with a 'Session is stored in vector store under key:' line.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step06_DependencyInjection", + ProjectPath = "samples/02-agents/Agents/Agent_Step06_DependencyInjection", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["Tell me a joke about a pirate", ""], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate in response to the user's request.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step08_UsingImages", + ProjectPath = "samples/02-agents/Agents/Agent_Step08_UsingImages", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should describe an image of a nature boardwalk/walkway scene.", + "It should mention elements like a wooden boardwalk or path, greenery or vegetation, and an outdoor or natural setting.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step09_AsFunctionTool", + ProjectPath = "samples/02-agents/Agents/Agent_Step09_AsFunctionTool", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should be a response about the weather in Amsterdam, written in French.", + "The response should reference the tool result: cloudy weather with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step10_BackgroundResponsesWithToolsAndPersistence", + ProjectPath = "samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a generated novel or story.", + "The output may include tool invocation messages like '[ResearchSpaceFacts]' or '[GenerateCharacterProfiles]'.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step11_Middleware", + ProjectPath = "samples/02-agents/Agents/Agent_Step11_Middleware", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + // Example 4 prompts for approval; provide "Y" for each possible tool call + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain multiple examples demonstrating different middleware patterns.", + "It should include sections with '===' headers for different middleware examples.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step12_Plugins", + ProjectPath = "samples/02-agents/Agents/Agent_Step12_Plugins", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain information about both the current time and the weather in Seattle.", + "The weather information should reference the plugin result: cloudy with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step13_ChatReduction", + ProjectPath = "samples/02-agents/Agents/Agent_Step13_ChatReduction", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["Chat history has", "messages."], + ExpectedOutputDescription = + [ + "The output should contain joke responses about a pirate, a robot, and a lemur.", + "Between each response there should be a 'Chat history has N messages.' line showing the message count.", + "There should be a fourth response after the user asks about the first joke. Due to chat reduction, the agent may not remember the pirate joke — any response is acceptable (including repeating another joke or saying it doesn't remember).", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step14_BackgroundResponses", + ProjectPath = "samples/02-agents/Agents/Agent_Step14_BackgroundResponses", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a generated story or novel text about otters in space.", + "The text may appear in two parts: first a polled-to-completion result, then a streamed continuation.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step16_Declarative", + ProjectPath = "samples/02-agents/Agents/Agent_Step16_Declarative", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a response in JSON format with 'language' and 'answer' fields, since the declarative agent is configured to respond in JSON.", + "The content should be a joke about a pirate in English.", + "There should be both a non-streaming and streaming response.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step17_AdditionalAIContext", + ProjectPath = "samples/02-agents/Agents/Agent_Step17_AdditionalAIContext", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show a personal assistant managing a todo list across multiple turns.", + "The assistant should acknowledge adding items like picking up milk, taking Sally to soccer practice, and making a dentist appointment for Jimmy.", + "There should be a JSON block showing the serialized session state.", + "The final response should reference the calendar appointments (doctor at 15:00, team meeting at 17:00, birthday party at 20:00).", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step18_CompactionPipeline", + ProjectPath = "samples/02-agents/Agents/Agent_Step18_CompactionPipeline", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["[User]", "[Agent]"], + ExpectedOutputDescription = + [ + "The output should show a turn-by-turn conversation between [User] and [Agent] about shopping for electronics (laptops, keyboards, mice).", + "The output may include '[Messages: #N]' lines showing chat history compaction.", + "The agent should provide information about product prices from tool results.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step19_InFunctionLoopCheckpointing", + ProjectPath = "samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_RESPONSES_STORE"], + MustContain = ["=== Non-Streaming Mode ===", "=== Streaming Mode ==="], + ExpectedOutputDescription = + [ + "The output should show non-streaming and streaming modes demonstrating in-function-loop checkpointing with multi-turn conversations.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentSkills ───────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Agent_Step01_FileBasedSkills", + ProjectPath = "samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = + [ + "Converting units with file-based skills", + "Agent:", + ], + ExpectedOutputDescription = + [ + "The output should show the agent converting 26.2 miles to kilometers and 75 kilograms to pounds.", + "The response should contain approximate numeric values for both conversions.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentWithMemory ───────────────────────────────────────────────── + + new SampleDefinition + { + Name = "AgentWithMemory_Step01_ChatHistoryMemory", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain two joke responses.", + "The first joke should be about a pirate (as explicitly requested).", + "The second joke should also be pirate-themed or similar to what the user likes, since the memory system should recall the user's preference for pirate jokes from the first session.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithMemory_Step04_MemoryUsingFoundry", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MEMORY_STORE_ID", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_EMBEDDING_DEPLOYMENT_NAME"], + MustContain = + [ + ">> Setting up Foundry Memory Store", + ">> Serialize and deserialize the session to demonstrate persisted state", + ">> Start a new session that shares the same Foundry Memory scope", + ], + ExpectedOutputDescription = + [ + "The output should show a Foundry Memory Store being set up and processing updates.", + "After serialization/deserialization, the agent should recall previously learned information.", + "In the new session section, the agent should know facts from the earlier session due to shared Foundry Memory.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithMemory_Step05_BoundedChatHistory", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + MustContain = + [ + "--- Filling the session window", + "--- Next exchange will trigger overflow to vector store ---", + "--- Asking about overflowed information", + ], + ExpectedOutputDescription = + [ + "The output should demonstrate bounded chat history with overflow to a vector store.", + "After the window fills up and overflows, the agent should still be able to recall older information (like a favorite color) from the vector store.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentWithRAG ──────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "AgentWithRAG_Step01_BasicTextRAG", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + MustContain = [">> Asking about returns", ">> Asking about shipping", ">> Asking about product care"], + ExpectedOutputDescription = + [ + "The returns section should mention a 30-day return policy, unused condition, and original packaging.", + "The shipping section should mention 3-5 business days for standard shipping.", + "The product care section should mention tent fabric maintenance tips like using lukewarm water, non-detergent soap, and air drying.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithRAG_Step03_CustomRAGDataSource", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = [">> Asking about returns", ">> Asking about shipping", ">> Asking about product care"], + ExpectedOutputDescription = + [ + "The returns section should mention a 30-day return policy.", + "The shipping section should mention 3-5 business days for standard shipping.", + "The product care section should mention tent fabric maintenance tips.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithRAG_Step04_FoundryServiceRAG", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = [">> Asking about returns", ">> Asking about shipping", ">> Asking about product care"], + ExpectedOutputDescription = + [ + "The returns section should mention a 30-day return policy.", + "The shipping section should mention standard shipping timeframes.", + "The product care section should mention tent fabric maintenance tips.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── AgentsWithFoundry ──────────────────────────────────────────────── + + new SampleDefinition + { + Name = "FoundryAgent_Step00_FoundryAgentLifecycle", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step01_Basics", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke response from the agent.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step02.1_MultiturnConversation", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain multiple joke responses showing a multi-turn conversation.", + "There should be both non-streaming and streaming responses, with the second turn in each building on the first (e.g., adding emojis or pirate voice).", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step02.2_MultiturnWithServerConversations", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should demonstrate server-side conversation sessions with non-streaming and streaming turns.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step03_UsingFunctionTools", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain weather information about Amsterdam from a function tool.", + "The response should mention cloudy weather with a high of 15°C (from the canned tool response).", + "There should be both a non-streaming and streaming response.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step04_UsingFunctionToolsWithApprovals", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain a prompt asking the user to approve a tool call, followed by weather information about Amsterdam.", + "The response should mention cloudy weather with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step05_StructuredOutput", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = ["Assistant Output:", "Name:"], + ExpectedOutputDescription = + [ + "The output should contain structured person information with Name, Age, and Occupation fields.", + "There should be both a direct structured output and a streamed-then-deserialized output.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step06_PersistedConversations", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a pirate joke, then after session persistence, a second response retelling the joke in pirate voice with emojis.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step08_DependencyInjection", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Tell me a joke about a pirate", ""], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate in response to the user's request.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step10_UsingImages", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should describe an image of a nature walkway or boardwalk scene.", + "It should mention elements like a wooden path, greenery, and an outdoor setting.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step11_AsFunctionTool", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should be a response about the weather in Amsterdam, written in French.", + "The response should reference the tool result: cloudy weather with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step12_Middleware", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain multiple middleware examples with '===' section headers.", + "The human-in-the-loop example should show tool approval prompts and agent responses.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step13_Plugins", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain information about both the current time and the weather in Seattle.", + "The weather information should reference the plugin result: cloudy with a high of 15°C.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step14_CodeInterpreter", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show the code interpreter being used to solve sin(x) + x^2 = 42, including a 'Code Input:' section with Python code.", + "It should show a 'Code Input:' section with Python code for the math problem.", + "It may show a 'Code Tool Result:' section with computed answers, or annotations with file references.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step16_FileSearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + MustContain = ["--- Running File Search Agent ---"], + ExpectedOutputDescription = + [ + "The output should show a file being uploaded and indexed in a vector store, then an agent answering a question based on the file content.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step17_OpenAPITools", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a list of countries or information about countries that use the EUR currency.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ── Skipped samples ───────────────────────────────────────────────── + + new SampleDefinition + { + Name = "AgentOpenTelemetry", + ProjectPath = "samples/02-agents/AgentOpenTelemetry", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Requires Aspire Dashboard / Docker for OpenTelemetry collection.", + }, + + new SampleDefinition + { + Name = "Agent_With_A2A", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_A2A", + RequiredEnvironmentVariables = ["A2A_AGENT_HOST"], + SkipReason = "Requires an external A2A agent host.", + }, + + new SampleDefinition + { + Name = "Agent_With_Anthropic", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_Anthropic", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME", "ANTHROPIC_RESOURCE"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_GitHubCopilot", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_GitHubCopilot", + RequiredEnvironmentVariables = [], + // The sample prompts for shell command approval; provide "Y" for each possible permission request + Inputs = ["Y", "Y", "Y"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should contain a user prompt and a response listing files in the current directory.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_GoogleGemini", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_GoogleGemini", + RequiredEnvironmentVariables = ["GOOGLE_GENAI_API_KEY"], + OptionalEnvironmentVariables = ["GOOGLE_GENAI_MODEL"], + MustContain = + [ + "Google GenAI client based agent response:", + "Community client based agent response:", + ], + ExpectedOutputDescription = + [ + "The output should contain two labeled sections, each with a joke about a pirate from a different Gemini client.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_ONNX", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_ONNX", + RequiredEnvironmentVariables = ["ONNX_MODEL_PATH"], + SkipReason = "Requires local ONNX model.", + }, + + new SampleDefinition + { + Name = "Agent_With_Ollama", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_Ollama", + RequiredEnvironmentVariables = ["OLLAMA_ENDPOINT", "OLLAMA_MODEL_NAME"], + SkipReason = "Requires local Ollama server.", + }, + + new SampleDefinition + { + Name = "Agent_With_OpenAIAssistants", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_OpenAIAssistants", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate from the OpenAI Assistants API.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_OpenAIChatCompletion", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_With_OpenAIResponses", + ProjectPath = "samples/02-agents/AgentProviders/Agent_With_OpenAIResponses", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Step05_Observability", + ProjectPath = "samples/02-agents/Agents/Agent_Step05_Observability", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "APPLICATIONINSIGHTS_CONNECTION_STRING"], + SkipReason = "Requires Application Insights / OpenTelemetry infrastructure.", + }, + + new SampleDefinition + { + Name = "Agent_Step07_AsMcpTool", + ProjectPath = "samples/02-agents/Agents/Agent_Step07_AsMcpTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "Agent_Step15_DeepResearch", + ProjectPath = "samples/02-agents/Agents/Agent_Step15_DeepResearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_BING_CONNECTION_ID"], + OptionalEnvironmentVariables = ["AZURE_AI_REASONING_DEPLOYMENT_NAME"], + SkipReason = "Requires Azure AI Foundry project with Bing search connection.", + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step01_Running", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step02_Reasoning", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + MustContain = + [ + "1. Non-streaming:", + "#### Start Thinking ####", + "#### End Thinking ####", + "#### Final Answer ####", + "Token usage:", + "2. Streaming", + ], + ExpectedOutputDescription = + [ + "The non-streaming section should show the agent's reasoning about a math problem, followed by a final answer.", + "The streaming section should show reasoning and a response about the theory of relativity.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step03_UsingFunctionTools", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain information about the weather in Amsterdam.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_Anthropic_Step04_UsingSkills", + ProjectPath = "samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step04_UsingSkills", + RequiredEnvironmentVariables = ["ANTHROPIC_API_KEY"], + OptionalEnvironmentVariables = ["ANTHROPIC_CHAT_MODEL_NAME"], + MustContain = + [ + "Creating a presentation about renewable energy...", + "#### Agent Response ####", + ], + ExpectedOutputDescription = + [ + "The output should show the agent creating a presentation about renewable energy.", + "There should be an agent response section with content about renewable energy sources.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithMemory_Step02_MemoryUsingMem0", + ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME", "MEM0_ENDPOINT", "MEM0_API_KEY"], + SkipReason = "Requires Mem0 service.", + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step01_Running", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step02_Reasoning", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + MustContain = + [ + "1. Non-streaming:", + "Token usage:", + "2. Streaming", + ], + ExpectedOutputDescription = + [ + "The non-streaming section should show the agent's reasoning about a math problem, followed by a final answer.", + "The streaming section should show reasoning and a response about the theory of relativity.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step03_CreateFromChatClient", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step04_CreateFromOpenAIResponseClient", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Agent_OpenAI_Step05_Conversation", + ProjectPath = "samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation", + RequiredEnvironmentVariables = ["OPENAI_API_KEY"], + OptionalEnvironmentVariables = ["OPENAI_CHAT_MODEL_NAME"], + MustContain = + [ + "=== Multi-turn Conversation Demo ===", + "Conversation created.", + "Conversation ID:", + ], + ExpectedOutputDescription = + [ + "The output should show a multi-turn conversation about France: capital, landmarks, and height of the most famous one.", + "The output should show the conversation history retrieved from the server.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "AgentWithRAG_Step02_CustomVectorStoreRAG", + ProjectPath = "samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + SkipReason = "Requires external Qdrant vector store.", + }, + + new SampleDefinition + { + Name = "DeclarativeAgents_ChatClient", + ProjectPath = "samples/02-agents/DeclarativeAgents/ChatClient", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Requires command-line arguments (YAML file path) with no YAML files checked in.", + }, + + new SampleDefinition + { + Name = "DevUI_Step01_BasicUsage", + ProjectPath = "samples/02-agents/DevUI/DevUI_Step01_BasicUsage", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step07_Observability", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "APPLICATIONINSIGHTS_CONNECTION_STRING"], + SkipReason = "Requires Application Insights / OpenTelemetry infrastructure.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step09_UsingMcpClientAsTools", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an agent using the Microsoft Learn MCP tool to search or retrieve documentation.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step15_ComputerUse", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = ["The output should show a computer automation session processing simulated browser screenshots with iteration steps and a final response describing search results."], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step18_BingCustomSearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID", "AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME"], + SkipReason = "Requires Bing Custom Search connection.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step19_SharePoint", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "SHAREPOINT_PROJECT_CONNECTION_ID"], + SkipReason = "Requires SharePoint connection.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step20_MicrosoftFabric", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "FABRIC_PROJECT_CONNECTION_ID"], + SkipReason = "Requires Microsoft Fabric connection.", + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step21_WebSearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an agent using web search to answer a question, with response text and citation annotations.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step22_MemorySearch", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_EMBEDDING_DEPLOYMENT_NAME"], + OptionalEnvironmentVariables = ["AZURE_AI_MEMORY_STORE_ID"], + MustContain = ["Agent created with Memory Search tool. Starting conversation..."], + ExpectedOutputDescription = + [ + "The output should show a memory store being created, memories stored from a prior conversation, and an agent querying those memories.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Step23_LocalMCP", + ProjectPath = "samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ExpectedOutputDescription = ["The output should show an agent using the Microsoft Learn MCP server to search for documentation and provide a response."], + }, + + new SampleDefinition + { + Name = "FoundryAgent_Hosted_MCP", + ProjectPath = "samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y", "Y", "Y"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should contain a summary or information about Azure AI documentation from Microsoft Learn."], + }, + + new SampleDefinition + { + Name = "ResponseAgent_Hosted_MCP", + ProjectPath = "samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["Y", "Y", "Y", "Y", "Y"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should contain a summary or information about Azure AI documentation from Microsoft Learn."], + }, + + new SampleDefinition + { + Name = "Agent_MCP_Server", + ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "Agent_MCP_Server_Auth", + ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Runs as an MCP stdio server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step01_GettingStarted_Client", + ProjectPath = "samples/02-agents/AGUI/Step01_GettingStarted/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step01_GettingStarted_Server", + ProjectPath = "samples/02-agents/AGUI/Step01_GettingStarted/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step02_BackendTools_Client", + ProjectPath = "samples/02-agents/AGUI/Step02_BackendTools/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step02_BackendTools_Server", + ProjectPath = "samples/02-agents/AGUI/Step02_BackendTools/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step03_FrontendTools_Client", + ProjectPath = "samples/02-agents/AGUI/Step03_FrontendTools/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step03_FrontendTools_Server", + ProjectPath = "samples/02-agents/AGUI/Step03_FrontendTools/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step04_HumanInLoop_Client", + ProjectPath = "samples/02-agents/AGUI/Step04_HumanInLoop/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step04_HumanInLoop_Server", + ProjectPath = "samples/02-agents/AGUI/Step04_HumanInLoop/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + + new SampleDefinition + { + Name = "AGUI_Step05_StateManagement_Client", + ProjectPath = "samples/02-agents/AGUI/Step05_StateManagement/Client", + RequiredEnvironmentVariables = [], + SkipReason = "Multi-process client/server architecture; requires AGUI server running.", + }, + + new SampleDefinition + { + Name = "AGUI_Step05_StateManagement_Server", + ProjectPath = "samples/02-agents/AGUI/Step05_StateManagement/Server", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "ASP.NET Core web server that does not exit on its own.", + }, + ]; +} diff --git a/dotnet/eng/verify-samples/ConsoleReporter.cs b/dotnet/eng/verify-samples/ConsoleReporter.cs new file mode 100644 index 0000000000..0b21138d79 --- /dev/null +++ b/dotnet/eng/verify-samples/ConsoleReporter.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Thread-safe console output with sample-name prefixes and colored status. +/// +internal sealed class ConsoleReporter +{ + private readonly object _lock = new(); + + /// + /// Writes a complete prefixed line atomically to the console. + /// + public void WriteLineWithPrefix(string sampleName, string message, ConsoleColor? color = null) + { + lock (this._lock) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"[{sampleName}] "); + if (color.HasValue) + { + Console.ForegroundColor = color.Value; + } + else + { + Console.ResetColor(); + } + + Console.WriteLine(message); + Console.ResetColor(); + } + } + + /// + /// Prints the final summary table and elapsed time to the console. + /// + public void PrintSummary( + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + TimeSpan elapsed) + { + var passCount = orderedResults.Count(r => r.Passed); + var failCount = orderedResults.Count(r => !r.Passed); + + Console.WriteLine(); + Console.WriteLine(new string('─', 60)); + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine("SUMMARY"); + Console.ResetColor(); + + foreach (var result in orderedResults) + { + Console.ForegroundColor = result.Passed ? ConsoleColor.Green : ConsoleColor.Red; + Console.Write(result.Passed ? " ✓ " : " ✗ "); + Console.ResetColor(); + Console.WriteLine($"{result.SampleName}: {result.Summary}"); + } + + foreach (var (name, reason) in skipped) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write(" ○ "); + Console.ResetColor(); + Console.WriteLine($"{name}: Skipped — {reason}"); + } + + Console.WriteLine(); + Console.Write("Results: "); + Console.ForegroundColor = ConsoleColor.Green; + Console.Write($"{passCount} passed"); + Console.ResetColor(); + + if (failCount > 0) + { + Console.Write(", "); + Console.ForegroundColor = ConsoleColor.Red; + Console.Write($"{failCount} failed"); + Console.ResetColor(); + } + + if (skipped.Count > 0) + { + Console.Write(", "); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"{skipped.Count} skipped"); + Console.ResetColor(); + } + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"Elapsed: {elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"); + Console.ResetColor(); + } +} diff --git a/dotnet/eng/verify-samples/CsvResultWriter.cs b/dotnet/eng/verify-samples/CsvResultWriter.cs new file mode 100644 index 0000000000..9a1128dcba --- /dev/null +++ b/dotnet/eng/verify-samples/CsvResultWriter.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; + +namespace VerifySamples; + +/// +/// Writes a CSV summary of sample verification results. +/// +internal static class CsvResultWriter +{ + /// + /// Writes the results to a CSV file at the specified path. + /// + public static async Task WriteAsync( + string path, + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + IReadOnlyList samples) + { + var pathLookup = samples.ToDictionary(s => s.Name, s => s.ProjectPath); + + var sb = new StringBuilder(); + sb.AppendLine("Sample,ProjectPath,Status,FailedChecks,Failures"); + + foreach (var result in orderedResults) + { + var status = result.Passed ? "PASSED" : "FAILED"; + var failedChecks = result.Failures.Count; + var failures = string.Join("; ", result.Failures); + pathLookup.TryGetValue(result.SampleName, out var projectPath); + sb.AppendLine($"{CsvEscape(result.SampleName)},{CsvEscape(projectPath ?? "")},{status},{failedChecks},{CsvEscape(failures)}"); + } + + foreach (var (name, reason) in skipped) + { + pathLookup.TryGetValue(name, out var projectPath); + sb.AppendLine($"{CsvEscape(name)},{CsvEscape(projectPath ?? "")},SKIPPED,0,{CsvEscape(reason)}"); + } + + await File.WriteAllTextAsync(path, sb.ToString()); + } + + /// + /// Escapes a value for CSV: wraps in quotes if it contains commas, quotes, or newlines. + /// + private static string CsvEscape(string value) + { + if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } +} diff --git a/dotnet/eng/verify-samples/GetStartedSamples.cs b/dotnet/eng/verify-samples/GetStartedSamples.cs new file mode 100644 index 0000000000..9298e39388 --- /dev/null +++ b/dotnet/eng/verify-samples/GetStartedSamples.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Defines the expected behavior for each sample in 01-get-started. +/// +internal static class GetStartedSamples +{ + public static IReadOnlyList All { get; } = + [ + new SampleDefinition + { + Name = "05_first_workflow", + ProjectPath = "samples/01-get-started/05_first_workflow", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "UppercaseExecutor: HELLO, WORLD!", + "ReverseTextExecutor: !DLROW ,OLLEH", + ], + }, + + new SampleDefinition + { + Name = "01_hello_agent", + ProjectPath = "samples/01-get-started/01_hello_agent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "There should be two separate joke responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "02_add_tools", + ProjectPath = "samples/01-get-started/02_add_tools", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = [], + ExpectedOutputDescription = + [ + "The output should contain information about the weather in Amsterdam.", + "The response should mention that it is cloudy with a high of 15°C (or equivalent), since this comes from a tool that returns a canned response.", + "There should be two responses — one from a non-streaming call and one from a streaming call.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "03_multi_turn", + ProjectPath = "samples/01-get-started/03_multi_turn", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should contain a joke about a pirate.", + "After the initial joke, there should be a modified version that includes emojis and is told in the voice of a pirate's parrot.", + "The pattern repeats: first a non-streaming pirate joke + parrot version, then a streaming pirate joke + parrot version.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "04_memory", + ProjectPath = "samples/01-get-started/04_memory", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = + [ + ">> Use session with blank memory", + ">> Use deserialized session with previously created memories", + ">> Read memories using memory component", + "MEMORY - User Name:", + "MEMORY - User Age:", + ">> Use new session with previously created memories", + ], + ExpectedOutputDescription = + [ + "In the 'Use session with blank memory' section, the agent should respond to the user's messages. It may ask for the user's name or age if not yet known.", + "In the 'Use deserialized session with previously created memories' section, the agent should correctly recall that the user's name is Ruaidhrí and age is 20.", + "The 'MEMORY - User Name:' line should show 'Ruaidhrí' (or a close transliteration).", + "The 'MEMORY - User Age:' line should show '20'.", + "In the 'Use new session with previously created memories' section, the agent should know the user's name and age from the transferred memory.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "06_host_your_agent", + ProjectPath = "samples/01-get-started/06_host_your_agent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Requires Azure Functions Core Tools runtime and starts a web server.", + }, + ]; +} diff --git a/dotnet/eng/verify-samples/LogFileWriter.cs b/dotnet/eng/verify-samples/LogFileWriter.cs new file mode 100644 index 0000000000..a46096f3d6 --- /dev/null +++ b/dotnet/eng/verify-samples/LogFileWriter.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; + +namespace VerifySamples; + +/// +/// Incrementally writes a sequential (non-interleaved) log file, appending after each sample completes. +/// Thread-safe: multiple parallel tasks may call write methods concurrently. +/// +internal sealed class LogFileWriter : IDisposable +{ + private readonly string _path; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public LogFileWriter(string path) + { + this._path = path; + } + + /// + public void Dispose() + { + this._writeLock.Dispose(); + } + + /// + /// Writes the log file header. Call once at the start of the run. + /// + public async Task WriteHeaderAsync() + { + var sb = new StringBuilder(); + sb.AppendLine($"Sample Verification Log — {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + sb.AppendLine(new string('═', 72)); + sb.AppendLine(); + + await File.WriteAllTextAsync(this._path, sb.ToString()); + } + + /// + /// Appends a skipped-sample entry to the log file. + /// + public async Task WriteSkippedAsync(string name, string reason) + { + var sb = new StringBuilder(); + sb.AppendLine($"── {name} ──"); + sb.AppendLine($"Status: SKIPPED — {reason}"); + sb.AppendLine(); + + await this.AppendAsync(sb.ToString()); + } + + /// + /// Appends a completed sample's full output section to the log file. + /// + public async Task WriteSampleResultAsync(VerificationResult result) + { + var sb = new StringBuilder(); + sb.AppendLine(new string('─', 72)); + sb.AppendLine($"── {result.SampleName} ──"); + sb.AppendLine($"Status: {(result.Passed ? "PASSED" : "FAILED")}"); + sb.AppendLine(); + + foreach (var line in result.LogLines) + { + sb.AppendLine(line); + } + + sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(result.Stdout)) + { + sb.AppendLine("--- stdout ---"); + sb.AppendLine(result.Stdout.TrimEnd()); + sb.AppendLine("--- end stdout ---"); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(result.Stderr)) + { + sb.AppendLine("--- stderr ---"); + sb.AppendLine(result.Stderr.TrimEnd()); + sb.AppendLine("--- end stderr ---"); + sb.AppendLine(); + } + + if (result.Failures.Count > 0) + { + sb.AppendLine("Failures:"); + foreach (var failure in result.Failures) + { + sb.AppendLine($" ✗ {failure}"); + } + + sb.AppendLine(); + } + + if (result.AIReasoning is not null) + { + sb.AppendLine("AI Reasoning:"); + sb.AppendLine(result.AIReasoning); + sb.AppendLine(); + } + + await this.AppendAsync(sb.ToString()); + } + + /// + /// Appends the final summary section and elapsed time to the log file. + /// + public async Task WriteSummaryAsync( + IReadOnlyList orderedResults, + IReadOnlyList<(string Name, string Reason)> skipped, + TimeSpan elapsed) + { + var passCount = orderedResults.Count(r => r.Passed); + var failCount = orderedResults.Count(r => !r.Passed); + + var sb = new StringBuilder(); + sb.AppendLine(new string('═', 72)); + sb.AppendLine("SUMMARY"); + sb.AppendLine(); + + foreach (var result in orderedResults) + { + sb.AppendLine($" {(result.Passed ? "✓" : "✗")} {result.SampleName}: {result.Summary}"); + } + + foreach (var (name, reason) in skipped) + { + sb.AppendLine($" ○ {name}: Skipped — {reason}"); + } + + sb.AppendLine(); + sb.AppendLine($"Results: {passCount} passed{(failCount > 0 ? $", {failCount} failed" : "")}{(skipped.Count > 0 ? $", {skipped.Count} skipped" : "")}"); + sb.AppendLine($"Elapsed: {elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"); + + await this.AppendAsync(sb.ToString()); + } + + private async Task AppendAsync(string text) + { + await this._writeLock.WaitAsync(); + try + { + await File.AppendAllTextAsync(this._path, text); + } + finally + { + this._writeLock.Release(); + } + } +} diff --git a/dotnet/eng/verify-samples/Program.cs b/dotnet/eng/verify-samples/Program.cs new file mode 100644 index 0000000000..e1a3bd3170 --- /dev/null +++ b/dotnet/eng/verify-samples/Program.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This tool runs the 01-get-started, 02-agents, and 03-workflows samples and verifies their output. +// Deterministic samples are verified with exact string matching. +// Non-deterministic (LLM) samples are verified using an agent-framework agent. +// +// Usage: +// dotnet run # Run all samples +// dotnet run -- 01_hello_agent 05_first_workflow # Run specific samples by name +// dotnet run -- --category 01-get-started # Run the 01-get-started category +// dotnet run -- --category 02-agents # Run the 02-agents category +// dotnet run -- --category 03-workflows # Run the 03-workflows category +// dotnet run -- --parallel 16 # Run up to 16 samples concurrently +// dotnet run -- --log results.log # Write sequential log to file +// dotnet run -- --csv results.csv # Write CSV summary to file +// +// Required environment variables (for AI-powered samples): +// AZURE_OPENAI_ENDPOINT +// AZURE_OPENAI_DEPLOYMENT_NAME (optional, defaults to gpt-5-mini) + +using System.Diagnostics; +using Azure.AI.OpenAI; +using Azure.Identity; +using VerifySamples; + +var options = VerifyOptions.Parse(args); +if (options is null) +{ + return 1; +} + +var stopwatch = Stopwatch.StartNew(); + +// Resolve the dotnet/ root directory (verify-samples is at dotnet/eng/verify-samples/) +var dotnetRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); +if (!File.Exists(Path.Combine(dotnetRoot, "agent-framework-dotnet.slnx"))) +{ + dotnetRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..")); +} + +// Set up the AI verifier +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT"); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5-mini"; + +OpenAI.Chat.ChatClient? chatClient = null; +if (!string.IsNullOrEmpty(endpoint)) +{ + chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName); +} + +// Set up optional log file writer +LogFileWriter? logWriter = null; +if (options.LogFilePath is not null) +{ + logWriter = new LogFileWriter(options.LogFilePath); + await logWriter.WriteHeaderAsync(); +} + +try +{ + // Run all samples + var reporter = new ConsoleReporter(); + var verifier = new SampleVerifier(chatClient); + var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter); + + var run = await orchestrator.RunAllAsync(options.Samples, options.MaxParallelism); + + stopwatch.Stop(); + + // Print summary + var orderedResults = run.SampleOrder + .Where(run.Results.ContainsKey) + .Select(name => run.Results[name]) + .ToList(); + + reporter.PrintSummary(orderedResults, run.Skipped, stopwatch.Elapsed); + + // Write log file summary + if (logWriter is not null) + { + await logWriter.WriteSummaryAsync(orderedResults, run.Skipped, stopwatch.Elapsed); + Console.WriteLine($"Log written to: {options.LogFilePath}"); + } + + // Write CSV summary + if (options.CsvFilePath is not null) + { + await CsvResultWriter.WriteAsync(options.CsvFilePath, orderedResults, run.Skipped, options.Samples); + Console.WriteLine($"CSV written to: {options.CsvFilePath}"); + } + + return orderedResults.Any(r => !r.Passed) ? 1 : 0; +} +finally +{ + logWriter?.Dispose(); +} diff --git a/dotnet/eng/verify-samples/SampleDefinition.cs b/dotnet/eng/verify-samples/SampleDefinition.cs new file mode 100644 index 0000000000..5f5f69a40e --- /dev/null +++ b/dotnet/eng/verify-samples/SampleDefinition.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Describes a sample to verify, including its expected output. +/// +internal sealed class SampleDefinition +{ + /// + /// Display name for the sample (e.g., "01_hello_agent"). + /// + public required string Name { get; init; } + + /// + /// Relative path from the dotnet/ directory to the sample project directory. + /// + public required string ProjectPath { get; init; } + + /// + /// Environment variables that the sample requires for a meaningful run. + /// The runner checks these before running and will skip the sample if any are unset, + /// recording a skip reason that indicates which required variables are missing. + /// + public string[] RequiredEnvironmentVariables { get; init; } = []; + + /// + /// Environment variables that the sample can use but typically has fallbacks or defaults for. + /// If these are not set, the sample might prompt or behave interactively, which could cause + /// automated verification to hang. The runner checks these and skips the sample if they are unset + /// to avoid non-deterministic or blocking behavior in automated runs. + /// + public string[] OptionalEnvironmentVariables { get; init; } = []; + + /// + /// If set, the sample is skipped with this reason. + /// Use only for structural reasons (e.g., web server, multi-process, needs external service). + /// Do NOT use for missing environment variables — those are checked dynamically. + /// + public string? SkipReason { get; init; } + + /// + /// Substrings that must appear in stdout for the sample to pass. + /// Used for deterministic verification. + /// + public string[] MustContain { get; init; } = []; + + /// + /// Substrings that must not appear in stdout for the sample to pass. + /// + public string[] MustNotContain { get; init; } = []; + + /// + /// If true, entries cover the entire expected output — + /// no AI verification is needed. + /// + public bool IsDeterministic { get; init; } + + /// + /// Natural-language description of what the sample output should look like. + /// Used by the AI verifier for non-deterministic samples. + /// Each entry describes one aspect of the expected output that should be verified. + /// + public string[] ExpectedOutputDescription { get; init; } = []; + + /// + /// Sequence of stdin inputs to feed to the sample process. + /// Each entry is written as a line (followed by newline) to the process stdin. + /// A null entry inserts a delay without writing anything. + /// Inputs are sent with a short delay between each to allow the process to prompt. + /// + public string?[] Inputs { get; init; } = []; + + /// + /// Delay in milliseconds between each input line. Default is 2000ms. + /// Increase for samples that need more time between prompts (e.g., LLM calls between inputs). + /// + public int InputDelayMs { get; init; } = 2000; +} diff --git a/dotnet/eng/verify-samples/SampleRunner.cs b/dotnet/eng/verify-samples/SampleRunner.cs new file mode 100644 index 0000000000..0fabd82262 --- /dev/null +++ b/dotnet/eng/verify-samples/SampleRunner.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; + +namespace VerifySamples; + +/// +/// Result of running a sample process. +/// +internal sealed record SampleRunResult( + string Stdout, + string Stderr, + int ExitCode, + TimeSpan Elapsed); + +/// +/// Runs a sample project via dotnet run and captures its output. +/// +internal static class SampleRunner +{ + /// + /// Runs dotnet run --framework net10.0 in the given project directory. + /// + public static Task RunAsync( + string projectPath, + TimeSpan timeout, + CancellationToken cancellationToken = default) + => RunAsync(projectPath, "run --framework net10.0", timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken); + + /// + /// Runs dotnet run --framework net10.0 with stdin inputs. + /// + public static Task RunAsync( + string projectPath, + TimeSpan timeout, + string?[]? inputs, + int inputDelayMs = 2000, + CancellationToken cancellationToken = default) + => RunAsync(projectPath, "run --framework net10.0", timeout, inputs, inputDelayMs, cancellationToken); + + /// + /// Runs an arbitrary dotnet command in the given working directory. + /// + public static async Task RunAsync( + string workingDirectory, + string dotnetArgs, + TimeSpan timeout, + string?[]? inputs = null, + int inputDelayMs = 0, + CancellationToken cancellationToken = default) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = dotnetArgs, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = inputs is { Length: > 0 }, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var sw = Stopwatch.StartNew(); + + using var process = new Process { StartInfo = psi }; + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + // Feed stdin inputs with delays if configured + if (inputs is { Length: > 0 }) + { + _ = Task.Run(async () => + { + try + { + foreach (var input in inputs) + { + await Task.Delay(inputDelayMs, cancellationToken); + if (input is not null) + { + await process.StandardInput.WriteLineAsync(input.AsMemory(), cancellationToken); + await process.StandardInput.FlushAsync(cancellationToken); + } + } + + process.StandardInput.Close(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + // Process may have exited before all inputs were sent + } + }, cancellationToken); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timeout — kill the process + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort + } + + sw.Stop(); + return new SampleRunResult( + Stdout: await stdoutTask, + Stderr: $"TIMEOUT: Sample did not complete within {timeout.TotalSeconds}s.\n{await stderrTask}", + ExitCode: -1, + Elapsed: sw.Elapsed); + } + + sw.Stop(); + return new SampleRunResult( + Stdout: await stdoutTask, + Stderr: await stderrTask, + ExitCode: process.ExitCode, + Elapsed: sw.Elapsed); + } +} diff --git a/dotnet/eng/verify-samples/SampleVerifier.cs b/dotnet/eng/verify-samples/SampleVerifier.cs new file mode 100644 index 0000000000..9dc17b1769 --- /dev/null +++ b/dotnet/eng/verify-samples/SampleVerifier.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +namespace VerifySamples; + +/// +/// Verifies sample output using deterministic checks and an AI agent +/// for non-deterministic output validation. +/// +internal sealed class SampleVerifier +{ + private readonly AIAgent? _verifierAgent; + + /// + /// Creates a verifier. If is provided, + /// AI-based verification is available for non-deterministic samples. + /// + public SampleVerifier(ChatClient? chatClient = null) + { + if (chatClient is not null) + { + this._verifierAgent = chatClient.AsAIAgent( + instructions: """ + You are a test output verifier. You will be given: + 1. The actual stdout output of a program + 2. A list of expectations about what the output should contain or demonstrate + + Your job is to determine whether the actual output satisfies each expectation. + Be reasonable — the output comes from an LLM so exact wording won't match, but the + semantic intent should be clearly satisfied. + """, + name: "OutputVerifier"); + } + } + + /// + /// Verifies the output of a sample run against its definition. + /// + public async Task VerifyAsync(SampleDefinition sample, SampleRunResult run) + { + var failures = new List(); + + // 1. Exit code check + if (run.ExitCode != 0) + { + failures.Add($"Exit code was {run.ExitCode}, expected 0. Stderr: {Truncate(run.Stderr, 500)}"); + } + + // 2. Must-contain checks + foreach (var expected in sample.MustContain) + { + if (!run.Stdout.Contains(expected, StringComparison.Ordinal)) + { + failures.Add($"Output missing expected substring: \"{expected}\""); + } + } + + // 3. Must-not-contain checks + foreach (var unexpected in sample.MustNotContain) + { + if (run.Stdout.Contains(unexpected, StringComparison.Ordinal)) + { + failures.Add($"Output contains unexpected substring: \"{unexpected}\""); + } + } + + // 4. AI verification for non-deterministic samples + string? aiReasoning = null; + if (!sample.IsDeterministic && sample.ExpectedOutputDescription.Length > 0) + { + if (this._verifierAgent is null) + { + failures.Add("AI verification required but no AI agent configured (missing AZURE_OPENAI_ENDPOINT)."); + } + else + { + var aiResult = await this.VerifyWithAIAsync(run.Stdout, sample.ExpectedOutputDescription); + aiReasoning = aiResult.Reasoning; + + foreach (var unmet in aiResult.UnmetExpectations) + { + failures.Add($"AI expectation not met: {unmet}"); + } + } + } + + bool passed = failures.Count == 0; + return new VerificationResult + { + SampleName = sample.Name, + Passed = passed, + Summary = passed ? "All checks passed" : $"{failures.Count} check(s) failed", + Failures = failures, + AIReasoning = aiReasoning, + }; + } + + private async Task<(string Reasoning, List UnmetExpectations)> VerifyWithAIAsync( + string actualOutput, + string[] expectations) + { + var expectationList = string.Join("\n", expectations.Select((e, i) => $" {i + 1}. {e}")); + var prompt = $""" + Actual program output: + --- + {Truncate(actualOutput, 4000)} + --- + + Expectations to verify: + {expectationList} + + Does the output satisfy all expectations? + """; + + try + { + var response = await this._verifierAgent!.RunAsync(prompt); + var result = response.Result; + + if (result is null) + { + return ($"AI verification returned null result. Raw: {response.Text}", ["AI verification returned null result."]); + } + + var reasoning = result.Reasoning ?? "(no reasoning provided)"; + + // Collect unmet expectations as individual failures + var unmet = new List(); + if (result.ExpectationResults is { Count: > 0 }) + { + foreach (var er in result.ExpectationResults.Where(er => !er.Met)) + { + var detail = string.IsNullOrWhiteSpace(er.Detail) ? er.Expectation : $"{er.Expectation} — {er.Detail}"; + unmet.Add(detail ?? "Unknown expectation"); + } + + // If the model flagged overall failure but all individual expectations were met, + // still treat as failure using the overall reasoning. + if (unmet.Count == 0 && !result.Pass) + { + unmet.Add(reasoning); + } + } + else if (!result.Pass) + { + // Fallback: no per-expectation detail but overall pass is false + unmet.Add(reasoning); + } + + return (reasoning, unmet); + } + catch (Exception ex) + { + return ($"AI verification error: {ex.Message}", [$"AI verification error: {ex.Message}"]); + } + } + + private static string Truncate(string text, int maxLength) + => text.Length <= maxLength ? text : text[..maxLength] + "... (truncated)"; +} + +/// +/// Structured response from the AI verification agent. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization via RunAsync.")] +internal sealed class AIVerificationResponse +{ + /// Whether all expectations were met. + [JsonPropertyName("pass")] + public bool Pass { get; set; } + + /// Brief explanation of the overall assessment. + [JsonPropertyName("reasoning")] + public string? Reasoning { get; set; } + + /// Per-expectation results. + [JsonPropertyName("expectation_results")] + public List? ExpectationResults { get; set; } +} + +/// +/// Result for an individual expectation check. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization via RunAsync.")] +internal sealed class ExpectationResult +{ + /// The expectation text that was evaluated. + [JsonPropertyName("expectation")] + public string? Expectation { get; set; } + + /// Whether this expectation was met. + [JsonPropertyName("met")] + public bool Met { get; set; } + + /// Detail about how the expectation was or was not met. + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} diff --git a/dotnet/eng/verify-samples/VerificationOrchestrator.cs b/dotnet/eng/verify-samples/VerificationOrchestrator.cs new file mode 100644 index 0000000000..1ce805bc5a --- /dev/null +++ b/dotnet/eng/verify-samples/VerificationOrchestrator.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; + +namespace VerifySamples; + +/// +/// Orchestrates sample verification: filters, runs in parallel, and collects results. +/// +internal sealed class VerificationOrchestrator +{ + private readonly SampleVerifier _verifier; + private readonly ConsoleReporter _reporter; + private readonly LogFileWriter? _logWriter; + private readonly string _dotnetRoot; + private readonly TimeSpan _timeout; + + public VerificationOrchestrator( + SampleVerifier verifier, + ConsoleReporter reporter, + string dotnetRoot, + TimeSpan timeout, + LogFileWriter? logWriter = null) + { + this._verifier = verifier; + this._reporter = reporter; + this._logWriter = logWriter; + this._dotnetRoot = dotnetRoot; + this._timeout = timeout; + } + + /// + /// The result of running all samples through the orchestrator. + /// + internal sealed record RunAllResult( + ConcurrentDictionary Results, + List<(string Name, string Reason)> Skipped, + List SampleOrder); + + /// + /// Filters samples, runs the runnable ones in parallel, and returns all results. + /// + public async Task RunAllAsync( + IReadOnlyList samples, + int maxParallelism) + { + var skipped = new List<(string Name, string Reason)>(); + var runnableSamples = new List(); + var sampleOrder = new List(); + + // Separate samples into skipped and runnable + foreach (var sample in samples) + { + sampleOrder.Add(sample.Name); + + if (sample.SkipReason is not null) + { + skipped.Add((sample.Name, sample.SkipReason)); + this._reporter.WriteLineWithPrefix(sample.Name, $"SKIPPED — {sample.SkipReason}", ConsoleColor.Yellow); + + if (this._logWriter is not null) + { + await this._logWriter.WriteSkippedAsync(sample.Name, sample.SkipReason); + } + + continue; + } + + var missingRequired = sample.RequiredEnvironmentVariables + .Where(v => string.IsNullOrEmpty(Environment.GetEnvironmentVariable(v))) + .ToList(); + + var missingOptional = sample.OptionalEnvironmentVariables + .Where(v => string.IsNullOrEmpty(Environment.GetEnvironmentVariable(v))) + .ToList(); + + if (missingRequired.Count > 0 || missingOptional.Count > 0) + { + var reasons = new List(); + if (missingRequired.Count > 0) + { + reasons.Add($"Missing required: {string.Join(", ", missingRequired)}"); + } + + if (missingOptional.Count > 0) + { + reasons.Add($"Missing optional (would cause console prompt hang): {string.Join(", ", missingOptional)}"); + } + + var skipReason = string.Join("; ", reasons); + skipped.Add((sample.Name, skipReason)); + this._reporter.WriteLineWithPrefix(sample.Name, $"SKIPPED — {skipReason}", ConsoleColor.Yellow); + + if (this._logWriter is not null) + { + await this._logWriter.WriteSkippedAsync(sample.Name, skipReason); + } + + continue; + } + + runnableSamples.Add(sample); + } + + // Run samples in parallel + var results = new ConcurrentDictionary(); + var semaphore = new SemaphoreSlim(maxParallelism); + + this._reporter.WriteLineWithPrefix( + "runner", $"Running {runnableSamples.Count} samples (max {maxParallelism} parallel)..."); + + try + { + var tasks = runnableSamples.Select(sample => this.RunSingleAsync(sample, results, semaphore)).ToArray(); + await Task.WhenAll(tasks); + } + finally + { + semaphore.Dispose(); + } + + return new RunAllResult(results, skipped, sampleOrder); + } + + private async Task RunSingleAsync( + SampleDefinition sample, + ConcurrentDictionary results, + SemaphoreSlim semaphore) + { + await semaphore.WaitAsync(); + try + { + var log = new List(); + log.Add($"[{sample.Name}] Running..."); + this._reporter.WriteLineWithPrefix(sample.Name, "Running..."); + + var projectPath = Path.Combine(this._dotnetRoot, sample.ProjectPath); + var run = sample.Inputs.Length > 0 + ? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs) + : await SampleRunner.RunAsync(projectPath, this._timeout); + + log.Add($"[{sample.Name}] Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode})"); + this._reporter.WriteLineWithPrefix( + sample.Name, $"Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode}). Verifying..."); + + var result = await this._verifier.VerifyAsync(sample, run); + + if (result.Passed) + { + log.Add($"[{sample.Name}] PASSED"); + this._reporter.WriteLineWithPrefix(sample.Name, "PASSED", ConsoleColor.Green); + } + else + { + log.Add($"[{sample.Name}] FAILED"); + this._reporter.WriteLineWithPrefix(sample.Name, "FAILED", ConsoleColor.Red); + foreach (var failure in result.Failures) + { + log.Add($"[{sample.Name}] ✗ {failure}"); + this._reporter.WriteLineWithPrefix(sample.Name, $" ✗ {failure}", ConsoleColor.Red); + } + } + + if (result.AIReasoning is not null) + { + log.Add($"[{sample.Name}] AI: {result.AIReasoning}"); + this._reporter.WriteLineWithPrefix( + sample.Name, $" AI: {Truncate(result.AIReasoning, 300)}", ConsoleColor.DarkGray); + } + + var verificationResult = new VerificationResult + { + SampleName = result.SampleName, + Passed = result.Passed, + Summary = result.Summary, + Failures = result.Failures, + AIReasoning = result.AIReasoning, + Stdout = run.Stdout, + Stderr = run.Stderr, + LogLines = log, + }; + results[sample.Name] = verificationResult; + + if (this._logWriter is not null) + { + await this._logWriter.WriteSampleResultAsync(verificationResult); + } + } + finally + { + semaphore.Release(); + } + } + + private static string Truncate(string text, int maxLength) + => text.Length <= maxLength ? text : text[..maxLength] + "..."; +} diff --git a/dotnet/eng/verify-samples/VerificationResult.cs b/dotnet/eng/verify-samples/VerificationResult.cs new file mode 100644 index 0000000000..50a08f969e --- /dev/null +++ b/dotnet/eng/verify-samples/VerificationResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// The result of verifying a single sample. +/// +internal sealed class VerificationResult +{ + public required string SampleName { get; init; } + public required bool Passed { get; init; } + public required string Summary { get; init; } + public List Failures { get; init; } = []; + public string? AIReasoning { get; init; } + + /// + /// The sample's stdout output, captured for log file output. + /// + public string? Stdout { get; init; } + + /// + /// The sample's stderr output, captured for log file output. + /// + public string? Stderr { get; init; } + + /// + /// Per-sample log lines, buffered during parallel execution + /// and written sequentially to the log file. + /// + public List LogLines { get; init; } = []; +} diff --git a/dotnet/eng/verify-samples/VerifyOptions.cs b/dotnet/eng/verify-samples/VerifyOptions.cs new file mode 100644 index 0000000000..c4e3cd1f59 --- /dev/null +++ b/dotnet/eng/verify-samples/VerifyOptions.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Parsed command-line options for the sample verification tool. +/// +internal sealed class VerifyOptions +{ + /// + /// Maximum number of samples to run concurrently. + /// + public int MaxParallelism { get; init; } = 8; + + /// + /// Path to write a CSV summary file, or null to skip. + /// + public string? CsvFilePath { get; init; } + + /// + /// Path to write a sequential log file, or null to skip. + /// + public string? LogFilePath { get; init; } + + /// + /// The filtered list of samples to process. + /// + public required IReadOnlyList Samples { get; init; } + + /// + /// All known sample set registries, keyed by category name. + /// + private static readonly Dictionary> s_sampleSets = + new(StringComparer.OrdinalIgnoreCase) + { + ["01-get-started"] = GetStartedSamples.All, + ["02-agents"] = AgentsSamples.All, + ["03-workflows"] = WorkflowSamples.All, + }; + + /// + /// Parses command-line arguments and resolves the sample list. + /// Returns null and writes to stderr if the arguments are invalid. + /// + public static VerifyOptions? Parse(string[] args) + { + var argList = args.ToList(); + + var categoryFilter = ExtractArg(argList, "--category"); + var logFilePath = ExtractArg(argList, "--log"); + var csvFilePath = ExtractArg(argList, "--csv"); + + int maxParallelism = 8; + var parallelArg = ExtractArg(argList, "--parallel"); + if (parallelArg is not null && int.TryParse(parallelArg, out var p) && p > 0) + { + maxParallelism = p; + } + + HashSet? nameFilter = null; + if (argList.Count > 0) + { + nameFilter = argList.ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + // Build the sample list + IReadOnlyList samples; + if (categoryFilter is not null) + { + if (!s_sampleSets.TryGetValue(categoryFilter, out var categoryList)) + { + Console.Error.WriteLine( + $"Unknown category '{categoryFilter}'. Available: {string.Join(", ", s_sampleSets.Keys)}"); + return null; + } + + samples = categoryList; + } + else + { + samples = s_sampleSets.Values.SelectMany(s => s).ToList(); + } + + if (nameFilter is not null) + { + samples = samples.Where(s => nameFilter.Contains(s.Name)).ToList(); + } + + if (samples.Count == 0) + { + var allNames = s_sampleSets.Values.SelectMany(s => s).Select(s => s.Name); + Console.Error.WriteLine($"No matching samples found. Available: {string.Join(", ", allNames)}"); + return null; + } + + return new VerifyOptions + { + MaxParallelism = maxParallelism, + LogFilePath = logFilePath, + CsvFilePath = csvFilePath, + Samples = samples, + }; + } + + private static string? ExtractArg(List list, string flag) + { + var idx = list.IndexOf(flag); + if (idx < 0) + { + return null; + } + + if (idx + 1 >= list.Count) + { + Console.Error.WriteLine($"Missing value for {flag}."); + list.RemoveAt(idx); + return null; + } + + var value = list[idx + 1]; + list.RemoveRange(idx, 2); + return value; + } +} diff --git a/dotnet/eng/verify-samples/WorkflowSamples.cs b/dotnet/eng/verify-samples/WorkflowSamples.cs new file mode 100644 index 0000000000..2842f4af89 --- /dev/null +++ b/dotnet/eng/verify-samples/WorkflowSamples.cs @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace VerifySamples; + +/// +/// Defines the expected behavior for each sample in 03-workflows. +/// +internal static class WorkflowSamples +{ + public static IReadOnlyList All { get; } = + [ + // ─────────────────────────────────────────────────────────────────── + // _StartHere + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_StartHere_01_Streaming", + ProjectPath = "samples/03-workflows/_StartHere/01_Streaming", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "UppercaseExecutor: HELLO, WORLD!", + "ReverseTextExecutor: !DLROW ,OLLEH", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_02_AgentsInWorkflows", + ProjectPath = "samples/03-workflows/_StartHere/02_AgentsInWorkflows", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show agent responses from a translation workflow.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_03_AgentWorkflowPatterns", + ProjectPath = "samples/03-workflows/_StartHere/03_AgentWorkflowPatterns", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["sequential"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should show a sequential workflow pattern with multiple agents executing tasks in order.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_04_MultiModelService", + ProjectPath = "samples/03-workflows/_StartHere/04_MultiModelService", + RequiredEnvironmentVariables = ["BEDROCK_ACCESS_KEY", "BEDROCK_SECRET_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"], + SkipReason = "Requires multiple external provider API keys (Bedrock, Anthropic, OpenAI).", + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_05_SubWorkflows", + ProjectPath = "samples/03-workflows/_StartHere/05_SubWorkflows", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "=== Sub-Workflow Demonstration ===", + "Final Output:", + "=== Main Workflow Completed ===", + "Sample Complete: Workflows can be composed hierarchically using sub-workflows", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_06_MixedWorkflowAgentsAndExecutors", + ProjectPath = "samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["What is 2 plus 2?"], + InputDelayMs = 3000, + ExpectedOutputDescription = + [ + "The output should show agents and executors working together to process a user question.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_StartHere_07_WriterCriticWorkflow", + ProjectPath = "samples/03-workflows/_StartHere/07_WriterCriticWorkflow", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["=== Writer-Critic Iteration Workflow ==="], + ExpectedOutputDescription = + [ + "The output should show a writer-critic iteration workflow with writer and critic sections.", + "The critic should either approve or request revisions.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Agents + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Agents_CustomAgentExecutors", + ProjectPath = "samples/03-workflows/Agents/CustomAgentExecutors", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show custom workflow events including slogan generation and feedback.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Agents_FoundryAgent", + ProjectPath = "samples/03-workflows/Agents/FoundryAgent", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + SkipReason = "Requires Azure AI Foundry project endpoint.", + }, + + new SampleDefinition + { + Name = "Workflow_Agents_GroupChatToolApproval", + ProjectPath = "samples/03-workflows/Agents/GroupChatToolApproval", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + MustContain = ["Starting group chat workflow for software deployment..."], + ExpectedOutputDescription = + [ + "The output should show a group chat workflow with QA and DevOps agents for software deployment.", + "There should be approval requests for tool calls.", + "The workflow should show interaction between QA and DevOps agents toward deployment.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Agents_WorkflowAsAnAgent", + ProjectPath = "samples/03-workflows/Agents/WorkflowAsAnAgent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + Inputs = ["hello", "exit"], + InputDelayMs = 5000, + ExpectedOutputDescription = + [ + "The output should show a conversational workflow responding to the user's hello message.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Checkpoint + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Checkpoint_CheckpointAndRehydrate", + ProjectPath = "samples/03-workflows/Checkpoint/CheckpointAndRehydrate", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Workflow completed with result:", + "Number of checkpoints created:", + "Hydrating a new workflow instance from the 6th checkpoint.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Checkpoint_CheckpointAndResume", + ProjectPath = "samples/03-workflows/Checkpoint/CheckpointAndResume", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Workflow completed with result:", + "Number of checkpoints created:", + "Restoring from the 6th checkpoint.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Checkpoint_CheckpointWithHumanInTheLoop", + ProjectPath = "samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop", + RequiredEnvironmentVariables = [], + Inputs = ["50", "25", "40", "45", "42", "50", "25", "40", "45", "42"], + InputDelayMs = 1000, + MustContain = ["found in"], + ExpectedOutputDescription = + [ + "The output should show a number guessing game with higher/lower hints that eventually reaches the correct number.", + "The output should demonstrate checkpoint save and restore behavior.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Concurrent + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Concurrent_Concurrent", + ProjectPath = "samples/03-workflows/Concurrent/Concurrent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show results from concurrent agent processing.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_Concurrent_MapReduce", + ProjectPath = "samples/03-workflows/Concurrent/MapReduce", + RequiredEnvironmentVariables = [], + MustContain = + [ + "=== RUNNING WORKFLOW ===", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // ConditionalEdges + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_ConditionalEdges_01_EdgeCondition", + ProjectPath = "samples/03-workflows/ConditionalEdges/01_EdgeCondition", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an email being classified as spam or not spam and processed accordingly.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_ConditionalEdges_02_SwitchCase", + ProjectPath = "samples/03-workflows/ConditionalEdges/02_SwitchCase", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an ambiguous email being classified as spam, not spam, or uncertain.", + "The output should not contain error messages or stack traces.", + ], + }, + + new SampleDefinition + { + Name = "Workflow_ConditionalEdges_03_MultiSelection", + ProjectPath = "samples/03-workflows/ConditionalEdges/03_MultiSelection", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + ExpectedOutputDescription = + [ + "The output should show an email being classified and potentially routed to multiple handlers.", + "The output should not contain error messages or stack traces.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // HumanInTheLoop + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_HumanInTheLoop_Basic", + ProjectPath = "samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic", + RequiredEnvironmentVariables = [], + Inputs = ["50", "25", "40", "45", "42"], + InputDelayMs = 1000, + MustContain = ["found in"], + ExpectedOutputDescription = + [ + "The output should show a number guessing game with higher/lower hints that eventually reaches the correct number 42.", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Loop + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Loop", + ProjectPath = "samples/03-workflows/Loop", + RequiredEnvironmentVariables = [], + MustContain = ["Result:"], + }, + + // ─────────────────────────────────────────────────────────────────── + // SharedStates + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_SharedStates", + ProjectPath = "samples/03-workflows/SharedStates", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Total Paragraphs:", + "Total Words:", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Visualization + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Visualization", + ProjectPath = "samples/03-workflows/Visualization", + RequiredEnvironmentVariables = [], + IsDeterministic = true, + MustContain = + [ + "Generating workflow visualization...", + "Mermaid string:", + "DiGraph string:", + ], + }, + + // ─────────────────────────────────────────────────────────────────── + // Observability + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Observability_ApplicationInsights", + ProjectPath = "samples/03-workflows/Observability/ApplicationInsights", + RequiredEnvironmentVariables = ["APPLICATIONINSIGHTS_CONNECTION_STRING"], + SkipReason = "Requires Application Insights connection string.", + }, + + new SampleDefinition + { + Name = "Workflow_Observability_AspireDashboard", + ProjectPath = "samples/03-workflows/Observability/AspireDashboard", + RequiredEnvironmentVariables = [], + SkipReason = "Requires Aspire Dashboard / OTLP endpoint.", + }, + + new SampleDefinition + { + Name = "Workflow_Observability_WorkflowAsAnAgent", + ProjectPath = "samples/03-workflows/Observability/WorkflowAsAnAgent", + RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"], + SkipReason = "Interactive console with ReadLine loop; requires OTLP endpoint.", + }, + + // ─────────────────────────────────────────────────────────────────── + // Declarative + // ─────────────────────────────────────────────────────────────────── + + new SampleDefinition + { + Name = "Workflow_Declarative_ConfirmInput", + ProjectPath = "samples/03-workflows/Declarative/ConfirmInput", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + Inputs = ["hello", "hello"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a confirmation prompt and a user response."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_CustomerSupport", + ProjectPath = "samples/03-workflows/Declarative/CustomerSupport", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["My laptop won't start"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a customer support workflow processing a laptop issue, with agent responses providing troubleshooting or support."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_DeepResearch", + ProjectPath = "samples/03-workflows/Declarative/DeepResearch", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + SkipReason = "Requires external weather API (wttr.in).", + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_ExecuteCode", + ProjectPath = "samples/03-workflows/Declarative/ExecuteCode", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + Inputs = ["What is 12 * 34?"], + InputDelayMs = 5000, + ExpectedOutputDescription = ["The output should show a declarative workflow executing generated code, processing a math question and producing a result."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_ExecuteWorkflow", + ProjectPath = "samples/03-workflows/Declarative/ExecuteWorkflow", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + SkipReason = "Requires a workflow file path as a CLI argument.", + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_FunctionTools", + ProjectPath = "samples/03-workflows/Declarative/FunctionTools", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What are today's specials?", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow calling function tools (e.g. a menu plugin) to answer a question about restaurant specials."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_GenerateCode", + ProjectPath = "samples/03-workflows/Declarative/GenerateCode", + IsDeterministic = true, + MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"], + ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_HostedWorkflow", + ProjectPath = "samples/03-workflows/Declarative/HostedWorkflow", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + SkipReason = "Hosts a persistent workflow server that does not exit.", + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_InputArguments", + ProjectPath = "samples/03-workflows/Declarative/InputArguments", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["I'd like to visit Seattle", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow capturing location input and providing travel-related information about Seattle."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeFunctionTool", + ProjectPath = "samples/03-workflows/Declarative/InvokeFunctionTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What's the soup of the day?", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeMcpTool", + ProjectPath = "samples/03-workflows/Declarative/InvokeMcpTool", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Search for .NET tutorials on Microsoft Learn"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a workflow using MCP tools to search Microsoft Learn documentation and provide a summary of results."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_Marketing", + ProjectPath = "samples/03-workflows/Declarative/Marketing", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["A smart water bottle that tracks hydration"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a marketing workflow generating content about a smart water bottle product."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_StudentTeacher", + ProjectPath = "samples/03-workflows/Declarative/StudentTeacher", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["What is 18 + 27?"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a student-teacher workflow where a student asks a math question and a teacher provides the answer."], + }, + + new SampleDefinition + { + Name = "Workflow_Declarative_ToolApproval", + ProjectPath = "samples/03-workflows/Declarative/ToolApproval", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + Inputs = ["Search for .NET tutorials", "EXIT"], + InputDelayMs = 8000, + ExpectedOutputDescription = ["The output should show a workflow using an MCP tool with approval to search Microsoft Learn, followed by an exit from the input loop."], + }, + ]; +} diff --git a/dotnet/eng/verify-samples/verify-samples.csproj b/dotnet/eng/verify-samples/verify-samples.csproj new file mode 100644 index 0000000000..f7f86ba90d --- /dev/null +++ b/dotnet/eng/verify-samples/verify-samples.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + false + + $(NoWarn);CA2007 + + + + + + + + + + + + + From 4b9856e66fe527417e71ff84886057a2fb3a9557 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 15:35:56 +0200 Subject: [PATCH 08/20] Python: updated azure ai inference sample (#5028) * updated azure ai inference sample * openai multimodel fix * update language --- .../core/agent_framework/azure/__init__.py | 4 ++++ .../core/agent_framework/azure/__init__.pyi | 8 +++++++ .../azure_ai_inference_embeddings.py | 22 ++++++++++++------- .../openai_chat_multimodal.py | 15 +++++++++---- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 9cd961c921..27a9dc7e3a 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -15,6 +15,10 @@ _IMPORTS: dict[str, tuple[str, str]] = { "AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureAIInferenceEmbeddingClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureAIInferenceEmbeddingOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureAIInferenceEmbeddingSettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "RawAzureAIInferenceEmbeddingClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureCredentialTypes": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureTokenProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"), diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index 13694a1017..52d32bb146 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -4,9 +4,13 @@ # Install the relevant packages for full type support. from agent_framework_azure_ai import ( + AzureAIInferenceEmbeddingClient, + AzureAIInferenceEmbeddingOptions, + AzureAIInferenceEmbeddingSettings, AzureAISettings, AzureCredentialTypes, AzureTokenProvider, + RawAzureAIInferenceEmbeddingClient, ) from agent_framework_azure_ai_search import ( AzureAISearchContextProvider, @@ -26,6 +30,9 @@ __all__ = [ "AgentCallbackContext", "AgentFunctionApp", "AgentResponseCallbackProtocol", + "AzureAIInferenceEmbeddingClient", + "AzureAIInferenceEmbeddingOptions", + "AzureAIInferenceEmbeddingSettings", "AzureAISearchContextProvider", "AzureAISearchSettings", "AzureAISettings", @@ -35,4 +42,5 @@ __all__ = [ "DurableAIAgentClient", "DurableAIAgentOrchestrationContext", "DurableAIAgentWorker", + "RawAzureAIInferenceEmbeddingClient", ] diff --git a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py b/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py index 70d60983e9..e806166f16 100644 --- a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py +++ b/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py @@ -12,7 +12,7 @@ import asyncio import pathlib from agent_framework import Content -from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient +from agent_framework.azure import AzureAIInferenceEmbeddingClient from dotenv import load_dotenv load_dotenv() @@ -24,8 +24,12 @@ Azure AI Inference embedding client with the Cohere-embed-v3-english model. Images are passed as ``Content`` objects created with ``Content.from_data()``. Prerequisites: - Set the following environment variables or add them to a .env file: - - AZURE_AI_INFERENCE_ENDPOINT: Your Azure AI model inference endpoint URL + Deploy an embedding model in Azure AI Inference that supports image inputs, such as Cohere-embed-v3-english. + + The details page for that model, has a target URI and a Key, which should be set in environment variables or a .env + file as follows, the target URI should append the `/models` path: + - AZURE_AI_INFERENCE_ENDPOINT: Your Azure AI model inference endpoint URL, for instance: + https://.azure-api.net//models - AZURE_AI_INFERENCE_API_KEY: Your API key - AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID: The text embedding model name (e.g. "text-embedding-3-small") @@ -73,15 +77,17 @@ if __name__ == "__main__": """ -Sample output (using Cohere-embed-v3-english): +Sample output (using deployment: Cohere-embed-v3-english, which is Cohere's "embed-english-v3.0-image" model): Image embedding dimensions: 1024 -First 5 values: [0.023, -0.045, 0.067, -0.089, 0.011] -Model: Cohere-embed-v3-english -Usage: {'prompt_tokens': 1, 'total_tokens': 1} +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] +Model: embed-english-v3.0-image +Usage: {'input_token_count': 1000, 'output_token_count': 0} -Image+text (separate) results: Text embedding dimensions: 1536 +First 5 values: [-0.019439403, 0.015791258, 0.012358093, 0.0028533707, -0.01649483] Image embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] Document embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] """ diff --git a/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py index 2879588a41..3fc8bae84d 100644 --- a/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py @@ -6,7 +6,7 @@ import struct from pathlib import Path from agent_framework import Content, Message -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file @@ -14,6 +14,13 @@ load_dotenv() ASSETS_DIR = Path(__file__).resolve().parents[2] / "shared" / "sample_assets" +""" +Leverage multimodel capabilities of different models. + +Uses the OpenAIChatClient and OpenAIChatCompletionClient to demonstrate multimodal input handling with the gpt-4o and gpt-4o-audio-preview models, respectively. The sample includes demonstrations for image, audio, and PDF inputs, showcasing how to create appropriate Content objects and send them in messages to the chat clients. + +""" + def load_sample_pdf() -> bytes: """Read the bundled sample PDF for tests.""" @@ -46,7 +53,7 @@ def create_sample_audio() -> str: async def test_image() -> None: """Test image analysis with OpenAI.""" - client = FoundryChatClient(model="gpt-4o") + client = OpenAIChatClient(model="gpt-4o") image_uri = create_sample_image() message = Message( @@ -63,7 +70,7 @@ async def test_image() -> None: async def test_audio() -> None: """Test audio analysis with OpenAI.""" - client = FoundryChatClient(model="gpt-4o-audio-preview") + client = OpenAIChatCompletionClient(model="gpt-4o-audio-preview-2025-06-03") audio_uri = create_sample_audio() message = Message( @@ -80,7 +87,7 @@ async def test_audio() -> None: async def test_pdf() -> None: """Test PDF document analysis with OpenAI.""" - client = FoundryChatClient(model="gpt-4o") + client = OpenAIChatClient(model="gpt-4o") pdf_bytes = load_sample_pdf() message = Message( From cee0a458fea0320858c80a98da24da49f18d797c Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 15:40:27 +0200 Subject: [PATCH 09/20] Python: fixed middleware samples (#5026) * fixed samples * small update to explanation * add snippet fix on root readme --- README.md | 24 +++---- python/samples/02-agents/middleware/README.md | 4 +- .../override_result_with_middleware.py | 38 ++++++++--- .../middleware/runtime_context_delegation.py | 64 +++++++++++++------ 4 files changed, 85 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c5cac3ce4b..759f899e20 100644 --- a/README.md +++ b/README.md @@ -94,23 +94,23 @@ Create a simple Azure Responses Agent that writes a haiku about the Microsoft Ag # Use `az login` to authenticate with Azure CLI import os import asyncio -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential async def main(): - # Initialize a chat agent with Azure OpenAI Responses + # Initialize a chat agent with Microsoft Foundry # the endpoint, deployment name, and api version can be set via environment variables - # or they can be passed in directly to the AzureOpenAIResponsesClient constructor - agent = AzureOpenAIResponsesClient( - # endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - # deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - # api_version=os.environ["AZURE_OPENAI_API_VERSION"], - # api_key=os.environ["AZURE_OPENAI_API_KEY"], # Optional if using AzureCliCredential - credential=AzureCliCredential(), # Optional, if using api_key - ).as_agent( - name="HaikuBot", - instructions="You are an upbeat assistant that writes beautifully.", + # or they can be passed in directly to the FoundryChatClient constructor + agent = Agent( + client=FoundryChatClient( + credential=AzureCliCredential(), + # project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + # model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"], + ), + name="HaikuBot", + instructions="You are an upbeat assistant that writes beautifully.", ) print(await agent.run("Write a haiku about Microsoft Agent Framework.")) diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md index 75380d93db..755a705eea 100644 --- a/python/samples/02-agents/middleware/README.md +++ b/python/samples/02-agents/middleware/README.md @@ -13,8 +13,8 @@ This folder contains focused middleware samples for `Agent`, chat clients, tools | [`exception_handling_with_middleware.py`](./exception_handling_with_middleware.py) | Shows how middleware can handle failures and recover cleanly. | | [`function_based_middleware.py`](./function_based_middleware.py) | Shows function-based agent and function middleware. | | [`middleware_termination.py`](./middleware_termination.py) | Demonstrates stopping a middleware pipeline early. | -| [`override_result_with_middleware.py`](./override_result_with_middleware.py) | Shows how middleware can replace the normal result. | -| [`runtime_context_delegation.py`](./runtime_context_delegation.py) | Demonstrates delegating work with runtime context data. | +| [`override_result_with_middleware.py`](./override_result_with_middleware.py) | Shows how middleware can replace regular and streaming results, then post-process the final response. | +| [`runtime_context_delegation.py`](./runtime_context_delegation.py) | Demonstrates delegating arguments with runtime context data. | | [`session_behavior_middleware.py`](./session_behavior_middleware.py) | Shows how middleware interacts with session-backed runs. | | [`shared_state_middleware.py`](./shared_state_middleware.py) | Demonstrates sharing mutable state across middleware invocations. | | [`usage_tracking_middleware.py`](./usage_tracking_middleware.py) | Demonstrates one chat middleware function that tracks per-call usage in non-streaming and streaming tool-loop runs. | diff --git a/python/samples/02-agents/middleware/override_result_with_middleware.py b/python/samples/02-agents/middleware/override_result_with_middleware.py index dde54e4238..2aa02da577 100644 --- a/python/samples/02-agents/middleware/override_result_with_middleware.py +++ b/python/samples/02-agents/middleware/override_result_with_middleware.py @@ -81,7 +81,7 @@ async def weather_override_middleware(context: ChatContext, call_next: Callable[ role="assistant", ) - context.result = ResponseStream(_override_stream()) + context.result = ResponseStream(_override_stream(), finalizer=ChatResponse.from_updates) else: # For non-streaming: just replace with a new message current_text = context.result.text if isinstance(context.result, ChatResponse) else "" @@ -99,12 +99,17 @@ async def validate_weather_middleware(context: ChatContext, call_next: Callable[ return if context.stream and isinstance(context.result, ResponseStream): + result_stream = context.result - def _append_validation_note(response: ChatResponse) -> ChatResponse: - response.messages.append(Message(role="assistant", text=validation_note)) - return response + async def _validated_stream() -> AsyncIterable[ChatResponseUpdate]: + async for update in result_stream: + yield update + yield ChatResponseUpdate( + contents=[Content.from_text(text=validation_note)], + role="assistant", + ) - context.result.with_finalizer(_append_validation_note) + context.result = ResponseStream(_validated_stream(), finalizer=ChatResponse.from_updates) elif isinstance(context.result, ChatResponse): context.result.messages.append(Message(role="assistant", text=validation_note)) @@ -118,11 +123,11 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[] validation_note = "Validation: weather data verified." - state = {"found_prefix": False} + state = {"found_prefix": False, "found_validation": False} def _sanitize(response: AgentResponse) -> AgentResponse: found_prefix = state["found_prefix"] - found_validation = False + found_validation = state["found_validation"] cleaned_messages: list[Message] = [] for message in response.messages: @@ -141,12 +146,14 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[] found_prefix = True text = text.replace("Weather Advisory:", "") - text = re.sub(r"\[\d+\]\s*", "", text) + text = re.sub(r"\[\d+\]\s*", "", text).strip() + if not text: + continue cleaned_messages.append( Message( role=message.role, - text=text.strip(), + text=text, author_name=message.author_name, message_id=message.message_id, additional_properties=message.additional_properties, @@ -166,19 +173,30 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[] if context.stream and isinstance(context.result, ResponseStream): def _clean_update(update: AgentResponseUpdate) -> AgentResponseUpdate: + cleaned_contents: list[Content] = [] + for content in update.contents or []: if not content.text: + cleaned_contents.append(content) continue text = content.text if "Weather Advisory:" in text: state["found_prefix"] = True text = text.replace("Weather Advisory:", "") + if validation_note in text: + state["found_validation"] = True + text = text.replace(validation_note, "").strip() + if not text: + continue text = re.sub(r"\[\d+\]\s*", "", text) content.text = text + cleaned_contents.append(content) + + update.contents = cleaned_contents return update context.result.with_transform_hook(_clean_update) - context.result.with_finalizer(_sanitize) + context.result.with_result_hook(_sanitize) elif isinstance(context.result, AgentResponse): context.result = _sanitize(context.result) diff --git a/python/samples/02-agents/middleware/runtime_context_delegation.py b/python/samples/02-agents/middleware/runtime_context_delegation.py index acc2219d6f..532fa56ba9 100644 --- a/python/samples/02-agents/middleware/runtime_context_delegation.py +++ b/python/samples/02-agents/middleware/runtime_context_delegation.py @@ -6,6 +6,7 @@ from typing import Annotated from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -43,6 +44,13 @@ Key Concepts: - MiddlewareTypes: Intercepts function calls to access/modify kwargs - Closure: Functions capturing variables from outer scope - kwargs Propagation: Automatic forwarding of runtime context through delegation chains + +Environment Setup: +- Configure Azure credentials (e.g., via Azure CLI) +- Run `az login` to authenticate +- Set FOUNDRY_PROJECT_ENDPOINT to your Azure AI Foundry project endpoint +- Set FOUNDRY_MODEL to the model deployment name (for example: gpt-4o) + """ @@ -85,7 +93,7 @@ class SessionContextContainer: runtime_context = SessionContextContainer() -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") async def send_email( to: Annotated[str, Field(description="Recipient email address")], @@ -149,7 +157,7 @@ async def pattern_1_single_agent_with_closure() -> None: print("Use case: Single agent with multiple tools sharing runtime context") print() - client = FoundryChatClient(model="gpt-4o-mini") + client = FoundryChatClient(credential=AzureCliCredential()) # Create agent with both tools and shared context via middleware communication_agent = Agent( @@ -177,9 +185,11 @@ async def pattern_1_single_agent_with_closure() -> None: result1 = await communication_agent.run( user_query, # Runtime context passed as kwargs - api_token="sk-test-token-xyz-789", - user_id="user-12345", - session_metadata={"tenant": "acme-corp", "region": "us-west"}, + function_invocation_kwargs={ + "api_token": "sk-test-token-xyz-789", + "user_id": "user-12345", + "session_metadata": {"tenant": "acme-corp", "region": "us-west"}, + }, ) print(f"\nAgent: {result1.text}") @@ -195,9 +205,11 @@ async def pattern_1_single_agent_with_closure() -> None: result2 = await communication_agent.run( user_query2, # Different runtime context for this request - api_token="sk-prod-token-abc-456", - user_id="user-67890", - session_metadata={"tenant": "store-inc", "region": "eu-central"}, + function_invocation_kwargs={ + "api_token": "sk-prod-token-abc-456", + "user_id": "user-67890", + "session_metadata": {"tenant": "store-inc", "region": "eu-central"}, + }, ) print(f"\nAgent: {result2.text}") @@ -215,9 +227,11 @@ async def pattern_1_single_agent_with_closure() -> None: result3 = await communication_agent.run( user_query3, - api_token="sk-dev-token-def-123", - user_id="user-11111", - session_metadata={"tenant": "dev-team", "region": "us-east"}, + function_invocation_kwargs={ + "api_token": "sk-dev-token-def-123", + "user_id": "user-11111", + "session_metadata": {"tenant": "dev-team", "region": "us-east"}, + }, ) print(f"\nAgent: {result3.text}") @@ -234,7 +248,9 @@ async def pattern_1_single_agent_with_closure() -> None: result4 = await communication_agent.run( user_query4, # Missing api_token - tools should handle gracefully - user_id="user-22222", + function_invocation_kwargs={ + "user_id": "user-22222", + }, ) print(f"\nAgent: {result4.text}") @@ -295,7 +311,7 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: print(f"[SMSAgent] Received runtime context: {list(context.kwargs.keys())}") await call_next() - client = FoundryChatClient(model="gpt-4o-mini") + client = FoundryChatClient(credential=AzureCliCredential()) # Create specialized sub-agents email_agent = Agent( @@ -341,9 +357,11 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: print("Test: Send email with runtime context\n") await coordinator.run( "Send an email to john@example.com with subject 'Meeting' and body 'See you at 2pm'", - api_token="secret-token-abc", - user_id="user-999", - tenant_id="tenant-acme", + function_invocation_kwargs={ + "api_token": "secret-token-abc", + "user_id": "user-999", + "tenant_id": "tenant-acme", + }, ) print(f"\n[Verification] EmailAgent received kwargs keys: {list(email_agent_kwargs.keys())}") @@ -400,7 +418,7 @@ async def pattern_3_hierarchical_with_middleware() -> None: auth_middleware = AuthContextMiddleware() - client = FoundryChatClient(model="gpt-4o-mini") + client = FoundryChatClient(credential=AzureCliCredential()) # Sub-agent with validation middleware protected_agent = Agent( @@ -428,16 +446,20 @@ async def pattern_3_hierarchical_with_middleware() -> None: print("Test 1: Valid token\n") await coordinator.run( "Execute operation: backup_database", - api_token="valid-token-xyz-789", - user_id="admin-123", + function_invocation_kwargs={ + "api_token": "valid-token-xyz-789", + "user_id": "admin-123", + }, ) # Test with invalid token print("\nTest 2: Invalid token\n") await coordinator.run( "Execute operation: delete_records", - api_token="invalid-token-bad", - user_id="user-456", + function_invocation_kwargs={ + "api_token": "invalid-token-bad", + "user_id": "user-456", + }, ) print(f"\n[Validation Summary] Validated tokens: {len(auth_middleware.validated_tokens)}") From 2cb78ea12e20ffcd102b00c0f42a23f53a32df21 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 15:47:20 +0200 Subject: [PATCH 10/20] fix and unify devui samples (#5025) --- python/samples/02-agents/devui/.env.example | 15 ++ python/samples/02-agents/devui/README.md | 108 +++++++++++---- .../devui/agent_foundry/.env.example | 5 + .../__init__.py | 0 .../{foundry_agent => agent_foundry}/agent.py | 2 +- .../devui/agent_weather/.env.example | 5 + .../__init__.py | 0 .../agent.py | 13 +- .../devui/azure_responses_agent/.env.example | 15 -- .../devui/azure_responses_agent/__init__.py | 6 - .../devui/azure_responses_agent/agent.py | 128 ------------------ .../devui/foundry_agent/.env.example | 6 - .../samples/02-agents/devui/in_memory_mode.py | 10 +- python/samples/02-agents/devui/main.py | 32 +++++ .../devui/weather_agent_azure/.env.example | 6 - .../devui/workflow_agents/.env.example | 7 - .../__init__.py | 0 .../workflow.py | 0 .../workflow.yaml | 0 .../__init__.py | 0 .../workflow.py | 0 .../__init__.py | 0 .../workflow.py | 0 .../devui/workflow_with_agents/.env.example | 9 ++ .../__init__.py | 0 .../workflow.py | 12 +- .../a2a/a2a_agent_as_function_tools.py | 4 +- .../evaluate_tool_calls_sample.py | 3 +- 28 files changed, 169 insertions(+), 217 deletions(-) create mode 100644 python/samples/02-agents/devui/.env.example create mode 100644 python/samples/02-agents/devui/agent_foundry/.env.example rename python/samples/02-agents/devui/{foundry_agent => agent_foundry}/__init__.py (100%) rename python/samples/02-agents/devui/{foundry_agent => agent_foundry}/agent.py (98%) create mode 100644 python/samples/02-agents/devui/agent_weather/.env.example rename python/samples/02-agents/devui/{weather_agent_azure => agent_weather}/__init__.py (100%) rename python/samples/02-agents/devui/{weather_agent_azure => agent_weather}/agent.py (94%) delete mode 100644 python/samples/02-agents/devui/azure_responses_agent/.env.example delete mode 100644 python/samples/02-agents/devui/azure_responses_agent/__init__.py delete mode 100644 python/samples/02-agents/devui/azure_responses_agent/agent.py delete mode 100644 python/samples/02-agents/devui/foundry_agent/.env.example create mode 100644 python/samples/02-agents/devui/main.py delete mode 100644 python/samples/02-agents/devui/weather_agent_azure/.env.example delete mode 100644 python/samples/02-agents/devui/workflow_agents/.env.example rename python/samples/02-agents/devui/{declarative => workflow_declarative}/__init__.py (100%) rename python/samples/02-agents/devui/{declarative => workflow_declarative}/workflow.py (100%) rename python/samples/02-agents/devui/{declarative => workflow_declarative}/workflow.yaml (100%) rename python/samples/02-agents/devui/{fanout_workflow => workflow_fanout}/__init__.py (100%) rename python/samples/02-agents/devui/{fanout_workflow => workflow_fanout}/workflow.py (100%) rename python/samples/02-agents/devui/{spam_workflow => workflow_spam}/__init__.py (100%) rename python/samples/02-agents/devui/{spam_workflow => workflow_spam}/workflow.py (100%) create mode 100644 python/samples/02-agents/devui/workflow_with_agents/.env.example rename python/samples/02-agents/devui/{workflow_agents => workflow_with_agents}/__init__.py (100%) rename python/samples/02-agents/devui/{workflow_agents => workflow_with_agents}/workflow.py (93%) diff --git a/python/samples/02-agents/devui/.env.example b/python/samples/02-agents/devui/.env.example new file mode 100644 index 0000000000..394dc7465d --- /dev/null +++ b/python/samples/02-agents/devui/.env.example @@ -0,0 +1,15 @@ +# Shared configuration for samples/02-agents/devui +# Used by in_memory_mode.py, main.py, and as a fallback for discovered samples. +# Run `az login` before starting Azure-backed samples. + +# Microsoft Foundry samples +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +FOUNDRY_MODEL=gpt-4o + +# Azure OpenAI workflow sample +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o +# Optional fallback env name also supported by workflow_with_agents/workflow.py: +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +# Optional if you need to override the default API version: +AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index 4315582009..8a0ed60b6f 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -16,76 +16,124 @@ DevUI is a sample application that provides: ## Quick Start -### Option 1: In-Memory Mode (Simplest) +### Option 1: In-Memory Mode (Programmatic Registration) -Run a single sample directly. This demonstrates how to wrap agents and workflows programmatically without needing a directory structure: +Run a single sample directly. This demonstrates how to register agents and workflows in code without using DevUI's directory discovery. + +This sample uses Azure AI Foundry. Before running it: + +1. Copy `.env.example` in this folder to `.env`, or export the same values in your shell +2. Set `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` +3. Run `az login` + +Then start the sample: ```bash cd python/samples/02-agents/devui python in_memory_mode.py ``` -This opens your browser at http://localhost:8090 with pre-configured agents and a basic workflow. +This opens your browser at http://localhost:8090 with two Foundry-backed agents and a simple text transformation workflow. -### Option 2: Directory Discovery +### Option 2: Directory Discovery with Shared Root `.env` -Launch DevUI to discover all samples in this folder: +Run the folder-level launcher to load `samples/02-agents/devui/.env` and then start DevUI with directory discovery for this folder: ```bash cd python/samples/02-agents/devui -devui +python main.py ``` -This starts the server at http://localhost:8080 with all agents and workflows available. +This starts the server at http://localhost:8080 with all discoverable agents and workflows available. The root `.env` acts as shared fallback configuration for discovered samples. + +### Option 3: Directory Discovery with the `devui` CLI + +If you prefer the CLI directly, you can still launch DevUI from this folder: + +```bash +cd python/samples/02-agents/devui +devui . +``` + +DevUI discovery checks for a sample-specific `.env` first and then falls back to `.env` in `samples/02-agents/devui/`. ## Sample Structure -Each agent/workflow follows a strict structure required by DevUI's discovery system: +DevUI discovers samples from Python packages that export either `agent` or `workflow`. + +Typical agent layout: ``` agent_name/ -├── __init__.py # Must export: agent = Agent(...) +├── __init__.py # Must export: agent = ... ├── agent.py # Agent implementation -└── .env.example # Example environment variables +└── .env.example # Optional example environment variables +``` + +Typical workflow layout: + +``` +workflow_name/ +├── __init__.py # Must export: workflow = ... +├── workflow.py # Workflow implementation +├── workflow.yaml # Optional declarative definition +└── .env.example # Optional example environment variables ``` ## Available Samples ### Agents -| Sample | Description | Features | Required Environment Variables | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| [**weather_agent_azure/**](weather_agent_azure/) | Weather agent using Azure OpenAI with API key authentication | Azure OpenAI integration, function calling, mock weather tools | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | -| [**foundry_agent/**](foundry_agent/) | Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication (run `az login` first) | Azure AI Agent integration, Azure CLI authentication, mock weather tools | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL` | +| Sample | What it demonstrates | Required keys / auth | +| ------ | -------------------- | -------------------- | +| [**agent_weather/**](agent_weather/) | A richer Foundry-backed weather agent that shows chat middleware, function middleware, tool calling, and an approval-required tool alongside auto-approved tools. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` | +| [**agent_foundry/**](agent_foundry/) | A minimal Foundry-backed weather agent with current weather and forecast tools. Use this when you want the smallest possible directory-discovered agent sample. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` | ### Workflows -| Sample | Description | Features | Required Environment Variables | -| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| [**declarative/**](declarative/) | Declarative YAML workflow with conditional branching | YAML-based workflow definition, conditional logic, no Python code required | None - uses mock data | -| [**workflow_agents/**](workflow_agents/) | Content review workflow with agents as executors | Agents as workflow nodes, conditional routing based on structured outputs, quality-based paths (Writer -> Reviewer -> Editor/Publisher) | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | -| [**spam_workflow/**](spam_workflow/) | 5-step email spam detection workflow with branching logic | Sequential execution, conditional branching (spam vs. legitimate), multiple executors, mock spam detection | None - uses mock data | -| [**fanout_workflow/**](fanout_workflow/) | Advanced data processing workflow with parallel execution | Fan-out/fan-in patterns, complex state management, multi-stage processing (validation -> transformation -> quality assurance) | None - uses mock data | +| Sample | What it demonstrates | Required keys / auth | +| ------ | -------------------- | -------------------- | +| [**workflow_declarative/**](workflow_declarative/) | A YAML-defined workflow loaded through `WorkflowFactory`, with nested age-based branching and no model client code. | None | +| [**workflow_with_agents/**](workflow_with_agents/) | A content review workflow that uses agents as executors and routes based on structured review output (`Writer -> Reviewer -> Editor/Publisher -> Summarizer`). | `AZURE_OPENAI_ENDPOINT`, plus `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` or `AZURE_OPENAI_DEPLOYMENT_NAME`; Azure CLI auth via `az login`; `AZURE_OPENAI_API_VERSION` is optional | +| [**workflow_spam/**](workflow_spam/) | A multi-step spam detection workflow with human-in-the-loop approval, branching for spam vs. legitimate messages, and a final reporting step. | None | +| [**workflow_fanout/**](workflow_fanout/) | A larger fan-out/fan-in data processing workflow with parallel validation, multiple transformations, QA, aggregation, and demo failure toggles. | None | ### Standalone Examples -| Sample | Description | Features | -| ------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| [**in_memory_mode.py**](in_memory_mode.py) | Demonstrates programmatic entity registration without directory structure | In-memory agent and workflow registration, multiple entities served from a single file, includes basic workflow, simplest way to get started | +| Sample | What it demonstrates | Required keys / auth | +| ------ | -------------------- | -------------------- | +| [**in_memory_mode.py**](in_memory_mode.py) | Registers multiple entities directly in Python: two Foundry-backed agents plus a simple workflow, all served from one file without directory discovery. | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, plus Azure CLI auth via `az login` | ## Environment Variables -Each sample that requires API keys includes a `.env.example` file. To use: +For samples that require external services: -1. Copy `.env.example` to `.env` in the same directory -2. Fill in your actual API keys -3. DevUI automatically loads `.env` files from entity directories +1. Copy `.env.example` to `.env` +2. Fill in the required values +3. Run `az login` for samples that use Azure CLI authentication + +Directory discovery checks `.env` files in this order: + +1. The entity directory itself, for example `agent_weather/.env` +2. The root DevUI samples folder, `samples/02-agents/devui/.env` + +That means the root `.env.example` can hold shared defaults for multiple samples, while a sample-specific `.env` can override those values when needed. + +`in_memory_mode.py` and `main.py` both load `.env` from `samples/02-agents/devui/`, so the root `.env.example` in this folder is the right starting point for both commands. Alternatively, set environment variables globally: ```bash -export OPENAI_API_KEY="your-key-here" -export OPENAI_CHAT_MODEL="gpt-4o" +# Foundry-backed samples +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com" +export FOUNDRY_MODEL="gpt-4o" + +# Azure OpenAI workflow_with_agents sample +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +export AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="gpt-4o" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" + +az login ``` ## Using DevUI with Your Own Agents @@ -145,7 +193,7 @@ curl http://localhost:8080/v1/entities ## Troubleshooting -**Missing API keys**: Check your `.env` files or environment variables. +**Missing credentials or settings**: Check your `.env` files, confirm the required variables for the sample you are running, and make sure `az login` has completed for Azure-authenticated samples. **Import errors**: Make sure you've installed the devui package: diff --git a/python/samples/02-agents/devui/agent_foundry/.env.example b/python/samples/02-agents/devui/agent_foundry/.env.example new file mode 100644 index 0000000000..c58831e971 --- /dev/null +++ b/python/samples/02-agents/devui/agent_foundry/.env.example @@ -0,0 +1,5 @@ +# Azure AI Foundry Configuration +# Make sure to run 'az login' before starting devui + +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +FOUNDRY_MODEL=gpt-4o diff --git a/python/samples/02-agents/devui/foundry_agent/__init__.py b/python/samples/02-agents/devui/agent_foundry/__init__.py similarity index 100% rename from python/samples/02-agents/devui/foundry_agent/__init__.py rename to python/samples/02-agents/devui/agent_foundry/__init__.py diff --git a/python/samples/02-agents/devui/foundry_agent/agent.py b/python/samples/02-agents/devui/agent_foundry/agent.py similarity index 98% rename from python/samples/02-agents/devui/foundry_agent/agent.py rename to python/samples/02-agents/devui/agent_foundry/agent.py index 8550c1a32c..eaeb316c2b 100644 --- a/python/samples/02-agents/devui/foundry_agent/agent.py +++ b/python/samples/02-agents/devui/agent_foundry/agent.py @@ -53,7 +53,7 @@ agent = Agent( name="FoundryWeatherAgent", client=FoundryChatClient( project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), - model_model=os.environ.get("FOUNDRY_MODEL"), + model=os.environ.get("FOUNDRY_MODEL"), credential=AzureCliCredential(), ), instructions=""" diff --git a/python/samples/02-agents/devui/agent_weather/.env.example b/python/samples/02-agents/devui/agent_weather/.env.example new file mode 100644 index 0000000000..c58831e971 --- /dev/null +++ b/python/samples/02-agents/devui/agent_weather/.env.example @@ -0,0 +1,5 @@ +# Azure AI Foundry Configuration +# Make sure to run 'az login' before starting devui + +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +FOUNDRY_MODEL=gpt-4o diff --git a/python/samples/02-agents/devui/weather_agent_azure/__init__.py b/python/samples/02-agents/devui/agent_weather/__init__.py similarity index 100% rename from python/samples/02-agents/devui/weather_agent_azure/__init__.py rename to python/samples/02-agents/devui/agent_weather/__init__.py diff --git a/python/samples/02-agents/devui/weather_agent_azure/agent.py b/python/samples/02-agents/devui/agent_weather/agent.py similarity index 94% rename from python/samples/02-agents/devui/weather_agent_azure/agent.py rename to python/samples/02-agents/devui/agent_weather/agent.py index 4861d7a4b8..bfbcea294b 100644 --- a/python/samples/02-agents/devui/weather_agent_azure/agent.py +++ b/python/samples/02-agents/devui/agent_weather/agent.py @@ -22,6 +22,7 @@ from agent_framework import ( ) from agent_framework.foundry import FoundryChatClient from agent_framework_devui import register_cleanup +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -145,7 +146,7 @@ def send_email( # Agent instance following Agent Framework conventions agent = Agent( - name="AzureWeatherAgent", + name="WeatherAgent", description="A helpful agent that provides weather information and forecasts", instructions=""" You are a weather assistant. You can provide current weather information @@ -153,7 +154,9 @@ agent = Agent( weather information when asked. """, client=FoundryChatClient( - api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""), + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), + model=os.environ.get("FOUNDRY_MODEL"), + credential=AzureCliCredential(), ), tools=[get_weather, get_forecast, send_email], middleware=[security_filter_middleware, atlantis_location_filter_middleware], @@ -164,7 +167,7 @@ register_cleanup(agent, cleanup_resources) def main(): - """Launch the Azure weather agent in DevUI.""" + """Launch the Weather Agent in DevUI.""" import logging from agent_framework.devui import serve @@ -173,9 +176,9 @@ def main(): logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger(__name__) - logger.info("Starting Azure Weather Agent") + logger.info("Starting Weather Agent") logger.info("Available at: http://localhost:8090") - logger.info("Entity ID: agent_AzureWeatherAgent") + logger.info("Entity ID: agent_WeatherAgent") # Launch server with the agent serve(entities=[agent], port=8090, auto_open=True) diff --git a/python/samples/02-agents/devui/azure_responses_agent/.env.example b/python/samples/02-agents/devui/azure_responses_agent/.env.example deleted file mode 100644 index 975324fa02..0000000000 --- a/python/samples/02-agents/devui/azure_responses_agent/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Azure OpenAI Responses API Configuration -# The Responses API supports PDF uploads, images, and other multimodal content. -# Requires api-version 2025-03-01-preview or later. - -# Option 1: Use API key authentication -AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here - -# Option 2: Use Azure CLI authentication (run 'az login' first) -# No API key needed - just leave AZURE_OPENAI_API_KEY unset - -# Required: Azure OpenAI endpoint with Responses API support -AZURE_OPENAI_ENDPOINT=https://your-resource.cognitiveservices.azure.com/ - -# Required: Deployment name (must support Responses API) -FOUNDRY_MODEL=gpt-4.1-mini diff --git a/python/samples/02-agents/devui/azure_responses_agent/__init__.py b/python/samples/02-agents/devui/azure_responses_agent/__init__.py deleted file mode 100644 index f72521a7af..0000000000 --- a/python/samples/02-agents/devui/azure_responses_agent/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Azure Responses Agent sample for DevUI.""" - -from .agent import agent - -__all__ = ["agent"] diff --git a/python/samples/02-agents/devui/azure_responses_agent/agent.py b/python/samples/02-agents/devui/azure_responses_agent/agent.py deleted file mode 100644 index bb6bda6cf6..0000000000 --- a/python/samples/02-agents/devui/azure_responses_agent/agent.py +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Sample agent using Azure OpenAI Responses API for Agent Framework DevUI. - -This agent uses the Responses API which supports: -- PDF file uploads -- Image uploads -- Audio inputs -- And other multimodal content - -The Chat Completions API (FoundryChatClient) does NOT support PDF uploads. -Use this agent when you need to process documents or other file types. - -Required environment variables: -- AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint -- FOUNDRY_MODEL: Deployment name for Responses API - (falls back to FOUNDRY_MODEL if not set) -- AZURE_OPENAI_API_KEY: Your API key (or use Azure CLI auth) -""" - -import logging -import os -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -logger = logging.getLogger(__name__) - -# Get deployment name - try responses-specific env var first, fall back to chat deployment -_deployment_name = os.environ.get( - "FOUNDRY_MODEL", - os.environ.get("FOUNDRY_MODEL", ""), -) - -# Get endpoint - try responses-specific env var first, fall back to default -_endpoint = os.environ.get( - "AZURE_OPENAI_RESPONSES_ENDPOINT", - os.environ.get("AZURE_OPENAI_ENDPOINT", ""), -) - - -def analyze_content( - query: Annotated[str, "What to analyze or extract from the uploaded content"], -) -> str: - """Analyze uploaded content based on the user's query. - - This is a placeholder - the actual analysis is done by the model - when processing the uploaded files. - """ - return f"Analyzing content for: {query}" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def summarize_document( - length: Annotated[str, "Desired summary length: 'brief', 'medium', or 'detailed'"] = "medium", -) -> str: - """Generate a summary of the uploaded document.""" - return f"Generating {length} summary of the document..." - - -@tool(approval_mode="never_require") -def extract_key_points( - max_points: Annotated[int, "Maximum number of key points to extract"] = 5, -) -> str: - """Extract key points from the uploaded document.""" - return f"Extracting up to {max_points} key points..." - - -# Agent using Azure OpenAI Responses API (supports PDF uploads!) -agent = Agent( - name="AzureResponsesAgent", - description="An agent that can analyze PDFs, images, and other documents using Azure OpenAI Responses API", - instructions=""" - You are a helpful document analysis assistant. You can: - - 1. Analyze uploaded PDF documents and extract information - 2. Summarize document contents - 3. Answer questions about uploaded files - 4. Extract key points and insights - - When a user uploads a file, carefully analyze its contents and provide - helpful, accurate information based on what you find. - - For PDFs, you can read and understand the text, tables, and structure. - For images, you can describe what you see and extract any text. - """, - client=FoundryChatClient( - model=_deployment_name, - endpoint=_endpoint, - api_version="2025-03-01-preview", # Required for Responses API - ), - tools=[summarize_document, extract_key_points], -) - - -def main(): - """Launch the Azure Responses agent in DevUI.""" - from agent_framework_devui import serve - - logging.basicConfig(level=logging.INFO, format="%(message)s") - - logger.info("=" * 60) - logger.info("Starting Azure Responses Agent") - logger.info("=" * 60) - logger.info("") - logger.info("This agent uses the Azure OpenAI Responses API which supports:") - logger.info(" - PDF file uploads") - logger.info(" - Image uploads") - logger.info(" - Audio inputs") - logger.info("") - logger.info("Try uploading a PDF and asking questions about it!") - logger.info("") - logger.info("Required environment variables:") - logger.info(" - AZURE_OPENAI_ENDPOINT") - logger.info(" - FOUNDRY_MODEL") - logger.info(" - AZURE_OPENAI_API_KEY (or use Azure CLI auth)") - logger.info("") - - serve(entities=[agent], port=8090, auto_open=True) - - -if __name__ == "__main__": - main() diff --git a/python/samples/02-agents/devui/foundry_agent/.env.example b/python/samples/02-agents/devui/foundry_agent/.env.example deleted file mode 100644 index bd24359800..0000000000 --- a/python/samples/02-agents/devui/foundry_agent/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Azure AI Foundry Configuration -# Get your credentials from Azure AI Foundry portal -# Make sure to run 'az login' before starting devui - -FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms -FOUNDRY_MODEL=gpt-4o diff --git a/python/samples/02-agents/devui/in_memory_mode.py b/python/samples/02-agents/devui/in_memory_mode.py index 9aba93b3e6..13c6c55f5d 100644 --- a/python/samples/02-agents/devui/in_memory_mode.py +++ b/python/samples/02-agents/devui/in_memory_mode.py @@ -20,6 +20,7 @@ from agent_framework import ( ) from agent_framework.devui import serve from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from typing_extensions import Never @@ -80,14 +81,13 @@ def main(): # Create Azure OpenAI chat client client = FoundryChatClient( - api_key=os.environ.get("AZURE_OPENAI_API_KEY"), model=os.environ["FOUNDRY_MODEL"], - endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), - api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21"), + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), + credential=AzureCliCredential(), ) # Create agents - weather_agent = Agent( + weather_assistant = Agent( name="weather-assistant", description="Provides weather information and time", instructions=( @@ -120,7 +120,7 @@ def main(): ) # Collect entities for serving - entities = [weather_agent, simple_agent, basic_workflow] + entities = [weather_assistant, simple_agent, basic_workflow] logger.info("Starting DevUI on http://localhost:8090") logger.info("Entities available:") diff --git a/python/samples/02-agents/devui/main.py b/python/samples/02-agents/devui/main.py new file mode 100644 index 0000000000..1280ad24d4 --- /dev/null +++ b/python/samples/02-agents/devui/main.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Launch DevUI with folder discovery for the samples in this directory. + +This sample demonstrates: +- Loading a shared root `.env` file for the DevUI samples folder +- Starting DevUI in directory discovery mode for this folder +- Using root-level settings as fallbacks for discovered samples +""" + +from pathlib import Path + +from agent_framework.devui import serve +from dotenv import load_dotenv + + +def main() -> None: + """Load the root .env file and launch DevUI with folder discovery.""" + samples_dir = Path(__file__).resolve().parent + + # 1. Load shared defaults for the samples in this folder. + load_dotenv(samples_dir / ".env") + + # 2. Start DevUI and discover entities from this directory. + serve(entities_dir=str(samples_dir), auto_open=True) + + +if __name__ == "__main__": + main() + +# Sample output: +# Starting Agent Framework DevUI on 127.0.0.1:8080 diff --git a/python/samples/02-agents/devui/weather_agent_azure/.env.example b/python/samples/02-agents/devui/weather_agent_azure/.env.example deleted file mode 100644 index 70817b460c..0000000000 --- a/python/samples/02-agents/devui/weather_agent_azure/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Azure OpenAI API Configuration -# Get your credentials from Azure Portal - -AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com diff --git a/python/samples/02-agents/devui/workflow_agents/.env.example b/python/samples/02-agents/devui/workflow_agents/.env.example deleted file mode 100644 index 1153ab182a..0000000000 --- a/python/samples/02-agents/devui/workflow_agents/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Azure OpenAI API Configuration -# Get your credentials from Azure Portal - -AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com -AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/declarative/__init__.py b/python/samples/02-agents/devui/workflow_declarative/__init__.py similarity index 100% rename from python/samples/02-agents/devui/declarative/__init__.py rename to python/samples/02-agents/devui/workflow_declarative/__init__.py diff --git a/python/samples/02-agents/devui/declarative/workflow.py b/python/samples/02-agents/devui/workflow_declarative/workflow.py similarity index 100% rename from python/samples/02-agents/devui/declarative/workflow.py rename to python/samples/02-agents/devui/workflow_declarative/workflow.py diff --git a/python/samples/02-agents/devui/declarative/workflow.yaml b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml similarity index 100% rename from python/samples/02-agents/devui/declarative/workflow.yaml rename to python/samples/02-agents/devui/workflow_declarative/workflow.yaml diff --git a/python/samples/02-agents/devui/fanout_workflow/__init__.py b/python/samples/02-agents/devui/workflow_fanout/__init__.py similarity index 100% rename from python/samples/02-agents/devui/fanout_workflow/__init__.py rename to python/samples/02-agents/devui/workflow_fanout/__init__.py diff --git a/python/samples/02-agents/devui/fanout_workflow/workflow.py b/python/samples/02-agents/devui/workflow_fanout/workflow.py similarity index 100% rename from python/samples/02-agents/devui/fanout_workflow/workflow.py rename to python/samples/02-agents/devui/workflow_fanout/workflow.py diff --git a/python/samples/02-agents/devui/spam_workflow/__init__.py b/python/samples/02-agents/devui/workflow_spam/__init__.py similarity index 100% rename from python/samples/02-agents/devui/spam_workflow/__init__.py rename to python/samples/02-agents/devui/workflow_spam/__init__.py diff --git a/python/samples/02-agents/devui/spam_workflow/workflow.py b/python/samples/02-agents/devui/workflow_spam/workflow.py similarity index 100% rename from python/samples/02-agents/devui/spam_workflow/workflow.py rename to python/samples/02-agents/devui/workflow_spam/workflow.py diff --git a/python/samples/02-agents/devui/workflow_with_agents/.env.example b/python/samples/02-agents/devui/workflow_with_agents/.env.example new file mode 100644 index 0000000000..520cd181cc --- /dev/null +++ b/python/samples/02-agents/devui/workflow_with_agents/.env.example @@ -0,0 +1,9 @@ +# Azure OpenAI configuration for the Responses-based workflow sample +# This sample uses Azure CLI auth, so run `az login` before starting DevUI. + +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o +# Optional fallback env name also supported by the client: +# AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +# Optional if you need to override the default API version: +AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/workflow_agents/__init__.py b/python/samples/02-agents/devui/workflow_with_agents/__init__.py similarity index 100% rename from python/samples/02-agents/devui/workflow_agents/__init__.py rename to python/samples/02-agents/devui/workflow_with_agents/__init__.py diff --git a/python/samples/02-agents/devui/workflow_agents/workflow.py b/python/samples/02-agents/devui/workflow_with_agents/workflow.py similarity index 93% rename from python/samples/02-agents/devui/workflow_agents/workflow.py rename to python/samples/02-agents/devui/workflow_with_agents/workflow.py index 750b8ac45f..cda57a67f7 100644 --- a/python/samples/02-agents/devui/workflow_agents/workflow.py +++ b/python/samples/02-agents/devui/workflow_with_agents/workflow.py @@ -18,7 +18,8 @@ import os from typing import Any from agent_framework import Agent, AgentExecutorResponse, WorkflowBuilder -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -62,8 +63,13 @@ def is_approved(message: Any) -> bool: return True -# Create Azure OpenAI chat client -client = FoundryChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", "")) +# Create Azure OpenAI Responses chat client +client = OpenAIChatClient( + model=os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), + api_version=os.environ.get("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), +) # Create Writer agent - generates content writer = Agent( diff --git a/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py b/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py index c219166fc5..ca753f2d42 100644 --- a/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py +++ b/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py @@ -48,9 +48,7 @@ async def main() -> None: project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") model = os.getenv("FOUNDRY_MODEL") if not project_endpoint or not model: - raise ValueError( - "FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL must be set" - ) + raise ValueError("FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL must be set") print(f"Connecting to A2A agent at: {a2a_agent_host}") diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py index 4b5d892fe4..50b084606b 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py @@ -48,8 +48,7 @@ async def main() -> None: client=chat_client, name="travel-assistant", instructions=( - "You are a helpful travel assistant. " - "Use your tools to answer questions about weather and flights." + "You are a helpful travel assistant. Use your tools to answer questions about weather and flights." ), tools=[get_weather, get_flight_price], ) From 25696a72dca5f1c07917076b5ad2d0520f89b3bb Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:18:33 +0100 Subject: [PATCH 11/20] .NET: Replace Azure Foundry/Azure AI Foundry with Microsoft Foundry in .NET samples (#5032) * Replace Azure Foundry/Azure AI Foundry with Microsoft Foundry in samples Update all .cs, .md, and .yaml files in dotnet/samples/ to use 'Microsoft Foundry' instead of 'Azure Foundry' and 'Azure AI Foundry'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/05-end-to-end/A2AClientServer/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix grammar: 'an Microsoft' -> 'a Microsoft', 'agents ids' -> 'agent IDs' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Agent_With_Anthropic/README.md | 18 +++++++++--------- .../Program.cs | 2 +- .../README.md | 6 +++--- .../Agent_With_AzureAIProject/Program.cs | 4 ++-- .../Agent_With_AzureAIProject/README.md | 6 +++--- .../Agent_With_AzureFoundryModel/Program.cs | 6 +++--- .../Agent_With_AzureFoundryModel/README.md | 14 +++++++------- .../samples/02-agents/AgentProviders/README.md | 2 +- .../02-agents/AgentWithAnthropic/README.md | 4 ++-- .../Program.cs | 6 +++--- .../README.md | 12 ++++++------ .../02-agents/AgentWithMemory/README.md | 4 ++-- .../README.md | 2 +- .../Agent_Step02_StructuredOutput/README.md | 2 +- .../Agents/Agent_Step07_AsMcpTool/README.md | 4 ++-- .../Agents/Agent_Step11_Middleware/Program.cs | 2 +- .../Agent_Step13_ChatReduction/Program.cs | 2 +- .../Agent_Step15_DeepResearch/Program.cs | 2 +- .../Agents/Agent_Step15_DeepResearch/README.md | 8 ++++---- dotnet/samples/02-agents/Agents/README.md | 2 +- .../Program.cs | 2 +- .../02-agents/AgentsWithFoundry/README.md | 2 +- .../FoundryAgent_Hosted_MCP/Program.cs | 4 ++-- .../FoundryAgent_Hosted_MCP/README.md | 6 +++--- .../02-agents/ModelContextProtocol/README.md | 2 +- .../Agents/FoundryAgent/Program.cs | 4 ++-- .../Declarative/InvokeMcpTool/Program.cs | 2 +- .../samples/03-workflows/Declarative/README.md | 12 ++++++------ dotnet/samples/03-workflows/README.md | 2 +- .../05-end-to-end/A2AClientServer/README.md | 2 +- .../AgentWithLocalTools/Program.cs | 2 +- .../HostedAgents/AgentWithLocalTools/README.md | 6 +++--- .../HostedAgents/FoundryMultiAgent/Program.cs | 2 +- .../HostedAgents/FoundryMultiAgent/README.md | 2 +- .../HostedAgents/FoundryMultiAgent/agent.yaml | 2 +- .../HostedAgents/FoundrySingleAgent/Program.cs | 2 +- .../HostedAgents/FoundrySingleAgent/README.md | 4 ++-- .../05-end-to-end/HostedAgents/README.md | 10 +++++----- dotnet/samples/AGENTS.md | 2 +- 39 files changed, 89 insertions(+), 89 deletions(-) diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md index c1a569874b..3be31187e0 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_Anthropic/README.md @@ -5,8 +5,8 @@ This sample demonstrates how to create an AIAgent using Anthropic Claude models The sample supports three deployment scenarios: 1. **Anthropic Public API** - Direct connection to Anthropic's public API -2. **Azure Foundry with API Key** - Anthropic models deployed through Azure Foundry using API key authentication -3. **Azure Foundry with Azure CLI** - Anthropic models deployed through Azure Foundry using Azure CLI credentials +2. **Microsoft Foundry with API Key** - Anthropic models deployed through Microsoft Foundry using API key authentication +3. **Microsoft Foundry with Azure CLI** - Anthropic models deployed through Microsoft Foundry using Azure CLI credentials ## Prerequisites @@ -25,29 +25,29 @@ $env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic A $env:ANTHROPIC_CHAT_MODEL_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 ``` -### For Azure Foundry with API Key +### For Microsoft Foundry with API Key -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Anthropic API key Set the following environment variables: ```powershell -$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Azure Foundry resource name (subdomain before .services.ai.azure.com) +$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Microsoft Foundry resource name (subdomain before .services.ai.azure.com) $env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key $env:ANTHROPIC_CHAT_MODEL_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 ``` -### For Azure Foundry with Azure CLI +### For Microsoft Foundry with Azure CLI -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) Set the following environment variables: ```powershell -$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Azure Foundry resource name (subdomain before .services.ai.azure.com) +$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Microsoft Foundry resource name (subdomain before .services.ai.azure.com) $env:ANTHROPIC_CHAT_MODEL_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 ``` -**Note**: When using Azure Foundry with Azure CLI, make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: When using Microsoft Foundry with Azure CLI, make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs index 0603933dbf..a4b702be2c 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs @@ -2,7 +2,7 @@ #pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend. +// This sample shows how to create and use a simple AI agent with Microsoft Foundry Agents as the backend. using Azure.AI.Agents.Persistent; using Azure.Identity; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md index 969795d87f..7c92d809d3 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md @@ -13,14 +13,14 @@ Below is a comparison between the classic and new Foundry Agents approaches: Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index b2cd14f68a..8265623904 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a AI agents with Azure Foundry Agents as the backend. +// This sample shows how to create and use AI agents with Microsoft Foundry Agents as the backend. using Azure.AI.Projects; using Azure.AI.Projects.Agents; @@ -13,7 +13,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME const string JokerName = "JokerAgent"; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// Get a client to create/retrieve/delete server side agents with Microsoft Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md index 66fcbf8297..ec688df59d 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md @@ -13,14 +13,14 @@ Below is a comparison between the classic and new Foundry Agents approaches: Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs index fe682d388a..556b52bf17 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Azure AI Foundry. -// You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in your Azure AI Foundry resource. +// This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Microsoft Foundry. +// You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in your Microsoft Foundry resource. // Note: Ensure that you pick a model that suits your needs. For example, if you want to use function calling, ensure that the model you pick supports function calling. using System.ClientModel; @@ -15,7 +15,7 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "Phi-4-mini-instruct"; -// Since we are using the OpenAI Client SDK, we need to override the default endpoint to point to Azure Foundry. +// Since we are using the OpenAI Client SDK, we need to override the default endpoint to point to Microsoft Foundry. var clientOptions = new OpenAIClientOptions() { Endpoint = new Uri(endpoint) }; // Create the OpenAI client with either an API key or Azure CLI credential. diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md index 6d5b6badd7..9bc4d60881 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md @@ -1,8 +1,8 @@ ## Overview -This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Azure AI Foundry. +This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Microsoft Foundry. -You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in Azure AI Foundry. +You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI or any other model you have deployed in Microsoft Foundry. **Note**: Ensure that you pick a model that suits your needs. For example, if you want to use function calling, ensure that the model you pick supports function calling. @@ -11,19 +11,19 @@ You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI o Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure AI Foundry resource -- A model deployment in your Azure AI Foundry resource. This example defaults to using the `Phi-4-mini-instruct` model, +- Microsoft Foundry resource +- A model deployment in your Microsoft Foundry resource. This example defaults to using the `Phi-4-mini-instruct` model, so if you want to use a different model, ensure that you set your `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable to the name of your deployed model. -- An API key or role based authentication to access the Azure AI Foundry resource +- An API key or role based authentication to access the Microsoft Foundry resource See [here](https://learn.microsoft.com/en-us/azure/ai-foundry/quickstarts/get-started-code?tabs=csharp) for more info on setting up these prerequisites Set the following environment variables: ```powershell -# Replace with your Azure AI Foundry resource endpoint -# Ensure that you have the "/openai/v1/" path in the URL, since this is required when using the OpenAI SDK to access Azure Foundry models. +# Replace with your Microsoft Foundry resource endpoint +# Ensure that you have the "/openai/v1/" path in the URL, since this is required when using the OpenAI SDK to access Microsoft Foundry models. $env:AZURE_OPENAI_ENDPOINT="https://ai-foundry-.services.ai.azure.com/openai/v1/" # Optional, defaults to using Azure CLI for authentication if not provided diff --git a/dotnet/samples/02-agents/AgentProviders/README.md b/dotnet/samples/02-agents/AgentProviders/README.md index 071722d50d..ee83f8c08c 100644 --- a/dotnet/samples/02-agents/AgentProviders/README.md +++ b/dotnet/samples/02-agents/AgentProviders/README.md @@ -18,7 +18,7 @@ See the README.md for each sample for the prerequisites for that sample. |[Creating an AIAgent with Anthropic](./Agent_With_Anthropic/)|This sample demonstrates how to create an AIAgent using Anthropic Claude models as the underlying inference service| |[Creating an AIAgent with Foundry Agents using Azure.AI.Agents.Persistent](./Agent_With_AzureAIAgentsPersistent/)|This sample demonstrates how to create a Foundry Persistent agent and expose it as an AIAgent using the Azure.AI.Agents.Persistent SDK| |[Creating an AIAgent with Foundry Agents using Azure.AI.Project](./Agent_With_AzureAIProject/)|This sample demonstrates how to create an Foundry Project agent and expose it as an AIAgent using the Azure.AI.Project SDK| -|[Creating an AIAgent with AzureFoundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Azure Foundry to create an AIAgent| +|[Creating an AIAgent with Foundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Microsoft Foundry to create an AIAgent| |[Creating an AIAgent with Azure OpenAI ChatCompletion](./Agent_With_AzureOpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using Azure OpenAI ChatCompletion as the underlying inference service| |[Creating an AIAgent with Azure OpenAI Responses](./Agent_With_AzureOpenAIResponses/)|This sample demonstrates how to create an AIAgent using Azure OpenAI Responses as the underlying inference service| |[Creating an AIAgent with a custom implementation](./Agent_With_CustomImplementation/)|This sample demonstrates how to create an AIAgent with a custom implementation| diff --git a/dotnet/samples/02-agents/AgentWithAnthropic/README.md b/dotnet/samples/02-agents/AgentWithAnthropic/README.md index 345c25142f..c2de7425d2 100644 --- a/dotnet/samples/02-agents/AgentWithAnthropic/README.md +++ b/dotnet/samples/02-agents/AgentWithAnthropic/README.md @@ -18,9 +18,9 @@ Before you begin, ensure you have the following prerequisites: **Note**: These samples use Anthropic Claude models. For more information, see [Anthropic documentation](https://docs.anthropic.com/). -## Using Anthropic with Azure Foundry +## Using Anthropic with Microsoft Foundry -To use Anthropic with Azure Foundry, you can check the sample [AgentProviders/Agent_With_Anthropic](../AgentProviders/Agent_With_Anthropic/README.md) for more details. +To use Anthropic with Microsoft Foundry, you can check the sample [AgentProviders/Agent_With_Anthropic](../AgentProviders/Agent_With_Anthropic/README.md) for more details. ## Samples diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index d6410d3308..9a8c643bb9 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. // This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent. -// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant +// The sample stores conversation messages in a Microsoft Foundry memory store and retrieves relevant // memories for subsequent invocations, even across new sessions. // -// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates +// Note: Memory extraction in Microsoft Foundry is asynchronous and takes time. This sample demonstrates // a simple polling approach to wait for memory updates to complete before querying. using System.Text.Json; @@ -62,7 +62,7 @@ await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session)); Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session)); -// Memory extraction in Azure AI Foundry is asynchronous and takes time to process. +// Memory extraction in Microsoft Foundry is asynchronous and takes time to process. // WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete. Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); await memoryProvider.WhenUpdatesCompletedAsync(); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md index bcc70b0103..b2d52e9837 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -1,6 +1,6 @@ -# Agent with Memory Using Azure AI Foundry +# Agent with Memory Using Microsoft Foundry -This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions. +This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories across sessions. ## Features Demonstrated @@ -13,7 +13,7 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## Prerequisites -1. Azure subscription with Azure AI Foundry project +1. Azure subscription with Microsoft Foundry project 2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002) 3. .NET 10.0 SDK 4. Azure CLI logged in (`az login`) @@ -21,7 +21,7 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## Environment Variables ```bash -# Azure AI Foundry project endpoint and memory store name +# Microsoft Foundry project endpoint and memory store name export AZURE_AI_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" export AZURE_AI_MEMORY_STORE_ID="my_memory_store" @@ -48,10 +48,10 @@ The agent will: ## Key Differences from Mem0 -| Aspect | Mem0 | Azure AI Foundry Memory | +| Aspect | Mem0 | Microsoft Foundry Memory | |--------|------|------------------------| | Authentication | API Key | Azure Identity (DefaultAzureCredential) | | Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string | | Memory Types | Single memory store | User Profile + Chat Summary | -| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service | +| Hosting | Mem0 cloud or self-hosted | Microsoft Foundry managed service | | Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` | diff --git a/dotnet/samples/02-agents/AgentWithMemory/README.md b/dotnet/samples/02-agents/AgentWithMemory/README.md index aa95012f68..c7f4510504 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/README.md @@ -7,7 +7,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem |[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.| |[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.| |[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.| -|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.| +|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.| |[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.| -> **See also**: [Memory Search with Foundry Agents](../AgentsWithFoundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry agents. +> **See also**: [Memory Search with Foundry Agents](../AgentsWithFoundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents. diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md index 131adde82b..ec69136e14 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md @@ -13,7 +13,7 @@ This sample uses Qdrant for the vector store, but this can easily be swapped out - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. - An existing Qdrant instance. You can use a managed service or run a local instance using Docker, but the sample assumes the instance is running locally. -**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md index 5652fe9b0a..9b9411f17e 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md @@ -18,7 +18,7 @@ Before you begin, ensure you have the following prerequisites: - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource -**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md index e35cf01e90..2feaee22e2 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md @@ -20,8 +20,8 @@ To use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) MCP Inspector is up and running at http://127.0.0.1:6274 ``` 1. Open a web browser and navigate to the URL displayed in the terminal. If not opened automatically, this will open the MCP Inspector interface. -1. In the MCP Inspector interface, add the following environment variables to allow your MCP server to access Azure AI Foundry Project to create and run the agent: - - AZURE_AI_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Azure AI Foundry Project endpoint +1. In the MCP Inspector interface, add the following environment variables to allow your MCP server to access Microsoft Foundry Project to create and run the agent: + - AZURE_AI_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Microsoft Foundry Project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME = gpt-4o-mini # Replace with your model deployment name 1. Find and click the `Connect` button in the MCP Inspector interface to connect to the MCP server. 1. As soon as the connection is established, open the `Tools` tab in the MCP Inspector interface and select the `Joker` tool from the list. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs index bab09bc886..f7f3602fd3 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs @@ -13,7 +13,7 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -// Get Azure AI Foundry configuration from environment variables +// Get Microsoft Foundry configuration from environment variables var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs index fe93ed785c..915bc5c376 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use a chat history reducer to keep the context within model size limits. // Any implementation of Microsoft.Extensions.AI.IChatReducer can be used to customize how the chat history is reduced. // NOTE: this feature is only supported where the chat history is stored locally, such as with OpenAI Chat Completion. -// Where the chat history is stored server side, such as with Azure Foundry Agents, the service must manage the chat history size. +// Where the chat history is stored server side, such as with Microsoft Foundry Agents, the service must manage the chat history size. using Azure.AI.OpenAI; using Azure.Identity; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs index 11d3f561f4..d329816f13 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -2,7 +2,7 @@ #pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions -// This sample shows how to create an Azure AI Foundry Agent with the Deep Research Tool. +// This sample shows how to create a Microsoft Foundry Agent with the Deep Research Tool. using Azure.AI.Agents.Persistent; using Azure.Identity; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md index 1848b10826..b04e65cafc 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md @@ -11,10 +11,10 @@ Key features: Before running this sample, ensure you have: -1. An Azure AI Foundry project set up +1. A Microsoft Foundry project set up 2. A deep research model deployment (e.g., o3-deep-research) 3. A model deployment (e.g., gpt-4o) -4. A Bing Connection configured in your Azure AI Foundry project +4. A Bing Connection configured in your Microsoft Foundry project 5. Azure CLI installed and authenticated **Important**: Please visit the following documentation for detailed setup instructions: @@ -29,14 +29,14 @@ Pay special attention to the purple `Note` boxes in the Azure documentation. /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ ``` -You can find this in the Azure AI Foundry portal under **Management > Connected resources**, or retrieve it programmatically via the connections API (`.id` property). +You can find this in the Microsoft Foundry portal under **Management > Connected resources**, or retrieve it programmatically via the connections API (`.id` property). ## Environment Variables Set the following environment variables: ```powershell -# Replace with your Azure AI Foundry project endpoint +# Replace with your Microsoft Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" # Replace with your Bing Grounding connection ID (full ARM resource URI) diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index c5258ba9f4..6cdf80625b 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -18,7 +18,7 @@ Before you begin, ensure you have the following prerequisites: - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. -**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs index c6b2d5c764..691af1fc16 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // This sample shows how to create, use, and clean up a FoundryAgent backed by a server-side -// versioned agent in Azure AI Foundry. It demonstrates the full lifecycle: +// versioned agent in Microsoft Foundry. It demonstrates the full lifecycle: // create agent version -> wrap as FoundryAgent -> run -> delete. using Azure.AI.Projects; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md index b5802053b3..8cc964e227 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -1,6 +1,6 @@ # Getting started with Foundry Agents -These samples demonstrate how to use Azure AI Foundry with Agent Framework. +These samples demonstrate how to use Microsoft Foundry with Agent Framework. ## Quick start diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index bedf87b8c5..ffd576d273 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend, that uses a Hosted MCP Tool. -// In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. +// This sample shows how to create and use a simple AI agent with Microsoft Foundry Agents as the backend, that uses a Hosted MCP Tool. +// In this case the Microsoft Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. // The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool. using Azure.AI.Projects; diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md index a172ec63cf..d03833b826 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md @@ -3,14 +3,14 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured +- Microsoft Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini ``` diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index be1aa83513..9cbaecad4b 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/README.md @@ -11,7 +11,7 @@ Before you begin, ensure you have the following prerequisites: - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. -**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). **Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index 5b1f59ac62..e4fb1378b1 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -11,12 +11,12 @@ using Microsoft.Extensions.AI; namespace WorkflowFoundryAgentSample; /// -/// This sample shows how to use Azure Foundry Agents within a workflow. +/// This sample shows how to use Microsoft Foundry Agents within a workflow. /// /// /// Pre-requisites: /// - Foundational samples should be completed first. -/// - An Azure Foundry project endpoint and model id. +/// - A Microsoft Foundry project endpoint and model ID. /// public static class Program { diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs index 61ce1afc70..7da862df92 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs @@ -30,7 +30,7 @@ namespace Demo.Workflows.Declarative.InvokeMcpTool; /// Integrating with MCP-compatible services /// /// -/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details. +/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Microsoft Foundry MCP server to get AI model details. /// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input, /// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results. /// diff --git a/dotnet/samples/03-workflows/Declarative/README.md b/dotnet/samples/03-workflows/Declarative/README.md index 2ad3e59c0d..6bd2c85824 100644 --- a/dotnet/samples/03-workflows/Declarative/README.md +++ b/dotnet/samples/03-workflows/Declarative/README.md @@ -6,7 +6,7 @@ to build a `Workflow` that may be executed using the same pattern as any code-ba ## Configuration These samples must be configured to create and use agents your -[Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). +[Microsoft Foundry Project](https://learn.microsoft.com/azure/ai-foundry). ### Settings @@ -18,9 +18,9 @@ The configuraton required by the samples is: |Setting Name| Description| |:--|:--| -|AZURE_AI_PROJECT_ENDPOINT| The endpoint URL of your Azure Foundry Project.| +|AZURE_AI_PROJECT_ENDPOINT| The endpoint URL of your Microsoft Foundry Project.| |AZURE_AI_MODEL_DEPLOYMENT_NAME| The name of the model deployment to use -|AZURE_AI_BING_CONNECTION_ID| The name of the Bing Grounding connection configured in your Azure Foundry Project.| +|AZURE_AI_BING_CONNECTION_ID| The name of the Bing Grounding connection configured in your Microsoft Foundry Project.| To set your secrets with .NET Secret Manager: @@ -42,13 +42,13 @@ To set your secrets with .NET Secret Manager: dotnet user-secrets init ``` -4. Define setting that identifies your Azure Foundry Project (endpoint): +4. Define setting that identifies your Microsoft Foundry Project (endpoint): ``` dotnet user-secrets set "AZURE_AI_PROJECT_ENDPOINT" "https://..." ``` -5. Define setting that identifies your Azure Foundry Model Deployment (endpoint): +5. Define setting that identifies your Microsoft Foundry Model Deployment (endpoint): ``` dotnet user-secrets set "AZURE_AI_MODEL_DEPLOYMENT_NAME" "gpt-5" @@ -70,7 +70,7 @@ $env:AZURE_AI_BING_CONNECTION_ID="mybinggrounding" ### Authorization -Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: +Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Microsoft Foundry Project: ``` az login diff --git a/dotnet/samples/03-workflows/README.md b/dotnet/samples/03-workflows/README.md index 1ab52106ec..d17148d60d 100644 --- a/dotnet/samples/03-workflows/README.md +++ b/dotnet/samples/03-workflows/README.md @@ -26,7 +26,7 @@ Once completed, please proceed to the other samples listed below. | Sample | Concepts | |--------|----------| -| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure Foundry agents in a workflow through `ChatClientAgent` | +| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Microsoft Foundry agents in a workflow through `ChatClientAgent` | | [Custom Agent Executors](./Agents/CustomAgentExecutors) | Shows how to create a custom agent executor for more complex scenarios | | [Workflow as an Agent](./Agents/WorkflowAsAnAgent) | Illustrates how to encapsulate a workflow as an agent | | [Group Chat with Tool Approval](./Agents/GroupChatToolApproval) | Shows multi-agent group chat with tool approval requests and human-in-the-loop interaction | diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/README.md b/dotnet/samples/05-end-to-end/A2AClientServer/README.md index cff5b40e2d..1f49d56f80 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/README.md +++ b/dotnet/samples/05-end-to-end/A2AClientServer/README.md @@ -51,7 +51,7 @@ dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentType "lo ### Configuring for use with Azure AI Agents -You must create the agents in an Azure AI Foundry project and then provide the project endpoint and agents ids. The instructions for each agent are as follows: +You must create the agents in a Microsoft Foundry project and then provide the project endpoint and agent IDs. The instructions for each agent are as follows: - Invoice Agent ``` diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs index 78a0aa62e9..329ad74ea2 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -// Uses Microsoft Agent Framework with Azure AI Foundry. +// Uses Microsoft Agent Framework with Microsoft Foundry. // Ready for deployment to Foundry Hosted Agent service. using System.ClientModel.Primitives; diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md index c080331a87..58ea174718 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md @@ -4,7 +4,7 @@ This sample demonstrates how to build a hosted agent that uses local C# function Key features: - Defining local C# functions as agent tools using `AIFunctionFactory` -- Using `AIProjectClient` to discover the OpenAI connection from the Azure AI Foundry project +- Using `AIProjectClient` to discover the OpenAI connection from the Microsoft Foundry project - Building a `ChatClientAgent` with custom instructions and tools - Deploying to the Foundry Hosted Agent service @@ -15,7 +15,7 @@ Key features: Before running this sample, ensure you have: 1. .NET 10 SDK installed -2. An Azure AI Foundry Project with a chat model deployed (e.g., gpt-4o-mini) +2. A Microsoft Foundry Project with a chat model deployed (e.g., gpt-4o-mini) 3. Azure CLI installed and authenticated (`az login`) ## Environment Variables @@ -23,7 +23,7 @@ Before running this sample, ensure you have: Set the following environment variables: ```powershell -# Replace with your Azure AI Foundry project endpoint +# Replace with your Microsoft Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" # Optional, defaults to gpt-4o-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs index 8d21eef20a..ca36341b97 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates a multi-agent workflow with Writer and Reviewer agents -// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder. +// using Microsoft Foundry AIProjectClient and the Agent Framework WorkflowBuilder. #pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md index 314320880b..4f589be86e 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md @@ -42,7 +42,7 @@ which provisions a REST API endpoint compatible with the OpenAI Responses protoc Before running this sample, ensure you have: -1. **Azure AI Foundry Project** +1. **Microsoft Foundry Project** - Project created. - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) - Note your project endpoint URL and model deployment name diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml index 70b82abf7c..672444cdd1 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml @@ -4,7 +4,7 @@ name: FoundryMultiAgent displayName: "Foundry Multi-Agent Workflow" description: > A multi-agent workflow featuring a Writer and Reviewer that collaborate - to create and refine content using Azure AI Foundry PersistentAgentsClient. + to create and refine content using Microsoft Foundry PersistentAgentsClient. metadata: authors: - Microsoft Agent Framework Team diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs index 80edf42089..5173578ee8 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -// Uses Microsoft Agent Framework with Azure AI Foundry. +// Uses Microsoft Agent Framework with Microsoft Foundry. // Ready for deployment to Foundry Hosted Agent service. #pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md index 31f3fc1a9d..e9dbced0a7 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md @@ -39,7 +39,7 @@ which provisions a REST API endpoint compatible with the OpenAI Responses protoc Before running this sample, ensure you have: -1. **Azure AI Foundry Project** +1. **Microsoft Foundry Project** - Project created. - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) - Note your project endpoint URL and model deployment name @@ -57,7 +57,7 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): -- `AZURE_AI_PROJECT_ENDPOINT` - Your Azure AI Foundry project endpoint URL (required) +- `AZURE_AI_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) - `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4o-mini`) **PowerShell:** diff --git a/dotnet/samples/05-end-to-end/HostedAgents/README.md b/dotnet/samples/05-end-to-end/HostedAgents/README.md index 919aa4b580..2042212a2e 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/README.md @@ -20,7 +20,7 @@ Before running any sample, ensure you have: 1. **.NET 10 SDK** or later — [Download](https://dotnet.microsoft.com/download/dotnet/10.0) 2. **Azure CLI** installed — [Install guide](https://learn.microsoft.com/cli/azure/install-azure-cli) -3. **Azure OpenAI** or **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`) +3. **Azure OpenAI** or **Microsoft Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`) ### Authenticate with Azure CLI @@ -39,14 +39,14 @@ Most samples require one or more of these environment variables: |----------|---------|-------------| | `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL | | `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) | -| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Azure AI Foundry project endpoint | +| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Microsoft Foundry project endpoint | | `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-4o-mini`) | See each sample's README for the specific variables required. -## Azure AI Foundry Setup (for samples that use Foundry) +## Microsoft Foundry Setup (for samples that use Foundry) -Some samples (`AgentWithLocalTools`, `FoundrySingleAgent`, `FoundryMultiAgent`) connect to an Azure AI Foundry project. If you're using these samples, you'll need additional setup. +Some samples (`AgentWithLocalTools`, `FoundrySingleAgent`, `FoundryMultiAgent`) connect to a Microsoft Foundry project. If you're using these samples, you'll need additional setup. ### Azure AI Developer Role @@ -61,7 +61,7 @@ az role assignment create ` > **Note**: You need **Owner** or **User Access Administrator** permissions on the resource to assign roles. If you don't have this, you may need to request JIT (Just-In-Time) elevated access via [Azure PIM](https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadmigratedresource). -For more details on permissions, see [Azure AI Foundry Permissions](https://aka.ms/FoundryPermissions). +For more details on permissions, see [Microsoft Foundry Permissions](https://aka.ms/FoundryPermissions). ## Running a Sample diff --git a/dotnet/samples/AGENTS.md b/dotnet/samples/AGENTS.md index f515f531eb..7208facf36 100644 --- a/dotnet/samples/AGENTS.md +++ b/dotnet/samples/AGENTS.md @@ -28,7 +28,7 @@ dotnet/samples/ │ ├── AGUI/ # AG-UI protocol samples │ ├── DeclarativeAgents/ # Declarative agent definitions │ ├── DevUI/ # DevUI samples -│ ├── AgentsWithFoundry/ # Azure AI Foundry samples (FoundryAgent + AsAIAgent extensions) +│ ├── AgentsWithFoundry/ # Microsoft Foundry samples (FoundryAgent + AsAIAgent extensions) │ └── ModelContextProtocol/ # MCP server/client patterns ├── 03-workflows/ # Workflow patterns │ ├── _StartHere/ # Introductory workflow samples From 38de9914815833f7e06644c516dfef53853b3785 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:38:48 -0700 Subject: [PATCH 12/20] .NET: Fix RequestInfoEvent lost when resuming workflow from checkpoint (#4955) * Fix RequestInfoEvent lost when resuming workflow from checkpoint * Fix streaming run double disposal in tests and lockstep republishing before Started event is emitted. * Fix bug to remove messages after sending to avoid losing messages on send failure. * Fix declarative test harness --- .../Checkpointing/ICheckpointingHandle.cs | 9 + .../Execution/AsyncRunHandle.cs | 19 +- .../Execution/ISuperStepRunner.cs | 8 + .../Execution/LockstepRunEventStream.cs | 95 +++- .../Execution/StreamingRunEventStream.cs | 4 + .../InProc/InProcessExecutionEnvironment.cs | 33 +- .../InProc/InProcessRunner.cs | 76 ++- .../Specialized/RequestInfoExecutor.cs | 45 +- .../Specialized/WorkflowHostExecutor.cs | 23 + .../WorkflowSession.cs | 43 +- .../Framework/WorkflowHarness.cs | 7 + .../CheckpointResumeTests.cs | 445 ++++++++++++++++++ 12 files changed, 748 insertions(+), 59 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs index 74ccd8edc3..f4e159a487 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointingHandle.cs @@ -21,6 +21,15 @@ internal interface ICheckpointingHandle /// /// Restores the system state from the specified checkpoint asynchronously. /// + /// + /// This contract is used by live runtime restore paths. Implementations may re-emit pending + /// external request events as part of the restore once the active event stream is ready to + /// observe them. + /// + /// Initial resume paths that create a new event stream should restore state first and defer + /// any replay until after the subscriber is attached, rather than calling this contract + /// directly before the stream is ready. + /// /// The checkpoint information that identifies the state to restore. Cannot be null. /// A cancellation token that can be used to cancel the restore operation. /// A that represents the asynchronous restore operation. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs index bda7e61a38..16cd61f6e1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs @@ -36,9 +36,10 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable this._eventStream.Start(); - // If there are already unprocessed messages (e.g., from a checkpoint restore that happened - // before this handle was created), signal the run loop to start processing them - if (stepRunner.HasUnprocessedMessages) + // If there are already unprocessed messages or unserviced requests (e.g., from a + // checkpoint restore that happened before this handle was created), signal the run + // loop to start processing them + if (stepRunner.HasUnprocessedMessages || stepRunner.HasUnservicedRequests) { this.SignalInputToRunLoop(); } @@ -192,13 +193,17 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable { streamingEventStream.ClearBufferedEvents(); } + else if (this._eventStream is LockstepRunEventStream lockstepEventStream) + { + lockstepEventStream.ClearBufferedEvents(); + } - // Restore the workflow state - this will republish unserviced requests as new events + // Restore the workflow state through the live runtime-restore path. + // This can re-emit pending requests into the already-active event stream. await this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken).ConfigureAwait(false); - // After restore, signal the run loop to process any restored messages - // This is necessary because ClearBufferedEvents() doesn't signal, and the restored - // queued messages won't automatically wake up the run loop + // After restore, signal the run loop to process any restored messages. Initial resume + // paths handle this separately when they create the event stream after restoring state. this.SignalInputToRunLoop(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index 8de0dbd5e2..ea53526604 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -27,6 +27,14 @@ internal interface ISuperStepRunner ConcurrentEventSink OutgoingEvents { get; } + /// + /// Re-emits s for any pending external requests. + /// Called by event streams after subscribing to so that + /// requests restored from a checkpoint are observable even when the restore happened + /// before the subscription was active. + /// + ValueTask RepublishPendingEventsAsync(CancellationToken cancellationToken = default); + ValueTask RunSuperStepAsync(CancellationToken cancellationToken); // This cannot be cancelled diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index 72e96efb10..cdd8cc7686 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -15,6 +15,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream { private readonly CancellationTokenSource _stopCancellation = new(); private readonly InputWaiter _inputWaiter = new(); + private ConcurrentQueue _eventSink = new(); private int _isDisposed; private readonly ISuperStepRunner _stepRunner; @@ -35,6 +36,8 @@ internal sealed class LockstepRunEventStream : IRunEventStream // doesn't leak into caller code via AsyncLocal. Activity? previousActivity = Activity.Current; + this._stepRunner.OutgoingEvents.EventRaised += this.OnWorkflowEventAsync; + this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity(); this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId) .SetTag(Tags.SessionId, this._stepRunner.SessionId); @@ -56,10 +59,6 @@ internal sealed class LockstepRunEventStream : IRunEventStream using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken); - ConcurrentQueue eventSink = []; - - this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync; - // Re-establish session as parent so the run activity nests correctly. Activity.Current = this._sessionActivity; @@ -73,7 +72,31 @@ internal sealed class LockstepRunEventStream : IRunEventStream runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); // Emit WorkflowStartedEvent to the event stream for consumers - eventSink.Enqueue(new WorkflowStartedEvent()); + this._eventSink.Enqueue(new WorkflowStartedEvent()); + + // Re-emit any pending external requests that were restored from a checkpoint + // before this subscription was active. For non-resume starts this is a no-op. + // This runs after WorkflowStartedEvent so consumers always see the started event first. + await this._stepRunner.RepublishPendingEventsAsync(linkedSource.Token).ConfigureAwait(false); + + // When resuming from a checkpoint with only pending requests (no queued messages), + // the inner processing loop won't execute, so we must drain events now. + // For normal starts this is a no-op since the inner loop handles the drain. + if (!this._stepRunner.HasUnprocessedMessages) + { + var (drainedEvents, shouldHalt) = this.DrainAndFilterEvents(); + foreach (WorkflowEvent raisedEvent in drainedEvents) + { + yield return raisedEvent; + } + + if (shouldHalt) + { + yield break; + } + + this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; + } do { @@ -107,26 +130,19 @@ internal sealed class LockstepRunEventStream : IRunEventStream yield break; // Exit if cancellation is requested } - bool hadRequestHaltEvent = false; - foreach (WorkflowEvent raisedEvent in Interlocked.Exchange(ref eventSink, [])) + var (drainedEvents, shouldHalt) = this.DrainAndFilterEvents(); + + foreach (WorkflowEvent raisedEvent in drainedEvents) { if (linkedSource.Token.IsCancellationRequested) { yield break; // Exit if cancellation is requested } - // TODO: Do we actually want to interpret this as a termination request? - if (raisedEvent is RequestHaltEvent) - { - hadRequestHaltEvent = true; - } - else - { - yield return raisedEvent; - } + yield return raisedEvent; } - if (hadRequestHaltEvent || linkedSource.Token.IsCancellationRequested) + if (shouldHalt || linkedSource.Token.IsCancellationRequested) { // If we had a completion event, we are done. yield break; @@ -151,25 +167,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream finally { this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; - this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync; // Explicitly dispose the Activity so Activity.Stop fires deterministically, // regardless of how the async iterator enumerator is disposed. runActivity?.Dispose(); } - ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e) - { - eventSink.Enqueue(e); - return default; - } - // If we are Idle or Ended, we should break out of the loop // If we are PendingRequests and not blocking on pending requests, we should break out of the loop // If cancellation is requested, we should break out of the loop bool ShouldBreak() => this.RunStatus is RunStatus.Idle or RunStatus.Ended || - (this.RunStatus == RunStatus.PendingRequests && !blockOnPendingRequest) || - linkedSource.Token.IsCancellationRequested; + (this.RunStatus == RunStatus.PendingRequests && !blockOnPendingRequest) || + linkedSource.Token.IsCancellationRequested; + } + + internal void ClearBufferedEvents() + { + Interlocked.Exchange(ref this._eventSink, new ConcurrentQueue()); } /// @@ -192,6 +206,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream if (Interlocked.Exchange(ref this._isDisposed, 1) == 0) { this._stopCancellation.Cancel(); + this._stepRunner.OutgoingEvents.EventRaised -= this.OnWorkflowEventAsync; // Stop the session activity if (this._sessionActivity is not null) @@ -207,4 +222,32 @@ internal sealed class LockstepRunEventStream : IRunEventStream return default; } + + private ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e) + { + this._eventSink.Enqueue(e); + return default; + } + + // Atomically drains the event sink and separates workflow events from halt signals. + // Used by both the early-drain (resume with pending requests only) and + // the inner superstep drain to keep halt-detection logic in one place. + private (List Events, bool ShouldHalt) DrainAndFilterEvents() + { + List events = []; + bool shouldHalt = false; + foreach (WorkflowEvent e in Interlocked.Exchange(ref this._eventSink, new ConcurrentQueue())) + { + if (e is RequestHaltEvent) + { + shouldHalt = true; + } + else + { + events.Add(e); + } + } + + return (events, shouldHalt); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index 6278f3446b..c6492270d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -60,6 +60,10 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Subscribe to events - they will flow directly to the channel as they're raised this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync; + // Re-emit any pending external requests that were restored from a checkpoint + // before this subscription was active. For non-resume starts this is a no-op. + await this._stepRunner.RepublishPendingEventsAsync(linkedSource.Token).ConfigureAwait(false); + // Start the session-level activity that spans the entire run loop lifetime. // Individual run-stage activities are nested within this session activity. Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs index 1eccb391fd..a2561437ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs @@ -50,10 +50,13 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen return runner.BeginStreamAsync(this.ExecutionMode, cancellationToken); } - internal ValueTask ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, CancellationToken cancellationToken) + internal ValueTask ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, CancellationToken cancellationToken = default) + => this.ResumeRunAsync(workflow, fromCheckpoint, knownValidInputTypes, republishPendingEvents: true, cancellationToken); + + internal ValueTask ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, bool republishPendingEvents, CancellationToken cancellationToken = default) { InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, this.CheckpointManager, fromCheckpoint.SessionId, this.EnableConcurrentRuns, knownValidInputTypes); - return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, cancellationToken); + return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, republishPendingEvents, cancellationToken); } /// @@ -104,6 +107,32 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen return new(runHandle); } + /// + /// Resumes a streaming workflow run from a checkpoint with control over whether + /// pending request events are republished through the event stream. + /// + /// The workflow to resume. + /// The checkpoint to resume from. + /// + /// When , any pending request events are republished through the event + /// stream after subscribing. When , the caller is responsible for + /// handling pending requests (e.g., already sends responses). + /// + /// Cancellation token. + internal async ValueTask ResumeStreamingInternalAsync( + Workflow workflow, + CheckpointInfo fromCheckpoint, + bool republishPendingEvents, + CancellationToken cancellationToken = default) + { + this.VerifyCheckpointingConfigured(); + + AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, fromCheckpoint, [], republishPendingEvents, cancellationToken) + .ConfigureAwait(false); + + return new(runHandle); + } + private async ValueTask BeginRunHandlingChatProtocolAsync(Workflow workflow, TInput input, string? sessionId = null, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index f93b09ddf3..0daa9bf285 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -71,6 +71,28 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle /// public string StartExecutorId { get; } + /// + /// Gating flag for deferred event republishing after checkpoint restore. + /// + /// + /// + /// Written with in + /// and consumed atomically with in + /// . The write does not need a full + /// memory barrier because it is sequenced before the constructor + /// by the in . The constructor is the + /// only code path that triggers consumption (via the event stream's subscribe and republish flow). + /// + /// + /// Note: also reads + /// in its constructor to signal the run loop, but that property reads from + /// 's request dictionary (restored during + /// ), not from this flag. The two are independent: + /// HasUnservicedRequests triggers the run loop; _needsRepublish triggers event emission. + /// + /// + private int _needsRepublish; + /// public WorkflowTelemetryContext TelemetryContext => this.Workflow.TelemetryContext; @@ -145,7 +167,10 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle return new(new AsyncRunHandle(this, this, mode)); } - public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) + public ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) + => this.ResumeStreamAsync(mode, fromCheckpoint, republishPendingEvents: true, cancellationToken); + + public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, bool republishPendingEvents, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); Throw.IfNull(fromCheckpoint); @@ -154,7 +179,18 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle throw new InvalidOperationException("This runner was not configured with a CheckpointManager, so it cannot restore checkpoints."); } - await this.RestoreCheckpointAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false); + // Restore checkpoint state without republishing pending request events. + // The event stream will republish them after subscribing so that events + // are never lost to an absent subscriber. + await this.RestoreCheckpointCoreAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false); + + if (republishPendingEvents) + { + // Signal the event stream to republish pending requests after subscribing. + // This is consumed atomically by RepublishPendingEventsAsync. + Volatile.Write(ref this._needsRepublish, 1); + } + return new AsyncRunHandle(this, this, mode); } @@ -163,6 +199,16 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId) => this.RunContext.TryGetResponsePortExecutorId(portId, out executorId); + ValueTask ISuperStepRunner.RepublishPendingEventsAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref this._needsRepublish, 0) != 0) + { + return this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken); + } + + return default; + } + public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled; public IReadOnlyList Checkpoints => this._checkpoints; @@ -310,7 +356,31 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle this._checkpoints.Add(this._lastCheckpointInfo); } + /// + /// Restores checkpoint state and re-emits any pending external request events. + /// + /// + /// This is the implementation used for runtime restores + /// where the event stream subscription is already active. For initial resumes, + /// calls + /// directly and defers republishing to the event stream. + /// public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default) + { + await this.RestoreCheckpointCoreAsync(checkpointInfo, cancellationToken).ConfigureAwait(false); + + // Republish pending request events. This is safe for runtime restores where + // the event stream is already subscribed. For initial resumes the event stream + // handles republishing itself, so ResumeStreamAsync calls RestoreCheckpointCoreAsync directly. + await this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Restores checkpoint state (queued messages, executor state, edge state, etc.) + /// without republishing pending request events. The caller is responsible for + /// ensuring events are republished after an event subscriber is attached. + /// + private async ValueTask RestoreCheckpointCoreAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); Throw.IfNull(checkpointInfo); @@ -335,11 +405,9 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle await this.RunContext.ImportStateAsync(checkpoint).ConfigureAwait(false); Task executorNotifyTask = this.RunContext.NotifyCheckpointLoadedAsync(cancellationToken); - ValueTask republishRequestsTask = this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken); await this.EdgeMap.ImportStateAsync(checkpoint).ConfigureAwait(false); await Task.WhenAll(executorNotifyTask, - republishRequestsTask.AsTask(), restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false); this._lastCheckpointInfo = checkpointInfo; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index b35d682f2c..52dae66b88 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -14,6 +14,7 @@ internal sealed class RequestPortOptions; internal sealed class RequestInfoExecutor : Executor { + private const string WrappedRequestsStateKey = nameof(WrappedRequestsStateKey); private readonly Dictionary _wrappedRequests = []; private RequestPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } @@ -124,22 +125,46 @@ internal sealed class RequestInfoExecutor : Executor return null; } - if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) - { - await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); - } - else - { - await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); - } - if (!message.Data.IsType(this.Port.Response, out object? data)) { throw this.Port.CreateExceptionForType(message); } - await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false); + if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) + { + await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); + this._wrappedRequests.Remove(message.RequestId); + } + else + { + await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false); + } return message; } + + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(WrappedRequestsStateKey, + new Dictionary(this._wrappedRequests, StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + + this._wrappedRequests.Clear(); + + Dictionary wrappedRequests = + await context.ReadStateAsync>(WrappedRequestsStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false) ?? []; + + foreach (KeyValuePair wrappedRequest in wrappedRequests) + { + this._wrappedRequests[wrappedRequest.Key] = wrappedRequest.Value; + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs index 107dc3fd7a..58e3a9e523 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -23,6 +24,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable private InProcessRunner? _activeRunner; private InMemoryCheckpointManager? _checkpointManager; private readonly ExecutorOptions _options; + private readonly ConcurrentDictionary _pendingResponsePorts = new(StringComparer.Ordinal); private ISuperStepJoinContext? _joinContext; private string? _joinId; @@ -163,6 +165,11 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable private ExternalResponse? CheckAndUnqualifyResponse([DisallowNull] ExternalResponse response) { + if (this._pendingResponsePorts.TryRemove(response.RequestId, out RequestPortInfo? originalPort)) + { + return response with { PortInfo = originalPort }; + } + if (!Throw.IfNull(response).PortInfo.PortId.StartsWith($"{this.Id}.", StringComparison.Ordinal)) { return null; @@ -193,6 +200,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable break; case RequestInfoEvent requestInfoEvt: ExternalRequest request = requestInfoEvt.Request; + this._pendingResponsePorts[request.RequestId] = request.PortInfo; resultTask = this._joinContext?.SendMessageAsync(this.Id, this.QualifyRequestPortId(request)).AsTask() ?? Task.CompletedTask; break; case WorkflowErrorEvent errorEvent: @@ -246,9 +254,13 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable } private const string CheckpointManagerStateKey = nameof(CheckpointManager); + private const string PendingResponsePortsStateKey = nameof(PendingResponsePortsStateKey); protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { await context.QueueStateUpdateAsync(CheckpointManagerStateKey, this._checkpointManager, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(PendingResponsePortsStateKey, + new Dictionary(this._pendingResponsePorts, StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); } @@ -269,6 +281,15 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable await this.ResetAsync().ConfigureAwait(false); } + this._pendingResponsePorts.Clear(); + Dictionary pendingResponsePorts = + await context.ReadStateAsync>(PendingResponsePortsStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false) ?? []; + foreach (KeyValuePair pendingResponsePort in pendingResponsePorts) + { + this._pendingResponsePorts[pendingResponsePort.Key] = pendingResponsePort.Value; + } + await this.EnsureRunSendMessageAsync(resume: true, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -280,6 +301,8 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable this._run = null; } + this._pendingResponsePorts.Clear(); + if (this._activeRunner != null) { this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 715c232e7d..c1f81f0ecf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -19,7 +19,14 @@ namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowSession : AgentSession { private readonly Workflow _workflow; - private readonly IWorkflowExecutionEnvironment _executionEnvironment; + + /// + /// The execution environment for this session. Concrete type is required because + /// uses the internal + /// API. + /// + private readonly InProcessExecutionEnvironment _inProcEnvironment; + private readonly bool _includeExceptionDetails; private readonly bool _includeWorkflowOutputsInResponse; @@ -63,17 +70,22 @@ internal sealed class WorkflowSession : AgentSession public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false) { this._workflow = Throw.IfNull(workflow); - this._executionEnvironment = Throw.IfNull(executionEnvironment); this._includeExceptionDetails = includeExceptionDetails; this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; - if (VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv)) + IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment); + if (VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv)) { // We have an InProcessExecutionEnvironment which is not configured for checkpointing. Ensure it has an externalizable checkpoint manager, // since we are responsible for maintaining the state. - this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); + env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); } + this._inProcEnvironment = env as InProcessExecutionEnvironment + ?? throw new InvalidOperationException( + $"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " + + $"but received {env.GetType().Name}."); + this.SessionId = Throw.IfNullOrEmpty(sessionId); this.ChatHistoryProvider = new WorkflowChatHistoryProvider(); } @@ -86,24 +98,30 @@ internal sealed class WorkflowSession : AgentSession public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null) { this._workflow = Throw.IfNull(workflow); - this._executionEnvironment = Throw.IfNull(executionEnvironment); this._includeExceptionDetails = includeExceptionDetails; this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse; + IWorkflowExecutionEnvironment env = Throw.IfNull(executionEnvironment); + JsonMarshaller marshaller = new(jsonSerializerOptions); SessionState sessionState = marshaller.Marshal(serializedSession); this._inMemoryCheckpointManager = sessionState.CheckpointManager; if (this._inMemoryCheckpointManager != null && - VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv)) + VerifyCheckpointingConfiguration(env, out InProcessExecutionEnvironment? inProcEnv)) { - this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); + env = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing()); } else if (this._inMemoryCheckpointManager != null) { throw new ArgumentException("The session was saved with an externalized checkpoint manager, but the incoming execution environment does not support it.", nameof(executionEnvironment)); } + this._inProcEnvironment = env as InProcessExecutionEnvironment + ?? throw new InvalidOperationException( + $"WorkflowSession requires an {nameof(InProcessExecutionEnvironment)}, " + + $"but received {env.GetType().Name}."); + this.SessionId = sessionState.SessionId; this.ChatHistoryProvider = new WorkflowChatHistoryProvider(); @@ -160,10 +178,15 @@ internal sealed class WorkflowSession : AgentSession // and does not need to be checked again here. if (this.LastCheckpoint is not null) { + // Use the internal resume path that suppresses pending request republishing. + // WorkflowSession handles pending requests itself by converting matching responses + // via SendMessagesWithResponseConversionAsync, so event-stream republishing would + // cause unwanted duplicate events visible to the consumer. StreamingRun run = - await this._executionEnvironment - .ResumeStreamingAsync(this._workflow, + await this._inProcEnvironment + .ResumeStreamingInternalAsync(this._workflow, this.LastCheckpoint, + republishPendingEvents: false, cancellationToken) .ConfigureAwait(false); @@ -172,7 +195,7 @@ internal sealed class WorkflowSession : AgentSession return new ResumeRunResult(run, dispatchInfo); } - StreamingRun newRun = await this._executionEnvironment + StreamingRun newRun = await this._inProcEnvironment .RunStreamingAsync(this._workflow, messages, this.SessionId, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index ee2a3b4815..8f46d91bbc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -126,6 +126,13 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId) { hasRequest = true; } + else + { + // This is a republished event for the request we're already responding to + // (emitted by RepublishUnservicedRequestsAsync during checkpoint resume). + // Skip yielding it so downstream code doesn't treat it as a new pending request. + continue; + } break; case ConversationUpdateEvent conversationEvent: diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs new file mode 100644 index 0000000000..9d4b514af7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Agents.AI.Workflows.Sample; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Regression tests for GH-2485: pending objects must be +/// re-emitted after resuming a workflow from a checkpoint. +/// +public class CheckpointResumeTests +{ + /// + /// Verifies that a resumed workflow re-emits s for + /// pending external requests that existed at the time of the checkpoint. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // Act 1: Run workflow, collect pending requests and a checkpoint. + List originalRequests = []; + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is RequestInfoEvent requestInfo) + { + originalRequests.Add(requestInfo.Request); + } + + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + originalRequests.Should().NotBeEmpty("the workflow should have created at least one external request"); + checkpoint.Should().NotBeNull("a checkpoint should have been created"); + } + + // Act 2: Resume from the checkpoint. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + + // Assert: The pending requests should be re-emitted. + List reEmittedRequests = []; + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + if (evt is RequestInfoEvent requestInfo) + { + reEmittedRequests.Add(requestInfo.Request); + } + } + + reEmittedRequests.Should().HaveCount(originalRequests.Count, + "all pending requests from the checkpoint should be re-emitted after resume"); + reEmittedRequests.Select(r => r.RequestId) + .Should().BeEquivalentTo(originalRequests.Select(r => r.RequestId), + "the re-emitted request IDs should match the original pending request IDs"); + } + + /// + /// Verifies that transitions to + /// after resuming from a checkpoint with pending external requests (not stuck at NotStarted). + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_WithPendingRequests_RunStatusIsPendingRequestsAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // First run: collect a checkpoint with pending requests. + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + checkpoint.Should().NotBeNull(); + } + + // Act: Resume from the checkpoint and consume events so the run loop processes. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + // Consume all events until the stream completes. + } + + // Assert + RunStatus status = await resumed.GetStatusAsync(); + status.Should().Be(RunStatus.PendingRequests, + "the resumed workflow should report PendingRequests after rehydration"); + } + + /// + /// Verifies the full roundtrip: resume from checkpoint, observe the re-emitted request, + /// send a response, and verify the workflow completes without duplicating the request. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_RespondToPendingRequest_CompletesWithoutDuplicateAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // First run: collect checkpoint + pending request. + ExternalRequest? pendingRequest = null; + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is RequestInfoEvent requestInfo) + { + pendingRequest = requestInfo.Request; + } + + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + pendingRequest.Should().NotBeNull(); + checkpoint.Should().NotBeNull(); + } + + // Act: Resume and respond to the restored request. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + + int requestEventCount = 0; + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + // Use blockOnPendingRequest: false for the first pass to see the re-emitted requests. + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + if (evt is RequestInfoEvent requestInfo) + { + requestEventCount++; + requestInfo.Request.RequestId.Should().Be(pendingRequest!.RequestId, + "the re-emitted request should match the original"); + } + } + + requestEventCount.Should().Be(1, + "the pending request should be emitted exactly once (no duplicates)"); + + // Assert intermediate state before responding: the run should be in PendingRequests + // and we should have observed the re-emitted request. If the first WatchStreamAsync + // didn't complete or yielded nothing, these assertions catch it with a clear message. + RunStatus statusBeforeResponse = await resumed.GetStatusAsync(); + statusBeforeResponse.Should().Be(RunStatus.PendingRequests, + "the run should be in PendingRequests state before we send a response"); + + // Now send the response and verify the workflow processes it. + ExternalResponse response = pendingRequest!.CreateResponse("World"); + await resumed.SendResponseAsync(response); + + // Consume the resulting events to verify the workflow progresses without errors. + List postResponseEvents = []; + + using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token)) + { + postResponseEvents.Add(evt); + } + + postResponseEvents.Should().NotBeEmpty( + "the workflow should process the response and produce events"); + postResponseEvents.OfType().Should().BeEmpty( + "no errors should occur when processing the restored request's response"); + } + + /// + /// Verifies that restoring a live run to a checkpoint re-emits pending requests and allows + /// the workflow to continue from that restored point. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Restore_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + Workflow workflow = CreateSimpleRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + await using StreamingRun run = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello"); + + (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); + + // Advance the run past the checkpoint so the restore has meaningful work to undo. + await run.SendResponseAsync(pendingRequest.CreateResponse("World")); + + List firstCompletionEvents = await ReadToHaltAsync(run); + firstCompletionEvents.OfType().Should().BeEmpty( + "the workflow should continue cleanly before we restore"); + RunStatus statusAfterFirstResponse = await run.GetStatusAsync(); + statusAfterFirstResponse.Should().Be(RunStatus.Idle, + "the workflow should finish processing the first response before we restore"); + + // Act + await run.RestoreCheckpointAsync(checkpoint); + + // Assert + List restoredEvents = await ReadToHaltAsync(run); + ExternalRequest[] replayedRequests = [.. restoredEvents.OfType().Select(evt => evt.Request)]; + + replayedRequests.Should().ContainSingle("runtime restore should re-emit the restored pending request"); + replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId, + "the replayed request should match the request captured at the checkpoint"); + + await run.SendResponseAsync(replayedRequests[0].CreateResponse("Again")); + + List secondCompletionEvents = await ReadToHaltAsync(run); + secondCompletionEvents.OfType().Should().BeEmpty( + "runtime restore replay should not introduce workflow errors"); + RunStatus statusAfterRestoreResponse = await run.GetStatusAsync(); + statusAfterRestoreResponse.Should().Be(RunStatus.Idle, + "the workflow should be able to continue after the runtime restore replay"); + } + + /// + /// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_SubworkflowWithPendingRequests_RepublishesQualifiedRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + Workflow workflow = CreateCheckpointedSubworkflowRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + ExternalRequest pendingRequest; + CheckpointInfo checkpoint; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + (pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); + } + + // Act + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint); + + // Assert + List resumedEvents = await ReadToHaltAsync(resumed); + ExternalRequest[] replayedRequests = [.. resumedEvents.OfType().Select(evt => evt.Request)]; + + replayedRequests.Should().ContainSingle("the resumed parent workflow should surface the subworkflow request once"); + replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId, + "the replayed subworkflow request should match the checkpointed request"); + replayedRequests[0].PortInfo.PortId.Should().Be(pendingRequest.PortInfo.PortId, + "the replayed request should remain qualified through the subworkflow boundary"); + + await resumed.SendResponseAsync(replayedRequests[0].CreateResponse("World")); + + List completionEvents = await ReadToHaltAsync(resumed); + completionEvents.OfType().Should().BeEmpty( + "the resumed subworkflow request should not be replayed twice"); + completionEvents.OfType().Should().BeEmpty( + "subworkflow replay should not introduce workflow errors"); + RunStatus statusAfterSubworkflowResponse = await resumed.GetStatusAsync(); + statusAfterSubworkflowResponse.Should().Be(RunStatus.Idle, + "the resumed subworkflow should continue after responding to the replayed request"); + } + + /// + /// Verifies that when republishPendingEvents is , + /// no is re-emitted after resuming from a checkpoint. + /// + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Checkpoint_Resume_WithRepublishDisabled_DoesNotEmitRequestInfoEventsAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + ForwardMessageExecutor processor = new("Processor"); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // First run: collect a checkpoint with pending requests. + CheckpointInfo? checkpoint = null; + + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + checkpoint.Should().NotBeNull(); + } + + // Act: Resume with republishPendingEvents: false via the internal API. + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingInternalAsync(workflow, checkpoint!, republishPendingEvents: false); + + // Assert: No RequestInfoEvent should appear in the event stream. + int requestEventCount = 0; + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + if (evt is RequestInfoEvent) + { + requestEventCount++; + } + } + + requestEventCount.Should().Be(0, + "no RequestInfoEvent should be emitted when republishPendingEvents is false"); + } + + private static Workflow CreateSimpleRequestWorkflow( + string requestPortId = "TestPort", + string processorId = "Processor") + { + RequestPort requestPort = RequestPort.Create(requestPortId); + ForwardMessageExecutor processor = new(processorId); + + return new WorkflowBuilder(requestPort) + .AddEdge(requestPort, processor) + .Build(); + } + + private static Workflow CreateCheckpointedSubworkflowRequestWorkflow() + { + ExecutorBinding subworkflow = CreateSimpleRequestWorkflow( + requestPortId: "InnerTestPort", + processorId: "InnerProcessor") + .BindAsExecutor("Subworkflow"); + + return new WorkflowBuilder(subworkflow) + .AddExternalRequest(subworkflow, id: "ForwardedSubworkflowRequest") + .Build(); + } + + private static async ValueTask<(ExternalRequest PendingRequest, CheckpointInfo Checkpoint)> CapturePendingRequestAndCheckpointAsync(StreamingRun run) + { + ExternalRequest? pendingRequest = null; + CheckpointInfo? checkpoint = null; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is RequestInfoEvent requestInfo) + { + pendingRequest ??= requestInfo.Request; + } + + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + + pendingRequest.Should().NotBeNull("the workflow should have emitted a pending request"); + checkpoint.Should().NotBeNull("the workflow should have produced a checkpoint"); + return (pendingRequest!, checkpoint!); + } + + private static async ValueTask> ReadToHaltAsync(StreamingRun run) + { + List events = []; + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + events.Add(evt); + } + + return events; + } +} From b065a4ce515dbc710d0d682ce8ee9ee540d6a57f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 18:13:11 +0200 Subject: [PATCH 13/20] Python: [BREAKING] update context provider APIs, middleware, and per-service-call history persistence (#4992) * Rename provider base APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Allow provider-added chat and function middleware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simulate service-stored history per model call Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix typing regressions in CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix response ID suppression review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename per-service-call history persistence APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address context persistence review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stabilize markdown sample docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Persist service continuation state per call Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 4 +- python/packages/a2a/tests/test_a2a_agent.py | 4 +- .../_context_provider.py | 10 +- .../_history_provider.py | 6 +- .../claude/agent_framework_claude/_agent.py | 4 +- .../agent_framework_copilotstudio/_agent.py | 4 +- python/packages/core/AGENTS.md | 6 +- .../packages/core/agent_framework/__init__.py | 8 +- .../packages/core/agent_framework/_agents.py | 412 ++++++++++----- .../packages/core/agent_framework/_clients.py | 6 + .../core/agent_framework/_compaction.py | 4 +- .../core/agent_framework/_sessions.py | 293 ++++++++++- .../packages/core/agent_framework/_skills.py | 4 +- .../packages/core/agent_framework/_tools.py | 60 ++- .../packages/core/agent_framework/_types.py | 13 + .../core/agent_framework/_workflows/_agent.py | 10 +- .../core/agent_framework/observability.py | 308 ++++++----- .../packages/core/tests/core/test_agents.py | 498 +++++++++++++++++- .../tests/core/test_middleware_with_agent.py | 141 +++++ .../packages/core/tests/core/test_sessions.py | 95 +++- .../foundry/agent_framework_foundry/_agent.py | 20 +- .../_memory_provider.py | 10 +- .../agent_framework_github_copilot/_agent.py | 4 +- .../agent_framework_mem0/_context_provider.py | 10 +- .../_handoff.py | 5 +- .../orchestrations/tests/test_handoff.py | 47 +- .../_context_provider.py | 10 +- .../_history_provider.py | 10 +- python/packages/redis/tests/test_providers.py | 2 +- python/samples/01-get-started/04_memory.py | 4 +- .../samples/02-agents/chat_client/README.md | 10 + ...re_per_service_call_history_persistence.py | 194 +++++++ .../simple_context_provider.py | 4 +- .../conversations/custom_history_provider.py | 4 +- .../05-end-to-end/hosted_agents/README.md | 2 +- .../agent_with_text_search_rag/main.py | 4 +- .../05-end-to-end/m365-agent/README.md | 2 +- 37 files changed, 1836 insertions(+), 396 deletions(-) create mode 100644 python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 4b6d9cc19b..7dae6ddcd2 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -35,9 +35,9 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, - BaseHistoryProvider, Content, ContinuationToken, + HistoryProvider, Message, ResponseStream, SessionContext, @@ -353,7 +353,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): # Run before_run providers (forward order) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if session is None: raise RuntimeError("Provider session must be available when context providers are configured.") diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 58a82f6d8c..136164c79a 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -24,8 +24,8 @@ from agent_framework import ( AgentResponse, AgentResponseUpdate, AgentSession, - BaseContextProvider, Content, + ContextProvider, Message, SessionContext, ) @@ -869,7 +869,7 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A # region Context Provider Tests -class TrackingContextProvider(BaseContextProvider): +class TrackingContextProvider(ContextProvider): """A context provider that records when before_run and after_run are called.""" def __init__(self) -> None: diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index adda863c5a..c10e7cb9b8 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Azure AI Search context provider using BaseContextProvider. +"""New-pattern Azure AI Search context provider using ContextProvider. This module provides ``AzureAISearchContextProvider``, built on the new -:class:`BaseContextProvider` hooks pattern. +:class:`ContextProvider` hooks pattern. """ from __future__ import annotations @@ -17,8 +17,8 @@ from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, AgentSession, Annotation, - BaseContextProvider, Content, + ContextProvider, Message, SecretString, SessionContext, @@ -154,8 +154,8 @@ class AzureAISearchSettings(TypedDict, total=False): api_key: SecretString | None -class AzureAISearchContextProvider(BaseContextProvider): - """Azure AI Search context provider using the new BaseContextProvider hooks pattern. +class AzureAISearchContextProvider(ContextProvider): + """Azure AI Search context provider using the new ContextProvider hooks pattern. Retrieves relevant context from Azure AI Search using semantic or agentic search modes. diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 6a61350a9c..d13f285249 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -11,7 +11,7 @@ from collections.abc import Sequence from typing import Any, ClassVar, TypedDict from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message -from agent_framework._sessions import BaseHistoryProvider +from agent_framework._sessions import HistoryProvider from agent_framework._settings import SecretString, load_settings from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -32,8 +32,8 @@ class AzureCosmosHistorySettings(TypedDict, total=False): key: SecretString | None -class CosmosHistoryProvider(BaseHistoryProvider): - """Azure Cosmos DB-backed history provider using BaseHistoryProvider hooks.""" +class CosmosHistoryProvider(HistoryProvider): + """Azure Cosmos DB-backed history provider using HistoryProvider hooks.""" DEFAULT_SOURCE_ID: ClassVar[str] = "azure_cosmos_history" _BATCH_OPERATION_LIMIT: ClassVar[int] = 100 diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index dd30a3b2d2..fcc7151342 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -16,8 +16,8 @@ from agent_framework import ( AgentRunInputs, AgentSession, BaseAgent, - BaseContextProvider, Content, + ContextProvider, FunctionTool, Message, ResponseStream, @@ -223,7 +223,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, default_options: OptionsT | MutableMapping[str, Any] | None = None, diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index fc2a35c72b..56a9c89081 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -11,8 +11,8 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, - BaseContextProvider, Content, + ContextProvider, Message, ResponseStream, normalize_messages, @@ -60,7 +60,7 @@ class CopilotStudioAgent(BaseAgent): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: list[AgentMiddlewareTypes] | None = None, environment_id: str | None = None, agent_identifier: str | None = None, diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 4a9317aa54..29fe978aef 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -61,8 +61,8 @@ agent_framework/ - **`AgentSession`** - Manages conversation state and session metadata - **`SessionContext`** - Context object for session-scoped data during agent runs -- **`BaseContextProvider`** - Base class for context providers (RAG, memory systems) -- **`BaseHistoryProvider`** - Base class for conversation history storage +- **`ContextProvider`** - Base class for context providers (RAG, memory systems) +- **`HistoryProvider`** - Base class for conversation history storage ### Skills (`_skills.py`) @@ -70,7 +70,7 @@ agent_framework/ - **`SkillResource`** - Named supplementary content attached to a skill; holds either static `content` or a dynamic `function` (sync or async). Exactly one must be provided. - **`SkillScript`** - An executable script attached to a skill; holds either an inline `function` (code-defined, runs in-process) or a `path` to a file on disk (file-based, delegated to a runner). Exactly one must be provided. - **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner. -- **`SkillsProvider`** - Context provider (extends `BaseContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. +- **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. ### Workflows (`_workflows/`) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index d30d5159ea..7c39e1ffea 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -102,8 +102,10 @@ from ._middleware import ( ) from ._sessions import ( AgentSession, - BaseContextProvider, - BaseHistoryProvider, + BaseContextProvider, # type: ignore[reportDeprecated] + BaseHistoryProvider, # type: ignore[reportDeprecated] + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, SessionContext, register_state_type, @@ -296,6 +298,7 @@ __all__ = [ "CompactionProvider", "CompactionStrategy", "Content", + "ContextProvider", "ContinuationToken", "ConversationSplit", "ConversationSplitter", @@ -331,6 +334,7 @@ __all__ = [ "FunctionTool", "GeneratedEmbeddings", "GraphConnectivityError", + "HistoryProvider", "InMemoryCheckpointStorage", "InMemoryHistoryProvider", "InProcRunnerContext", diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 1868742111..b857a6377f 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -29,14 +29,16 @@ from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage] from ._clients import BaseChatClient, SupportsChatGetResponse from ._docstrings import apply_layered_docstring from ._mcp import LOG_LEVEL_MAPPING, MCPTool -from ._middleware import AgentMiddlewareLayer, FunctionInvocationContext, MiddlewareTypes +from ._middleware import AgentMiddlewareLayer, FunctionInvocationContext, MiddlewareTypes, categorize_middleware from ._serialization import SerializationMixin from ._sessions import ( AgentSession, - BaseContextProvider, - BaseHistoryProvider, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, + PerServiceCallHistoryPersistingMiddleware, SessionContext, + is_local_history_conversation_id, ) from ._tools import FunctionInvocationLayer, FunctionTool, ToolTypes, normalize_tools from ._types import ( @@ -50,7 +52,7 @@ from ._types import ( map_chat_to_agent_update, normalize_messages, ) -from .exceptions import AgentInvalidResponseException, UserInputRequiredException +from .exceptions import AgentInvalidRequestException, AgentInvalidResponseException, UserInputRequiredException from .observability import AgentTelemetryLayer if sys.version_info >= (3, 13): @@ -166,6 +168,7 @@ class _RunContext(TypedDict): input_messages: Sequence[Message] session_messages: Sequence[Message] agent_name: str + suppress_response_id: bool chat_options: MutableMapping[str, Any] compaction_strategy: CompactionStrategy | None tokenizer: TokenizerProtocol | None @@ -366,6 +369,7 @@ class BaseAgent(SerializationMixin): """ DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"} + require_per_service_call_history_persistence: bool = False def __init__( self, @@ -373,7 +377,7 @@ class BaseAgent(SerializationMixin): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, additional_properties: MutableMapping[str, Any] | None = None, ) -> None: @@ -393,7 +397,7 @@ class BaseAgent(SerializationMixin): self.id = id self.name = name self.description = description - self.context_providers: list[BaseContextProvider] = list(context_providers or []) + self.context_providers: list[ContextProvider] = list(context_providers or []) self.middleware: list[MiddlewareTypes] | None = ( cast(list[MiddlewareTypes], middleware) if middleware is not None else None ) @@ -455,7 +459,12 @@ class BaseAgent(SerializationMixin): if provider_session is None and self.context_providers: provider_session = AgentSession() + per_service_call_history_required = self.require_per_service_call_history_persistence and any( + isinstance(provider, HistoryProvider) for provider in self.context_providers + ) for provider in reversed(self.context_providers): + if per_service_call_history_required and isinstance(provider, HistoryProvider): + continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") await provider.after_run( @@ -656,8 +665,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] description: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsCoT | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, @@ -675,6 +685,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] description: A brief description of the agent's purpose. context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. + require_per_service_call_history_persistence: When True, history providers are invoked + around each model call instead of once per ``run()`` when the service + is not already storing history. If service-side storage is active for + the run, the agent skips local history providers and relies on the + service-managed conversation instead. default_options: A TypedDict containing chat options. When using a typed agent like ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for provider-specific options including temperature, max_tokens, model_id, @@ -706,6 +721,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] ) self.client = client self.compaction_strategy = compaction_strategy + self.require_per_service_call_history_persistence = require_per_service_call_history_persistence self.tokenizer = tokenizer # Get tools from options or named parameter (named param takes precedence) @@ -764,6 +780,35 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] await self._async_exit_stack.enter_async_context(context_manager) return self + def _get_history_providers(self) -> list[HistoryProvider]: + return [provider for provider in self.context_providers if isinstance(provider, HistoryProvider)] + + def _resolve_per_service_call_history_providers( + self, + *, + session: AgentSession | None, + options: Mapping[str, Any] | None, + service_stores_history: bool, + ) -> list[HistoryProvider]: + history_providers = self._get_history_providers() + if not self.require_per_service_call_history_persistence or not history_providers: + return [] + + conversation_id = ( + session.service_session_id + if session and session.service_session_id + else cast(str | None, (options or {}).get("conversation_id") or self.default_options.get("conversation_id")) + ) + if service_stores_history: + return [] + + if conversation_id is not None: + raise AgentInvalidRequestException( + "require_per_service_call_history_persistence cannot be used " + "with an existing service-managed conversation." + ) + return history_providers + async def __aexit__( self, exc_type: type[BaseException] | None, @@ -885,97 +930,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] When stream=True: A ResponseStream of AgentResponseUpdate items with ``get_final_response()`` for the final AgentResponse. """ - if not stream: - async def _run_non_streaming() -> AgentResponse[Any]: - ctx = await self._prepare_run_context( - messages=messages, - session=session, - tools=tools, - options=options, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - ) - response = cast( - ChatResponse[Any], - await self.client.get_response( # type: ignore - messages=ctx["session_messages"], - stream=False, - options=ctx["chat_options"], # type: ignore[reportArgumentType] - compaction_strategy=ctx["compaction_strategy"], - tokenizer=ctx["tokenizer"], - function_invocation_kwargs=ctx["function_invocation_kwargs"], - client_kwargs=ctx["client_kwargs"], - ), - ) - - if not response: - raise AgentInvalidResponseException("Chat client did not return a response.") - - await self._finalize_response( - response=response, - agent_name=ctx["agent_name"], - session=ctx["session"], - session_context=ctx["session_context"], - ) - response_format = ctx["chat_options"].get("response_format") - if not ( - response_format is not None - and isinstance(response_format, type) - and issubclass(response_format, BaseModel) - ): - response_format = None - - return AgentResponse( - messages=response.messages, - response_id=response.response_id, - created_at=response.created_at, - usage_details=response.usage_details, - value=response.value, - response_format=response_format, - continuation_token=response.continuation_token, - raw_representation=response, - additional_properties=response.additional_properties, - ) - - return _run_non_streaming() - - # Use a holder to capture the context created during stream initialization - ctx_holder: dict[str, _RunContext | None] = {"ctx": None} - - async def _post_hook(response: AgentResponse) -> None: - ctx = ctx_holder["ctx"] - if ctx is None: - return # No context available (shouldn't happen in normal flow) - - # Update thread with conversation_id derived from streaming raw updates. - # Using response_id here can break function-call continuation for APIs - # where response IDs are not valid conversation handles. - conversation_id = self._extract_conversation_id_from_streaming_response(response) - # Ensure author names are set for all messages - for message in response.messages: - if message.author_name is None: - message.author_name = ctx["agent_name"] - - # Propagate conversation_id back to session from streaming updates. - # For Responses-style APIs this can rotate every turn (response_id-based continuation), - # so refresh when a newer value is returned. - sess = ctx["session"] - if sess and conversation_id and sess.service_session_id != conversation_id: - sess.service_session_id = conversation_id - - # Run after_run providers (reverse order) - session_context = ctx["session_context"] - session_context._response = AgentResponse( # type: ignore[assignment] - messages=response.messages, - response_id=response.response_id, - ) - await self._run_after_providers(session=ctx["session"], context=session_context) - - async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - ctx_holder["ctx"] = await self._prepare_run_context( + async def _prepare_run_context() -> _RunContext: + return await self._prepare_run_context( messages=messages, session=session, tools=tools, @@ -985,55 +942,177 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) - ctx: _RunContext = ctx_holder["ctx"] # type: ignore[assignment] # Safe: we just assigned it + + if not stream: + + async def _run_non_streaming() -> AgentResponse[Any]: + ctx = await _prepare_run_context() + response = await self._call_chat_client(ctx, stream=False) + return await self._parse_non_streaming_response(ctx, response) + + return _run_non_streaming() + + async def _run_streaming() -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + ctx = await _prepare_run_context() + stream_response = self._call_chat_client(ctx, stream=True) + return self._parse_streaming_response(ctx, stream_response) + + return cast( + ResponseStream[AgentResponseUpdate, AgentResponse[Any]], + cast(Any, ResponseStream).from_awaitable(_run_streaming()), + ) + + @overload + def _call_chat_client( + self, + context: _RunContext, + *, + stream: Literal[False], + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def _call_chat_client( + self, + context: _RunContext, + *, + stream: Literal[True], + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + def _call_chat_client( + self, + context: _RunContext, + *, + stream: bool, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Invoke the downstream chat client for a prepared run context.""" + if stream: return self.client.get_response( # type: ignore[call-overload, no-any-return] - messages=ctx["session_messages"], + messages=context["session_messages"], stream=True, - options=ctx["chat_options"], # type: ignore[reportArgumentType] - compaction_strategy=ctx["compaction_strategy"], - tokenizer=ctx["tokenizer"], - function_invocation_kwargs=ctx["function_invocation_kwargs"], - client_kwargs=ctx["client_kwargs"], + options=context["chat_options"], # type: ignore[reportArgumentType] + compaction_strategy=context["compaction_strategy"], + tokenizer=context["tokenizer"], + function_invocation_kwargs=context["function_invocation_kwargs"], + client_kwargs=context["client_kwargs"], ) - def _propagate_conversation_id( - update: AgentResponseUpdate, - ) -> AgentResponseUpdate: - """Eagerly propagate conversation_id to session as updates arrive. + return self.client.get_response( # type: ignore[call-overload, no-any-return] + messages=context["session_messages"], + stream=False, + options=context["chat_options"], # type: ignore[reportArgumentType] + compaction_strategy=context["compaction_strategy"], + tokenizer=context["tokenizer"], + function_invocation_kwargs=context["function_invocation_kwargs"], + client_kwargs=context["client_kwargs"], + ) - This ensures session.service_session_id is set even when the user - only iterates the stream without calling get_final_response(). - """ + async def _parse_non_streaming_response( + self, + context: _RunContext, + response: ChatResponse[Any], + ) -> AgentResponse[Any]: + """Finalize a non-streaming chat response into an AgentResponse.""" + if not response: + raise AgentInvalidResponseException("Chat client did not return a response.") + + await self._finalize_response( + response=response, + agent_name=context["agent_name"], + session=context["session"], + session_context=context["session_context"], + suppress_response_id=context["suppress_response_id"], + ) + + response_format = context["chat_options"].get("response_format") + if not ( + response_format is not None and isinstance(response_format, type) and issubclass(response_format, BaseModel) + ): + response_format = None + + return AgentResponse( + messages=response.messages, + response_id=None if context["suppress_response_id"] else response.response_id, + created_at=response.created_at, + usage_details=response.usage_details, + value=response.value, + response_format=response_format, + continuation_token=response.continuation_token, + raw_representation=response, + additional_properties=response.additional_properties, + ) + + def _parse_streaming_response( + self, + context: _RunContext, + stream_response: ResponseStream[ChatResponseUpdate, ChatResponse[Any]], + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Finalize a streaming chat response into an agent response stream.""" + + async def _post_hook(response: AgentResponse) -> None: + # Update thread with conversation_id derived from streaming raw updates. + # Using response_id here can break function-call continuation for APIs + # where response IDs are not valid conversation handles. + conversation_id = self._extract_conversation_id_from_streaming_response(response) + + for message in response.messages: + if message.author_name is None: + message.author_name = context["agent_name"] + + session = context["session"] + if ( + session + and conversation_id + and not is_local_history_conversation_id(conversation_id) + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + + suppress_response_id = context["suppress_response_id"] + session_context = context["session_context"] + session_context._response = AgentResponse( # type: ignore[assignment] + messages=response.messages, + response_id=None if suppress_response_id else response.response_id, + ) + await self._run_after_providers(session=session, context=session_context) + + def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpdate: + """Eagerly propagate conversation_id to session as updates arrive.""" + session = context["session"] if session is None: return update raw = update.raw_representation - conv_id = getattr(raw, "conversation_id", None) if raw else None - if isinstance(conv_id, str) and conv_id and session.service_session_id != conv_id: - session.service_session_id = conv_id + conversation_id = getattr(raw, "conversation_id", None) if raw else None + if ( + isinstance(conversation_id, str) + and conversation_id + and not is_local_history_conversation_id(conversation_id) + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + return update + + def _suppress_response_id(update: AgentResponseUpdate) -> AgentResponseUpdate: + """Hide raw service response ids when local per-service-call persistence owns continuation.""" + update.response_id = None return update def _finalizer(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: - ctx = ctx_holder["ctx"] - rf = ( - ctx.get("chat_options", {}).get("response_format") - if ctx - else (options.get("response_format") if options else None) # type: ignore[union-attr] + return self._finalize_response_updates( + updates, + response_format=context["chat_options"].get("response_format"), ) - return self._finalize_response_updates(updates, response_format=rf) - return ( - ResponseStream - .from_awaitable(_get_stream()) # type: ignore[reportUnknownMemberType] - .map( - transform=partial( - map_chat_to_agent_update, - agent_name=self.name, - ), - finalizer=_finalizer, - ) - .with_transform_hook(_propagate_conversation_id) - .with_result_hook(_post_hook) + stream = stream_response.map( + transform=partial( + map_chat_to_agent_update, + agent_name=self.name, + ), + finalizer=_finalizer, ) + if context["suppress_response_id"]: + stream = stream.with_transform_hook(_suppress_response_id) + + return stream.with_transform_hook(_propagate_conversation_id).with_result_hook(_post_hook) def _finalize_response_updates( self, @@ -1111,6 +1190,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] if active_session is None and self.context_providers: active_session = AgentSession() + per_service_call_history_providers = self._resolve_per_service_call_history_providers( + session=active_session, + options=opts, + service_stores_history=bool(store_), + ) + session_context, chat_options = await self._prepare_session_and_messages( session=active_session, input_messages=input_messages, @@ -1191,6 +1276,43 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if active_session is not None: effective_client_kwargs["session"] = active_session + if per_service_call_history_providers and active_session is not None: + per_service_call_history_middleware = PerServiceCallHistoryPersistingMiddleware( + agent=self, + session=active_session, + providers=per_service_call_history_providers, + ) + existing_middleware = effective_client_kwargs.get("middleware") + if isinstance(existing_middleware, Sequence) and not isinstance(existing_middleware, (str, bytes)): + effective_client_kwargs["middleware"] = [per_service_call_history_middleware, *existing_middleware] + elif existing_middleware is not None: + effective_client_kwargs["middleware"] = [ + per_service_call_history_middleware, + cast(MiddlewareTypes, existing_middleware), + ] + else: + effective_client_kwargs["middleware"] = [per_service_call_history_middleware] + provider_middleware = session_context.get_middleware() + if provider_middleware: + middleware_list = categorize_middleware(provider_middleware) + provider_function_chat_middleware = [ + *middleware_list["function"], + *middleware_list["chat"], + ] + if provider_function_chat_middleware: + existing_middleware = effective_client_kwargs.get("middleware") + if isinstance(existing_middleware, Sequence) and not isinstance(existing_middleware, (str, bytes)): + effective_client_kwargs["middleware"] = [ + *existing_middleware, + *provider_function_chat_middleware, + ] + elif existing_middleware is not None: + effective_client_kwargs["middleware"] = [ + cast(MiddlewareTypes, existing_middleware), + *provider_function_chat_middleware, + ] + else: + effective_client_kwargs["middleware"] = provider_function_chat_middleware return { "session": active_session, @@ -1198,6 +1320,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "input_messages": input_messages, "session_messages": session_messages, "agent_name": agent_name, + "suppress_response_id": bool(per_service_call_history_providers), "chat_options": co, "compaction_strategy": compaction_strategy or self.compaction_strategy, "tokenizer": tokenizer or self.tokenizer, @@ -1211,6 +1334,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] agent_name: str, session: AgentSession | None, session_context: SessionContext, + suppress_response_id: bool = False, ) -> None: """Finalize response by setting author names and running after_run providers. @@ -1219,6 +1343,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] agent_name: The name of the agent to set as author. session: The conversation session. session_context: The invocation context. + suppress_response_id: When True, omit the raw service response ID from the public response. """ # Ensure that the author name is set for each message in the response. for message in response.messages: @@ -1228,13 +1353,18 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] # Propagate conversation_id back to session (e.g. thread ID from Assistants API). # For Responses-style APIs this can rotate every turn (response_id-based continuation), # so refresh when a newer value is returned. - if session and response.conversation_id and session.service_session_id != response.conversation_id: + if ( + session + and response.conversation_id + and not is_local_history_conversation_id(response.conversation_id) + and session.service_session_id != response.conversation_id + ): session.service_session_id = response.conversation_id # Set the response on the context for after_run providers session_context._response = AgentResponse( # type: ignore[assignment] messages=response.messages, - response_id=response.response_id, + response_id=None if suppress_response_id else response.response_id, ) # Run after_run providers (reverse order) @@ -1284,9 +1414,15 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options or {}, ) - # Run before_run providers (forward order, skip BaseHistoryProvider with load_messages=False) + per_service_call_history_required = self.require_per_service_call_history_persistence and bool( + self._get_history_providers() + ) + + # Run before_run providers (forward order, skip HistoryProvider when per-service-call persistence owns history) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if per_service_call_history_required and isinstance(provider, HistoryProvider): + continue + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") @@ -1551,8 +1687,9 @@ class Agent( description: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsCoT | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: MutableMapping[str, Any] | None = None, @@ -1568,6 +1705,7 @@ class Agent( default_options=default_options, context_providers=context_providers, middleware=middleware, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, compaction_strategy=compaction_strategy, tokenizer=tokenizer, additional_properties=additional_properties, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index d1394a6733..41bcf25883 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -572,6 +572,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): default_options: OptionsCoT | Mapping[str, Any] | None = None, context_providers: Sequence[Any] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -596,6 +597,10 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): and dict literals are accepted without specialized option typing. context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. + require_per_service_call_history_persistence: Whether to require per-service-call + chat history persistence. When enabled, history providers are invoked around + each model call instead of once per ``run()`` when the service is not already + storing history. function_invocation_configuration: Optional function invocation configuration override. compaction_strategy: Optional agent-level compaction override. When omitted, client-level compaction defaults remain in effect for each call. @@ -636,6 +641,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): "default_options": cast(Any, default_options), "context_providers": context_providers, "middleware": middleware, + "require_per_service_call_history_persistence": require_per_service_call_history_persistence, "compaction_strategy": compaction_strategy, "tokenizer": tokenizer, "additional_properties": dict(additional_properties) if additional_properties is not None else None, diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index 8a15a6438c..06879e3a16 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -15,7 +15,7 @@ from typing import ( runtime_checkable, ) -from ._sessions import BaseContextProvider +from ._sessions import ContextProvider from ._types import ChatResponse, Content, Message if TYPE_CHECKING: @@ -1152,7 +1152,7 @@ async def apply_compaction( COMPACTION_STATE_KEY: Final[str] = "_compaction_messages" -class CompactionProvider(BaseContextProvider): +class CompactionProvider(ContextProvider): """Context provider that compacts messages before and after agent runs. This provider accepts two separate strategies: diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index 84656824aa..11aa2419db 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -4,8 +4,8 @@ This module provides the core types for the context provider pipeline: - SessionContext: Per-invocation state passed through providers -- BaseContextProvider: Base class for context providers (renamed to ContextProvider in PR2) -- BaseHistoryProvider: Base class for history storage providers (renamed to HistoryProvider in PR2) +- ContextProvider: Base class for context providers +- HistoryProvider: Base class for history storage providers - AgentSession: Lightweight session state container - InMemoryHistoryProvider: Built-in in-memory history provider """ @@ -13,21 +13,42 @@ This module provides the core types for the context provider pipeline: from __future__ import annotations import copy +import sys import uuid from abc import abstractmethod -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, ClassVar, cast +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, TypeGuard, cast -from ._types import AgentResponse, Message +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import deprecated # type: ignore # pragma: no cover + +from ._middleware import ChatContext, ChatMiddleware +from ._types import AgentResponse, ChatResponse, Message, ResponseStream +from .exceptions import ChatClientInvalidResponseException if TYPE_CHECKING: from ._agents import SupportsAgentRun + from ._middleware import MiddlewareTypes # Registry of known types for state deserialization _STATE_TYPE_REGISTRY: dict[str, type] = {} +def _is_middleware_sequence( + middleware: MiddlewareTypes | Sequence[MiddlewareTypes], +) -> TypeGuard[Sequence[MiddlewareTypes]]: + return isinstance(middleware, Sequence) and not isinstance(middleware, (str, bytes)) + + +def _is_single_middleware( + middleware: MiddlewareTypes | Sequence[MiddlewareTypes], +) -> TypeGuard[MiddlewareTypes]: + return not _is_middleware_sequence(middleware) + + def register_state_type(cls: type) -> None: """Register a type for automatic deserialization in session state. @@ -131,6 +152,8 @@ class SessionContext: Maintains insertion order (provider execution order). instructions: Additional instructions added by providers. tools: Additional tools added by providers. + middleware: Dict mapping source_id -> chat/function middleware added by that provider. + Maintains insertion order (provider execution order). response: After invocation, contains the full AgentResponse, should not be changed. options: Options passed to agent.run() - read-only, for reflection only. metadata: Shared metadata dictionary for cross-provider communication. @@ -145,6 +168,7 @@ class SessionContext: context_messages: dict[str, list[Message]] | None = None, instructions: list[str] | None = None, tools: list[Any] | None = None, + middleware: dict[str, list[MiddlewareTypes]] | None = None, options: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, ): @@ -157,6 +181,7 @@ class SessionContext: context_messages: Pre-populated context messages by source. instructions: Pre-populated instructions. tools: Pre-populated tools. + middleware: Pre-populated chat/function middleware by source. options: Options from agent.run() - read-only for providers. metadata: Shared metadata for cross-provider communication. """ @@ -166,6 +191,10 @@ class SessionContext: self.context_messages: dict[str, list[Message]] = context_messages or {} self.instructions: list[str] = instructions or [] self.tools: list[Any] = tools or [] + self.middleware: dict[str, list[MiddlewareTypes]] = {} + if middleware: + for source_id, provider_middleware in middleware.items(): + self.extend_middleware(source_id, provider_middleware) self._response: AgentResponse | None = None self.options: dict[str, Any] = options or {} self.metadata: dict[str, Any] = metadata or {} @@ -236,6 +265,40 @@ class SessionContext: additional_properties["context_source"] = source_id self.tools.extend(tools) + def extend_middleware( + self, + source_id: str, + middleware: MiddlewareTypes | Sequence[MiddlewareTypes], + ) -> None: + """Add middleware to be applied for this invocation. + + Args: + source_id: The provider source_id adding this middleware. + middleware: A single chat/function middleware object/callable or sequence of middleware. + """ + from ._middleware import categorize_middleware + from .exceptions import MiddlewareException + + if _is_middleware_sequence(middleware): + middleware_items = list(middleware) + elif _is_single_middleware(middleware): + middleware_items = [middleware] + else: + raise TypeError("middleware must be a middleware object or a sequence of middleware objects.") + middleware_list = categorize_middleware(middleware_items) + if middleware_list["agent"]: + raise MiddlewareException("Context providers may only add chat or function middleware.") + if source_id not in self.middleware: + self.middleware[source_id] = [] + self.middleware[source_id].extend(middleware_items) + + def get_middleware(self) -> list[MiddlewareTypes]: + """Get provider-added chat/function middleware in provider execution order.""" + result: list[MiddlewareTypes] = [] + for middleware_items in self.middleware.values(): + result.extend(middleware_items) + return result + def get_messages( self, *, @@ -272,17 +335,12 @@ class SessionContext: return result -class BaseContextProvider: - """Base class for context providers (hooks pattern). +class ContextProvider: + """Base class for context providers. Context providers participate in the context engineering pipeline, adding context before model invocation and processing responses after. - Note: - This class uses a temporary name prefixed with ``_`` to avoid collision - with the existing ``ContextProvider`` in ``_memory.py``. It will be - renamed to ``ContextProvider`` in PR2 when the old class is removed. - Attributes: source_id: Unique identifier for this provider instance (required). Used for message/tool attribution so other providers can filter. @@ -312,7 +370,7 @@ class BaseContextProvider: Args: agent: The agent running this invocation. session: The current session. - context: The invocation context - add messages/instructions/tools here. + context: The invocation context - add messages/instructions/tools/chat/function middleware here. state: The provider-scoped mutable state dict for this provider. Full cross-provider state remains available at ``session.state``. """ @@ -339,7 +397,7 @@ class BaseContextProvider: """ -class BaseHistoryProvider(BaseContextProvider): +class HistoryProvider(ContextProvider): """Base class for conversation history storage providers. A single class configurable for different use cases: @@ -347,10 +405,6 @@ class BaseHistoryProvider(BaseContextProvider): - Audit/logging storage (stores only, doesn't load) - Evaluation storage (stores only for later analysis) - Note: - This class uses a temporary name prefixed with ``_`` to avoid collision - with existing types. It will be renamed to ``HistoryProvider`` in PR2. - Subclasses only need to implement ``get_messages()`` and ``save_messages()``. The default ``before_run``/``after_run`` handle loading and storing based on configuration flags. Override them for custom behavior. @@ -467,6 +521,207 @@ class BaseHistoryProvider(BaseContextProvider): await self.save_messages(context.session_id, messages_to_store, state=state) +LOCAL_HISTORY_CONVERSATION_ID = "agent_framework_local_history_persistence" + + +def is_local_history_conversation_id(conversation_id: str | None) -> bool: + """Return whether a conversation id is the local history-persistence sentinel.""" + return conversation_id == LOCAL_HISTORY_CONVERSATION_ID + + +def _response_contains_follow_up_request(response: ChatResponse) -> bool: + """Return whether a response requires another model call in the current run.""" + return any( + item.type in {"function_call", "function_approval_request"} + for message in response.messages + for item in message.contents + ) + + +def _split_service_call_messages(messages: Sequence[Message]) -> tuple[list[Message], dict[str, list[Message]]]: + """Split service-call messages into input messages and attributed context messages.""" + input_messages: list[Message] = [] + context_messages: dict[str, list[Message]] = {} + for message in messages: + attribution = message.additional_properties.get("_attribution") + if isinstance(attribution, Mapping): + attribution_mapping = cast(Mapping[str, Any], attribution) + source_id = attribution_mapping.get("source_id") + if isinstance(source_id, str): + context_messages.setdefault(source_id, []).append(message) + continue + input_messages.append(message) + return input_messages, context_messages + + +class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware): + """Persist local chat history after each service call when history is framework-managed. + + This middleware runs around each model call when + ``require_per_service_call_history_persistence`` is enabled. It loads history providers + before the model call, persists them after the model call, and uses a local + sentinel conversation id so the function loop follows the existing + service-managed branch without forwarding that sentinel to the leaf client. + """ + + def __init__( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + providers: Sequence[HistoryProvider], + ) -> None: + """Initialize the middleware. + + Args: + agent: The agent that owns the history providers. + session: The active session for the current run. + providers: The history providers participating in per-service-call persistence. + """ + self._agent = agent + self._session = session + self._providers = list(providers) + + async def _prepare_service_call_context(self, messages: Sequence[Message]) -> SessionContext: + """Create a per-call SessionContext and load history providers into it.""" + input_messages, context_messages = _split_service_call_messages(messages) + service_call_context = SessionContext( + session_id=self._session.session_id, + service_session_id=None, + input_messages=list(input_messages), + ) + for source_id, source_messages in context_messages.items(): + service_call_context.extend_messages(source_id, source_messages) + for provider in self._providers: + if not provider.load_messages: + continue + await provider.before_run( + agent=self._agent, + session=self._session, + context=service_call_context, + state=self._session.state.setdefault(provider.source_id, {}), + ) + return service_call_context + + async def _persist_service_call_response( + self, + *, + service_call_context: SessionContext, + response: ChatResponse, + ) -> None: + """Persist a single model-call response through the configured history providers.""" + service_call_context._response = AgentResponse( # type: ignore[assignment] + messages=response.messages, + response_id=None, + ) + for provider in reversed(self._providers): + await provider.after_run( + agent=self._agent, + session=self._session, + context=service_call_context, + state=self._session.state.setdefault(provider.source_id, {}), + ) + + def _strip_local_conversation_id(self, context: ChatContext) -> None: + """Remove the local sentinel before the leaf chat client is invoked.""" + if is_local_history_conversation_id(cast(str | None, context.kwargs.get("conversation_id"))): + context.kwargs.pop("conversation_id", None) + + if context.options is None: + return + + mutable_options = dict(context.options) + if is_local_history_conversation_id(cast(str | None, mutable_options.get("conversation_id"))): + mutable_options.pop("conversation_id", None) + context.options = mutable_options + + async def _finalize_response( + self, + *, + service_call_context: SessionContext, + response: ChatResponse, + ) -> ChatResponse: + """Persist a model response and apply the local follow-up sentinel when needed.""" + if response.conversation_id is not None and not is_local_history_conversation_id(response.conversation_id): + raise ChatClientInvalidResponseException( + "require_per_service_call_history_persistence cannot be used " + "when the chat client returns a real conversation_id." + ) + + await self._persist_service_call_response( + service_call_context=service_call_context, + response=response, + ) + if _response_contains_follow_up_request(response): + response.mark_internal_conversation_id() + response.conversation_id = LOCAL_HISTORY_CONVERSATION_ID + return response + + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + """Load and persist history providers around a single model call. + + Args: + context: The chat invocation context for the current model call. + call_next: The next middleware or the leaf chat client. + + Raises: + ChatClientInvalidResponseException: If the leaf client returns a real + service-managed conversation id while local per-service-call persistence is enabled. + ValueError: If the downstream middleware contract returns the wrong + result type for streaming or non-streaming execution. + """ + service_call_context = await self._prepare_service_call_context(context.messages) + context.messages = service_call_context.get_messages(include_input=True) + self._strip_local_conversation_id(context) + + await call_next() + + if context.result is None: + return + + if context.stream: + if not isinstance(context.result, ResponseStream): + raise ValueError("Streaming chat middleware requires a ResponseStream result.") + context.result = context.result.with_result_hook( + lambda response: self._finalize_response( + service_call_context=service_call_context, + response=response, + ) + ) + return + + if isinstance(context.result, ResponseStream): + raise ValueError("Non-streaming chat middleware requires a ChatResponse result.") + context.result = await self._finalize_response( + service_call_context=service_call_context, + response=context.result, + ) + + +@deprecated( + "BaseContextProvider is deprecated. Use ContextProvider instead.", + category=DeprecationWarning, +) +class BaseContextProvider(ContextProvider): + """Deprecated alias for :class:`ContextProvider`. + + .. deprecated:: + BaseContextProvider is deprecated. Use :class:`ContextProvider` instead. + """ + + +@deprecated( + "BaseHistoryProvider is deprecated. Use HistoryProvider instead.", + category=DeprecationWarning, +) +class BaseHistoryProvider(HistoryProvider): + """Deprecated alias for :class:`HistoryProvider`. + + .. deprecated:: + BaseHistoryProvider is deprecated. Use :class:`HistoryProvider` instead. + """ + + class AgentSession: """A conversation session with an agent. @@ -535,7 +790,7 @@ class AgentSession: return session -class InMemoryHistoryProvider(BaseHistoryProvider): +class InMemoryHistoryProvider(HistoryProvider): """Built-in history provider that stores messages in session.state. Messages are stored in ``state["messages"]`` as a list of diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index dc111750af..5c99dbaa60 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -36,7 +36,7 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable from ._feature_stage import ExperimentalFeature, experimental -from ._sessions import BaseContextProvider +from ._sessions import ContextProvider from ._tools import FunctionTool if TYPE_CHECKING: @@ -519,7 +519,7 @@ SCRIPT_RUNNER_INSTRUCTIONS: Final[str] = ( @experimental(feature_id=ExperimentalFeature.SKILLS) -class SkillsProvider(BaseContextProvider): +class SkillsProvider(ContextProvider): """Context provider that advertises skills and exposes skill tools. Supports both **file-based** skills (discovered from ``SKILL.md`` files) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 521a0c4d96..043187caa4 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1688,6 +1688,34 @@ def _update_conversation_id( options["conversation_id"] = conversation_id +def _update_continuation_state( + kwargs: dict[str, Any], + response: ChatResponse[Any], + *, + session: AgentSession | None, + options: dict[str, Any] | None = None, +) -> None: + """Update in-flight and persisted continuation state from a response.""" + conversation_id = response.conversation_id + if conversation_id is None: + return + + _update_conversation_id(kwargs, conversation_id, options) + if ( + session is not None + and not response.has_internal_conversation_id() + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + + +def _clear_internal_conversation_id(response: ChatResponse[Any]) -> ChatResponse[Any]: + if response.has_internal_conversation_id(): + response.conversation_id = None + response.clear_internal_conversation_id() + return response + + def _extract_tools( options: dict[str, Any] | None, ) -> ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None: @@ -2206,9 +2234,14 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ), ) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) + _update_continuation_state( + filtered_kwargs, + response, + session=invocation_session, + options=mutable_options, + ) if response.conversation_id is not None: - _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( @@ -2223,7 +2256,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) if result.get("action") == "return": response.usage_details = aggregated_usage - return response + return _clear_internal_conversation_id(response) total_function_calls += result.get("function_call_count", 0) if result.get("action") == "stop": # Error threshold reached: force a final non-tool turn so @@ -2279,11 +2312,17 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ), ) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) + _update_continuation_state( + filtered_kwargs, + response, + session=invocation_session, + options=mutable_options, + ) response.usage_details = aggregated_usage if fcc_messages: for msg in reversed(fcc_messages): response.messages.insert(0, msg) - return response + return _clear_internal_conversation_id(response) return _get_response() @@ -2343,6 +2382,12 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): # Get the finalized response from the inner stream # This triggers the inner stream's finalizer and result hooks response = await inner_stream.get_final_response() + _update_continuation_state( + filtered_kwargs, + response, + session=invocation_session, + options=mutable_options, + ) if not any( item.type in ("function_call", "function_approval_request") @@ -2352,7 +2397,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): return if response.conversation_id is not None: - _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( @@ -2430,7 +2474,13 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): async for update in final_inner_stream: yield update # Finalize the inner stream to trigger its hooks - await final_inner_stream.get_final_response() + final_response = await final_inner_stream.get_final_response() + _update_continuation_state( + filtered_kwargs, + final_response, + session=invocation_session, + options=mutable_options, + ) def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]: # Note: stream_result_hooks are already run via inner stream's get_final_response() diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 6d6bc58068..bd468b8450 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2001,6 +2001,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """ DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation", "additional_properties"} + _INTERNAL_CONVERSATION_ID_KEY: ClassVar[str] = "_agent_framework_internal_conversation_id" def __init__( self, @@ -2069,6 +2070,18 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): self.continuation_token = continuation_token self.raw_representation: Any | list[Any] | None = raw_representation + def mark_internal_conversation_id(self) -> None: + """Mark the current conversation_id as internal control-flow state.""" + self.additional_properties[self._INTERNAL_CONVERSATION_ID_KEY] = True + + def clear_internal_conversation_id(self) -> None: + """Remove the internal conversation-id marker.""" + self.additional_properties.pop(self._INTERNAL_CONVERSATION_ID_KEY, None) + + def has_internal_conversation_id(self) -> bool: + """Return whether conversation_id is internal control-flow state.""" + return bool(self.additional_properties.get(self._INTERNAL_CONVERSATION_ID_KEY, False)) + @property def model_id(self) -> str | None: """Deprecated alias for :attr:`model`.""" diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index bf615814b3..53df314a24 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -14,8 +14,8 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload from .._agents import BaseAgent from .._sessions import ( AgentSession, - BaseContextProvider, - BaseHistoryProvider, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, SessionContext, ) @@ -86,7 +86,7 @@ class WorkflowAgent(BaseAgent): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, **kwargs: Any, ) -> None: """Initialize the WorkflowAgent. @@ -249,7 +249,7 @@ class WorkflowAgent(BaseAgent): options={}, ) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") @@ -314,7 +314,7 @@ class WorkflowAgent(BaseAgent): options={}, ) for provider in self.context_providers: - if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + if isinstance(provider, HistoryProvider) and not provider.load_messages: continue if provider_session is None: raise RuntimeError("Provider session must be available when context providers are configured.") diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 236daa29a0..cdc6179602 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1502,6 +1502,161 @@ class AgentTelemetryLayer: self.token_usage_histogram = _get_token_usage_histogram() self.duration_histogram = _get_duration_histogram() + def _trace_agent_invocation( + self, + *, + messages: AgentRunInputs | None, + session: AgentSession | None, + merged_options: Mapping[str, Any], + client_kwargs: Mapping[str, Any] | None, + stream: bool, + execute: Callable[[], Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]], + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Trace an agent invocation while delegating execution to ``execute``.""" + global OBSERVABILITY_SETTINGS + from ._types import ResponseStream + + if not OBSERVABILITY_SETTINGS.ENABLED: + return execute() + + provider_name = str(self.otel_provider_name) + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + attributes = _get_span_attributes( + operation_name=OtelAttr.AGENT_INVOKE_OPERATION, + provider_name=provider_name, + agent_id=getattr(self, "id", "unknown"), + agent_name=getattr(self, "name", None) or getattr(self, "id", "unknown"), + agent_description=getattr(self, "description", None), + thread_id=session.service_session_id if session else None, + all_options=dict(merged_options), + **merged_client_kwargs, + ) + + inner_response_telemetry_captured_fields: set[str] = set() + inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set( + inner_response_telemetry_captured_fields + ) + inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({}) + + if stream: + try: + run_result: object = execute() + if isinstance(run_result, ResponseStream): + result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] + elif isinstance(run_result, Awaitable): + result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + else: + raise RuntimeError("Streaming telemetry requires a ResponseStream result.") + except Exception: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + raise + + operation = attributes.get(OtelAttr.OPERATION, "operation") + span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") + span = get_tracer().start_span(f"{operation} {span_name}") + span.set_attributes(attributes) + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=_get_instructions_from_options(dict(merged_options)), + ) + + span_state = {"closed": False} + duration_state: dict[str, float] = {} + start_time = perf_counter() + + def _close_span() -> None: + if span_state["closed"]: + return + span_state["closed"] = True + span.end() + + def _record_duration() -> None: + duration_state["duration"] = perf_counter() - start_time + + async def _finalize_stream() -> None: + from ._types import AgentResponse + + try: + response: AgentResponse[Any] = await result_stream.get_final_response() + duration = duration_state.get("duration") + response_attributes = _get_response_attributes( + attributes, + response, + capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD + not in inner_response_telemetry_captured_fields, + capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, + ) + _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) + _capture_response(span=span, attributes=response_attributes, duration=duration) + if ( + OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED + and isinstance(response, AgentResponse) + and response.messages + ): + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + finally: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + _close_span() + + wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook( + _record_duration + ).with_cleanup_hook(_finalize_stream) + weakref.finalize(wrapped_stream, _close_span) + return wrapped_stream + + async def _run() -> AgentResponse[Any]: + try: + with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=_get_instructions_from_options(dict(merged_options)), + ) + start_time_stamp = perf_counter() + try: + response: AgentResponse[Any] = await execute() + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + raise + duration = perf_counter() - start_time_stamp + if response: + response_attributes = _get_response_attributes( + attributes, + response, + capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD + not in inner_response_telemetry_captured_fields, + capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, + ) + _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) + _capture_response(span=span, attributes=response_attributes, duration=duration) + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + return response # type: ignore[return-value,no-any-return] + finally: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + + return _run() + @overload def run( self, @@ -1565,14 +1720,12 @@ class AgentTelemetryLayer: client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" - global OBSERVABILITY_SETTINGS - from ._types import ResponseStream, merge_chat_options + from ._types import merge_chat_options super_run = cast( "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", super().run, # type: ignore[misc] ) - provider_name = str(self.otel_provider_name) super_run_kwargs: dict[str, Any] = { "messages": messages, "stream": stream, @@ -1586,156 +1739,21 @@ class AgentTelemetryLayer: } if middleware is not None: super_run_kwargs["middleware"] = middleware - if not OBSERVABILITY_SETTINGS.ENABLED: - return super_run(**super_run_kwargs) # type: ignore[no-any-return] default_options = dict(getattr(self, "default_options", {})) merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} merged_options: dict[str, Any] = merge_chat_options( default_options, dict(options) if options is not None else {} ) - attributes = _get_span_attributes( - operation_name=OtelAttr.AGENT_INVOKE_OPERATION, - provider_name=provider_name, - agent_id=getattr(self, "id", "unknown"), - agent_name=getattr(self, "name", None) or getattr(self, "id", "unknown"), - agent_description=getattr(self, "description", None), - thread_id=session.service_session_id if session else None, - all_options=merged_options, - **merged_client_kwargs, + return self._trace_agent_invocation( + messages=messages, + session=session, + merged_options=merged_options, + client_kwargs=merged_client_kwargs, + stream=stream, + execute=lambda: super_run(**super_run_kwargs), ) - inner_response_telemetry_captured_fields: set[str] = set() - inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set( - inner_response_telemetry_captured_fields - ) - inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({}) - - if stream: - try: - run_result: object = super_run(**super_run_kwargs) - if isinstance(run_result, ResponseStream): - result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] - elif isinstance(run_result, Awaitable): - result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] - else: - raise RuntimeError("Streaming telemetry requires a ResponseStream result.") - except Exception: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - raise - - # Create span directly without trace.use_span() context attachment. - # Streaming spans are closed asynchronously in cleanup hooks, which run - # in a different async context than creation — using use_span() would - # cause "Failed to detach context" errors from OpenTelemetry. - operation = attributes.get(OtelAttr.OPERATION, "operation") - span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") - span = get_tracer().start_span(f"{operation} {span_name}") - span.set_attributes(attributes) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=_get_instructions_from_options(merged_options), - ) - - span_state = {"closed": False} - duration_state: dict[str, float] = {} - start_time = perf_counter() - - def _close_span() -> None: - if span_state["closed"]: - return - span_state["closed"] = True - span.end() - - def _record_duration() -> None: - duration_state["duration"] = perf_counter() - start_time - - async def _finalize_stream() -> None: - from ._types import AgentResponse - - try: - response: AgentResponse[Any] = await result_stream.get_final_response() - duration = duration_state.get("duration") - response_attributes = _get_response_attributes( - attributes, - response, - capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD - not in inner_response_telemetry_captured_fields, - capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, - ) - _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) - _capture_response(span=span, attributes=response_attributes, duration=duration) - if ( - OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED - and isinstance(response, AgentResponse) - and response.messages - ): - _capture_messages( - span=span, - provider_name=provider_name, - messages=response.messages, - output=True, - ) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - finally: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - _close_span() - - # Register a weak reference callback to close the span if stream is garbage collected - # without being consumed. This ensures spans don't leak if users don't consume streams. - wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook( - _record_duration - ).with_cleanup_hook(_finalize_stream) - weakref.finalize(wrapped_stream, _close_span) - return wrapped_stream - - async def _run() -> AgentResponse: - try: - with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=_get_instructions_from_options(merged_options), - ) - start_time_stamp = perf_counter() - try: - response: AgentResponse[Any] = await super_run(**super_run_kwargs) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - raise - duration = perf_counter() - start_time_stamp - if response: - response_attributes = _get_response_attributes( - attributes, - response, - capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD - not in inner_response_telemetry_captured_fields, - capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, - ) - _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) - _capture_response(span=span, attributes=response_attributes, duration=duration) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=response.messages, - output=True, - ) - return response # type: ignore[return-value,no-any-return] - finally: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - - return _run() - # region Otel Helpers diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 94253b3c34..015de2a2e3 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -3,8 +3,8 @@ import contextlib import inspect import json -from collections.abc import AsyncIterable, MutableSequence -from typing import Any +from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -18,22 +18,29 @@ from agent_framework import ( AgentResponse, AgentResponseUpdate, AgentSession, - BaseContextProvider, + ChatContext, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + ContextProvider, FunctionTool, + HistoryProvider, + InMemoryHistoryProvider, Message, + ResponseStream, + SessionContext, SlidingWindowStrategy, SupportsAgentRun, SupportsChatGetResponse, TruncationStrategy, + chat_middleware, tool, ) from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_mcp_name from agent_framework._middleware import FunctionInvocationContext +from agent_framework.exceptions import AgentInvalidRequestException, ChatClientInvalidResponseException class _FixedTokenizer: @@ -68,6 +75,49 @@ class _ConnectedMCPTool(MCPTool): raise NotImplementedError +class _RecordingHistoryProvider(HistoryProvider): + def __init__(self, source_id: str = "recording_history") -> None: + super().__init__(source_id=source_id) + + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: + if state is None: + return [] + state["get_call_count"] = state.get("get_call_count", 0) + 1 + return list(cast(list[Message], state.get("messages", []))) + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + if state is None: + return + state["save_call_count"] = state.get("save_call_count", 0) + 1 + state.setdefault("messages", []).extend(messages) + + +class _ResponseIdRecordingHistoryProvider(_RecordingHistoryProvider): + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + state.setdefault("response_ids", []).append(context.response.response_id if context.response else None) + await super().after_run(agent=agent, session=session, context=context, state=state) + + def test_agent_session_type(agent_session: AgentSession) -> None: assert isinstance(agent_session, AgentSession) @@ -314,6 +364,413 @@ async def test_prepare_run_context_handles_function_kwargs( assert ctx["client_kwargs"]["session"] is session +async def test_chat_agent_persists_history_per_service_call( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + session = AgentSession() + session.state[provider.source_id] = { + "messages": [ + Message(role="user", text="Earlier question"), + Message(role="assistant", text="Earlier answer"), + ] + } + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + response_id="resp_call_1", + ), + ChatResponse(messages=Message(role="assistant", text="It is sunny in Seattle."), response_id="resp_call_2"), + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + result = await agent.run("What's the weather in Seattle?", session=session) + + provider_state = session.state[provider.source_id] + stored_messages = cast(list[Message], provider_state["messages"]) + + assert result.text == "It is sunny in Seattle." + assert result.response_id is None + assert chat_client_base.call_count == 2 + assert provider_state["get_call_count"] == 2 + assert provider_state["save_call_count"] == 2 + assert stored_messages[-1].text == "It is sunny in Seattle." + assert session.service_session_id is None + + +async def test_chat_agent_persists_history_per_service_call_streaming( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + session = AgentSession() + session.state[provider.source_id] = { + "messages": [ + Message(role="user", text="Earlier question"), + Message(role="assistant", text="Earlier answer"), + ] + } + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="stop", + response_id="resp_call_1", + ) + ], + [ + ChatResponseUpdate( + contents=[Content.from_text("It is sunny in Seattle.")], + role="assistant", + finish_reason="stop", + response_id="resp_call_2", + ) + ], + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + stream = agent.run("What's the weather in Seattle?", session=session, stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + + provider_state = session.state[provider.source_id] + stored_messages = cast(list[Message], provider_state["messages"]) + + assert result.text == "It is sunny in Seattle." + assert result.response_id is None + assert chat_client_base.call_count == 2 + assert provider_state["get_call_count"] == 2 + assert provider_state["save_call_count"] == 2 + assert stored_messages[-1].text == "It is sunny in Seattle." + assert session.service_session_id is None + + +async def test_streaming_per_service_call_persistence_hides_response_id_from_after_run( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _ResponseIdRecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="stop", + response_id="resp_call_1", + ) + ], + [ + ChatResponseUpdate( + contents=[Content.from_text("It is sunny in Seattle.")], + role="assistant", + finish_reason="stop", + response_id="resp_call_2", + ) + ], + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + stream = agent.run("What's the weather in Seattle?", session=session, stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + + provider_state = session.state[provider.source_id] + + assert result.response_id is None + assert provider_state["response_ids"] == [None, None] + + +async def test_per_service_call_persistence_uses_real_service_storage_when_client_stores_by_default( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + conversation_id="resp_service_managed", + response_id="resp_call_1", + ), + ChatResponse( + messages=Message(role="assistant", text="It is sunny in Seattle."), + conversation_id="resp_service_managed", + response_id="resp_call_2", + ), + ] + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + result = await agent.run("What's the weather in Seattle?", session=session) + + provider_state = session.state[provider.source_id] + + assert result.text == "It is sunny in Seattle." + assert result.response_id == "resp_call_2" + assert chat_client_base.call_count == 2 + assert "get_call_count" not in provider_state + assert "save_call_count" not in provider_state + assert session.service_session_id == "resp_service_managed" + + +async def test_service_storage_updates_session_handle_per_service_call_before_non_streaming_failure( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + first_response = ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + conversation_id="resp_call_1", + response_id="resp_call_1", + ) + mock_get_non_streaming_response = AsyncMock( + side_effect=[first_response, RuntimeError("service down")], + ) + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with ( + patch.object(chat_client_base, "_get_non_streaming_response", new=mock_get_non_streaming_response), + pytest.raises(RuntimeError, match="service down"), + ): + await agent.run("What's the weather in Seattle?", session=session) + + assert mock_get_non_streaming_response.await_count == 2 + assert session.service_session_id == "resp_call_1" + + +async def test_service_storage_updates_session_handle_per_service_call_before_streaming_failure( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + + async def _first_stream_updates() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="stop", + ) + + def _finalize_first_stream(_updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]: + return ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "Seattle"}', + ) + ], + ), + conversation_id="resp_call_1", + response_id="resp_call_1", + ) + + first_stream = ResponseStream(_first_stream_updates(), finalizer=_finalize_first_stream) + mock_get_streaming_response = MagicMock(side_effect=[first_stream, RuntimeError("service down")]) + + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with ( + patch.object(chat_client_base, "_get_streaming_response", new=mock_get_streaming_response), + pytest.raises(RuntimeError, match="service down"), + ): + stream = agent.run("What's the weather in Seattle?", session=session, stream=True) + async for _ in stream: + pass + + assert mock_get_streaming_response.call_count == 2 + assert session.service_session_id == "resp_call_1" + + +async def test_chat_agent_without_per_service_call_persistence_preserves_response_id( + chat_client_base: SupportsChatGetResponse, +) -> None: + chat_client_base.run_responses = [ + ChatResponse( + messages=Message(role="assistant", text="Hello"), + response_id="resp_call_1", + ) + ] + + agent = Agent( + client=chat_client_base, + context_providers=[InMemoryHistoryProvider()], + ) + + result = await agent.run("Hello", session=AgentSession(), options={"store": False}) + + assert result.response_id == "resp_call_1" + + +async def test_per_service_call_persistence_rejects_real_service_conversation_id( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + chat_client_base.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + chat_client_base.run_responses = [ + ChatResponse( + messages=Message(role="assistant", text="Hello"), + conversation_id="resp_service_managed", + ) + ] + + agent = Agent( + client=chat_client_base, + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with pytest.raises( + ChatClientInvalidResponseException, + match="require_per_service_call_history_persistence cannot be used", + ): + await agent.run("Hello", session=session, options={"store": False}) + + +async def test_per_service_call_persistence_rejects_existing_conversation_id_when_service_not_storing_history( + chat_client_base: SupportsChatGetResponse, +) -> None: + provider = _RecordingHistoryProvider() + session = AgentSession() + session.state[provider.source_id] = {"messages": []} + + agent = Agent( + client=chat_client_base, + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + with pytest.raises( + AgentInvalidRequestException, + match="require_per_service_call_history_persistence cannot be used", + ): + await agent.run("Hello", session=session, options={"store": False, "conversation_id": "existing_conversation"}) + + async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None: mock_response = ChatResponse( messages=[Message(role="assistant", contents=[Content.from_text("test response")])], @@ -586,7 +1043,7 @@ async def test_chat_client_agent_author_name_is_used_from_response( # Mock context provider for testing -class MockContextProvider(BaseContextProvider): +class MockContextProvider(ContextProvider): def __init__(self, messages: list[Message] | None = None) -> None: super().__init__(source_id="mock") self.context_messages = messages @@ -1723,7 +2180,7 @@ async def test_agent_create_session_with_context_providers( ): """Test that create_session works when context_providers are set on the agent.""" - class TestContextProvider(BaseContextProvider): + class TestContextProvider(ContextProvider): def __init__(self): super().__init__(source_id="test") @@ -1798,7 +2255,7 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none( """A tool provided by context.""" return text - class ToolContextProvider(BaseContextProvider): + class ToolContextProvider(ContextProvider): def __init__(self): super().__init__(source_id="tool-context") @@ -1827,7 +2284,7 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none ): """Test that context provider instructions are used when agent has no default instructions.""" - class InstructionContextProvider(BaseContextProvider): + class InstructionContextProvider(ContextProvider): def __init__(self): super().__init__(source_id="instruction-context") @@ -1849,6 +2306,33 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none assert options.get("instructions") == "Context-provided instructions" +async def test_chat_agent_context_provider_adds_middleware_when_agent_has_none( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Test that context provider middleware is collected during preparation.""" + + @chat_middleware + async def context_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + class MiddlewareContextProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="middleware-context") + + async def before_run(self, *, agent, session, context, state) -> None: + context.extend_middleware("middleware-context", context_chat_middleware) + + agent = Agent(client=chat_client_base, context_providers=[MiddlewareContextProvider()]) + + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=None, + input_messages=[Message(role="user", text="Hello")], + ) + + assert session_context.middleware["middleware-context"] == [context_chat_middleware] + assert session_context.get_middleware() == [context_chat_middleware] + + # region STORES_BY_DEFAULT tests diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index 69d08482d3..508bdee075 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -15,6 +15,7 @@ from agent_framework import ( ChatResponse, ChatResponseUpdate, Content, + ContextProvider, FunctionInvocationContext, FunctionMiddleware, FunctionTool, @@ -464,6 +465,31 @@ class TestChatAgentMultipleMiddlewareOrdering: expected_order = ["class_agent_before", "function_agent_before", "function_agent_after", "class_agent_after"] assert execution_order == expected_order + async def test_provider_added_agent_middleware_is_rejected(self, chat_client_base: "MockBaseChatClient") -> None: + """Test provider-added agent middleware is rejected explicitly.""" + + @agent_middleware + async def provider_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + class ProviderMiddlewareContextProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="provider-middleware") + + async def before_run(self, *, agent, session, context, state) -> None: + context.extend_middleware(self.source_id, provider_middleware) + + agent = Agent( + client=chat_client_base, + context_providers=[ProviderMiddlewareContextProvider()], + ) + + with pytest.raises( + MiddlewareException, + match="Context providers may only add chat or function middleware", + ): + await agent.run([Message(role="user", text="test message")]) + # region Tool Functions for Testing @@ -2066,6 +2092,121 @@ class TestChatAgentChatMiddleware: "agent_middleware_after", ] + async def test_provider_added_chat_and_function_middleware_are_forwarded( + self, chat_client_base: "MockBaseChatClient" + ) -> None: + """Test provider-added chat and function middleware forwarding and ordering.""" + execution_order: list[str] = [] + + @chat_middleware + async def constructor_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + execution_order.append("constructor_chat_before") + await call_next() + execution_order.append("constructor_chat_after") + + @chat_middleware + async def provider_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + execution_order.append("provider_chat_before") + await call_next() + execution_order.append("provider_chat_after") + + @chat_middleware + async def run_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + execution_order.append("run_chat_before") + await call_next() + execution_order.append("run_chat_after") + + @function_middleware + async def constructor_function_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + execution_order.append("constructor_function_before") + await call_next() + execution_order.append("constructor_function_after") + + @function_middleware + async def provider_function_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + execution_order.append("provider_function_before") + await call_next() + execution_order.append("provider_function_after") + + @function_middleware + async def run_function_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + execution_order.append("run_function_before") + await call_next() + execution_order.append("run_function_after") + + class ProviderMiddlewareContextProvider(ContextProvider): + def __init__(self) -> None: + super().__init__(source_id="provider-middleware") + + async def before_run(self, *, agent, session, context, state) -> None: + context.extend_middleware( + self.source_id, + [ + provider_chat_middleware, + provider_function_middleware, + ], + ) + + chat_client_base.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_provider", + name="sample_tool_function", + arguments='{"location": "Seattle"}', + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Final response")]), + ] + + agent = Agent( + client=chat_client_base, + middleware=[constructor_chat_middleware, constructor_function_middleware], + context_providers=[ProviderMiddlewareContextProvider()], + tools=[sample_tool_function], + ) + + response = await agent.run( + [Message(role="user", text="Get weather for Seattle")], + middleware=[run_chat_middleware, run_function_middleware], + ) + + assert response is not None + assert chat_client_base.call_count == 2 + assert response.messages[-1].text == "Final response" + assert execution_order == [ + "constructor_chat_before", + "run_chat_before", + "provider_chat_before", + "provider_chat_after", + "run_chat_after", + "constructor_chat_after", + "constructor_function_before", + "run_function_before", + "provider_function_before", + "provider_function_after", + "run_function_after", + "constructor_function_after", + "constructor_chat_before", + "run_chat_before", + "provider_chat_before", + "provider_chat_after", + "run_chat_after", + "constructor_chat_after", + ] + async def test_agent_middleware_can_access_and_override_options(self) -> None: """Test that agent middleware can access and override runtime options.""" captured_options: dict[str, Any] = {} diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index bd2cb8155e..3d4d75afe5 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -1,16 +1,26 @@ # Copyright (c) Microsoft. All rights reserved. import json -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence -from agent_framework import Message -from agent_framework._sessions import ( +import pytest + +from agent_framework import ( + AgentContext, AgentSession, BaseContextProvider, BaseHistoryProvider, + ChatContext, + ContextProvider, + HistoryProvider, InMemoryHistoryProvider, + Message, SessionContext, + agent_middleware, + chat_middleware, ) +from agent_framework._sessions import LOCAL_HISTORY_CONVERSATION_ID, is_local_history_conversation_id +from agent_framework.exceptions import MiddlewareException # --------------------------------------------------------------------------- # SessionContext tests @@ -102,6 +112,50 @@ class TestSessionContext: ctx.extend_instructions("sys", ["Be helpful", "Be concise"]) assert ctx.instructions == ["Be helpful", "Be concise"] + def test_extend_middleware_creates_key_and_appends(self) -> None: + ctx = SessionContext(input_messages=[]) + + @chat_middleware + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @chat_middleware + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + ctx.extend_middleware("rag", first_middleware) + ctx.extend_middleware("rag", [second_middleware]) + + assert ctx.middleware["rag"] == [first_middleware, second_middleware] + assert ctx.get_middleware() == [first_middleware, second_middleware] + + def test_extend_middleware_preserves_source_order(self) -> None: + ctx = SessionContext(input_messages=[]) + + @chat_middleware + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @chat_middleware + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + ctx.extend_middleware("a", first_middleware) + ctx.extend_middleware("b", second_middleware) + + assert list(ctx.middleware.keys()) == ["a", "b"] + assert ctx.get_middleware() == [first_middleware, second_middleware] + + def test_extend_middleware_rejects_agent_middleware(self) -> None: + ctx = SessionContext(input_messages=[]) + + @agent_middleware + async def provider_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + with pytest.raises(MiddlewareException, match="Context providers may only add chat or function middleware"): + ctx.extend_middleware("rag", provider_agent_middleware) + def test_get_messages_all(self) -> None: ctx = SessionContext(input_messages=[]) ctx.extend_messages("a", [Message(role="user", contents=["a"])]) @@ -154,37 +208,58 @@ class TestSessionContext: ctx._response = resp assert ctx.response is resp + def test_local_history_conversation_id_sentinel(self) -> None: + assert is_local_history_conversation_id(LOCAL_HISTORY_CONVERSATION_ID) is True + assert is_local_history_conversation_id("some_other_id") is False + # --------------------------------------------------------------------------- -# BaseContextProvider tests +# ContextProvider tests # --------------------------------------------------------------------------- -class TestContextProviderBase: +class TestContextProvider: def test_source_id_required(self) -> None: - provider = BaseContextProvider(source_id="test") + provider = ContextProvider(source_id="test") assert provider.source_id == "test" async def test_before_run_is_noop(self) -> None: - provider = BaseContextProvider(source_id="test") + provider = ContextProvider(source_id="test") session = AgentSession() ctx = SessionContext(input_messages=[]) # Should not raise await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] async def test_after_run_is_noop(self) -> None: - provider = BaseContextProvider(source_id="test") + provider = ContextProvider(source_id="test") session = AgentSession() ctx = SessionContext(input_messages=[]) await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] # --------------------------------------------------------------------------- -# BaseHistoryProvider tests +# Deprecated provider alias tests # --------------------------------------------------------------------------- -class ConcreteHistoryProvider(BaseHistoryProvider): +class TestDeprecatedProviderAliases: + def test_base_context_provider_warns_and_is_compatible(self) -> None: + with pytest.warns(DeprecationWarning, match="BaseContextProvider is deprecated. Use ContextProvider instead."): + provider = BaseContextProvider(source_id="test") + + assert isinstance(provider, ContextProvider) + + def test_base_provider_aliases_preserve_subtyping(self) -> None: + assert issubclass(BaseContextProvider, ContextProvider) + assert issubclass(BaseHistoryProvider, HistoryProvider) + + +# --------------------------------------------------------------------------- +# HistoryProvider tests +# --------------------------------------------------------------------------- + + +class ConcreteHistoryProvider(HistoryProvider): """Concrete test implementation.""" def __init__(self, source_id: str, stored_messages: list[Message] | None = None, **kwargs) -> None: diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 6f548b4012..a499abf6f4 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -17,9 +17,9 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, AgentMiddlewareLayer, - BaseContextProvider, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, @@ -50,8 +50,8 @@ else: if TYPE_CHECKING: from agent_framework import ( Agent, - BaseContextProvider, ChatAndFunctionMiddlewareTypes, + ContextProvider, MiddlewareTypes, ToolTypes, ) @@ -224,8 +224,9 @@ class RawFoundryAgentChatClient( # type: ignore[misc] instructions: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -246,6 +247,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] tools=function_tools, context_providers=context_providers, middleware=middleware, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, client_type=cast(type[RawFoundryAgentChatClient], self.__class__), id=id, name=self.agent_name if name is None else name, @@ -468,7 +470,7 @@ class RawFoundryAgent( # type: ignore[misc] project_client: AIProjectClient | None = None, allow_preview: bool | None = None, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, @@ -478,6 +480,7 @@ class RawFoundryAgent( # type: ignore[misc] description: str | None = None, instructions: str | None = None, default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -507,6 +510,8 @@ class RawFoundryAgent( # type: ignore[misc] description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. + require_per_service_call_history_persistence: Whether to require per-service-call + chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. compaction_strategy: Optional agent-level in-run compaction override. tokenizer: Optional agent-level tokenizer override. @@ -548,6 +553,7 @@ class RawFoundryAgent( # type: ignore[misc] default_options=cast(FoundryAgentOptionsT | None, default_options), context_providers=context_providers, middleware=middleware, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, compaction_strategy=compaction_strategy, tokenizer=tokenizer, additional_properties=dict(additional_properties) if additional_properties is not None else None, @@ -661,7 +667,7 @@ class FoundryAgent( # type: ignore[misc] project_client: AIProjectClient | None = None, allow_preview: bool | None = None, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, @@ -671,6 +677,7 @@ class FoundryAgent( # type: ignore[misc] description: str | None = None, instructions: str | None = None, default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + require_per_service_call_history_persistence: bool = False, function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, @@ -696,6 +703,8 @@ class FoundryAgent( # type: ignore[misc] description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. + require_per_service_call_history_persistence: Whether to require per-service-call + chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. compaction_strategy: Optional agent-level in-run compaction override. tokenizer: Optional agent-level tokenizer override. @@ -719,6 +728,7 @@ class FoundryAgent( # type: ignore[misc] description=description, instructions=instructions, default_options=default_options, + require_per_service_call_history_persistence=require_per_service_call_history_persistence, function_invocation_configuration=function_invocation_configuration, compaction_strategy=compaction_strategy, tokenizer=tokenizer, diff --git a/python/packages/foundry/agent_framework_foundry/_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py index 36d4a27a43..742b2e4753 100644 --- a/python/packages/foundry/agent_framework_foundry/_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""Foundry Memory Context Provider using BaseContextProvider. +"""Foundry Memory Context Provider using ContextProvider. This module provides ``FoundryMemoryProvider``, built on -:class:`BaseContextProvider`. +:class:`ContextProvider`. """ from __future__ import annotations @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, AgentSession, - BaseContextProvider, + ContextProvider, Message, SessionContext, load_settings, @@ -46,8 +46,8 @@ class FoundryProjectSettings(TypedDict, total=False): project_endpoint: str | None -class FoundryMemoryProvider(BaseContextProvider): - """Foundry Memory context provider using the new BaseContextProvider hooks pattern. +class FoundryMemoryProvider(ContextProvider): + """Foundry Memory context provider using the new ContextProvider hooks pattern. Integrates Azure AI Foundry Memory Store for persistent semantic memory, searching and storing memories via the Azure AI Projects SDK. diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 69f3bf20d4..8bec66737a 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -15,8 +15,8 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, - BaseContextProvider, Content, + ContextProvider, Message, ResponseStream, normalize_messages, @@ -178,7 +178,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsT | None = None, diff --git a/python/packages/mem0/agent_framework_mem0/_context_provider.py b/python/packages/mem0/agent_framework_mem0/_context_provider.py index 36b878e411..6aef321b15 100644 --- a/python/packages/mem0/agent_framework_mem0/_context_provider.py +++ b/python/packages/mem0/agent_framework_mem0/_context_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Mem0 context provider using BaseContextProvider. +"""New-pattern Mem0 context provider using ContextProvider. This module provides ``Mem0ContextProvider``, built on the new -:class:`BaseContextProvider` hooks pattern. +:class:`ContextProvider` hooks pattern. """ from __future__ import annotations @@ -13,7 +13,7 @@ from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework._sessions import AgentSession, ContextProvider, SessionContext from mem0 import AsyncMemory, AsyncMemoryClient if sys.version_info >= (3, 11): @@ -33,8 +33,8 @@ class _MemorySearchResponse_v1_1(TypedDict): _MemorySearchResponse_v2 = list[dict[str, Any]] -class Mem0ContextProvider(BaseContextProvider): - """Mem0 context provider using the new BaseContextProvider hooks pattern. +class Mem0ContextProvider(ContextProvider): + """Mem0 context provider using the new ContextProvider hooks pattern. Integrates Mem0 for persistent semantic memory, searching and storing memories via the Mem0 API. diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index 4352a8af47..baf91e0134 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -39,7 +39,7 @@ from dataclasses import dataclass from typing import Any from agent_framework import Agent, SupportsAgentRun -from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware +from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination from agent_framework._sessions import AgentSession from agent_framework._tools import FunctionTool, tool from agent_framework._types import AgentResponse, Content, Message @@ -138,8 +138,6 @@ class _AutoHandoffMiddleware(FunctionMiddleware): await call_next() return - from agent_framework._middleware import MiddlewareTermination - # Short-circuit execution and provide deterministic response payload for the tool call. # Parse the result using the default parser to ensure in a form that can be passed directly to LLM APIs. context.result = FunctionTool.parse_result({ @@ -375,6 +373,7 @@ class HandoffAgentExecutor(AgentExecutor): description=agent.description, context_providers=agent.context_providers, middleware=agent.agent_middleware, + require_per_service_call_history_persistence=agent.require_per_service_call_history_persistence, default_options=cloned_options, # type: ignore[assignment] ) diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 5c594ed537..b1524cce85 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -8,10 +8,11 @@ from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework import ( Agent, - BaseContextProvider, ChatResponse, ChatResponseUpdate, Content, + ContextProvider, + InMemoryHistoryProvider, Message, ResponseStream, WorkflowEvent, @@ -695,6 +696,48 @@ def test_handoff_clone_disables_provider_side_storage() -> None: assert executor._agent.default_options.get("store") is False +async def test_handoff_clone_preserves_per_service_call_history_persistence() -> None: + """Handoff clones should keep per-service-call history persistence active for auto-handoff termination.""" + triage_history = InMemoryHistoryProvider() + triage = Agent( + id="triage", + name="triage", + client=MockChatClient(name="triage", handoff_to="specialist"), + context_providers=[triage_history], + require_per_service_call_history_persistence=True, + ) + specialist = Agent( + id="specialist", + name="specialist", + client=MockChatClient(name="specialist"), + default_options={"tool_choice": "none"}, + ) + + workflow = ( + HandoffBuilder(participants=[triage, specialist], termination_condition=lambda _: False) + .with_start_agent(triage) + .add_handoff(triage, [specialist]) + .add_handoff(specialist, [triage]) + .build() + ) + + await _drain(workflow.run("start", stream=True)) + + executor = workflow.executors[resolve_agent_id(triage)] + assert isinstance(executor, HandoffAgentExecutor) + assert executor._agent.require_per_service_call_history_persistence is True + + provider_state = executor._session.state[triage_history.source_id] + stored_messages = await triage_history.get_messages( + executor._session.session_id, + state=provider_state, + ) + + assert [message.role for message in stored_messages] == ["user", "assistant"] + assert any(content.type == "function_call" for content in stored_messages[-1].contents) + assert all(message.role != "tool" for message in stored_messages) + + async def test_handoff_clears_stale_service_session_id_before_run() -> None: """Stale service session IDs must be dropped before each handoff agent turn.""" triage = MockHandoffAgent(name="triage", handoff_to="specialist") @@ -997,7 +1040,7 @@ async def test_context_provider_preserved_during_handoff(): # Track whether context provider methods were called provider_calls: list[str] = [] - class TestContextProvider(BaseContextProvider): + class TestContextProvider(ContextProvider): """A test context provider that tracks its invocations.""" def __init__(self) -> None: diff --git a/python/packages/redis/agent_framework_redis/_context_provider.py b/python/packages/redis/agent_framework_redis/_context_provider.py index 98d5d9917f..3753ab7be1 100644 --- a/python/packages/redis/agent_framework_redis/_context_provider.py +++ b/python/packages/redis/agent_framework_redis/_context_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Redis context provider using BaseContextProvider. +"""New-pattern Redis context provider using ContextProvider. This module provides ``RedisContextProvider``, built on the new -:class:`BaseContextProvider` hooks pattern. +:class:`ContextProvider` hooks pattern. """ from __future__ import annotations @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal import numpy as np from agent_framework import Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework._sessions import AgentSession, ContextProvider, SessionContext from agent_framework.exceptions import ( AgentException, IntegrationInvalidRequestException, @@ -41,8 +41,8 @@ if TYPE_CHECKING: from agent_framework._agents import SupportsAgentRun -class RedisContextProvider(BaseContextProvider): - """Redis context provider using the new BaseContextProvider hooks pattern. +class RedisContextProvider(ContextProvider): + """Redis context provider using the new ContextProvider hooks pattern. Stores context in Redis and retrieves scoped context via full-text or optional hybrid vector search. diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index be2db098b8..dbdc358a93 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""New-pattern Redis history provider using BaseHistoryProvider. +"""New-pattern Redis history provider using HistoryProvider. This module provides ``RedisHistoryProvider``, built on the new -:class:`BaseHistoryProvider` hooks pattern. +:class:`HistoryProvider` hooks pattern. """ from __future__ import annotations @@ -13,12 +13,12 @@ from typing import Any, ClassVar import redis.asyncio as redis from agent_framework import Message -from agent_framework._sessions import BaseHistoryProvider +from agent_framework._sessions import HistoryProvider from redis.credentials import CredentialProvider -class RedisHistoryProvider(BaseHistoryProvider): - """Redis-backed history provider using the new BaseHistoryProvider hooks pattern. +class RedisHistoryProvider(HistoryProvider): + """Redis-backed history provider using the new HistoryProvider hooks pattern. Stores conversation history in Redis Lists, with each session isolated by a unique Redis key. diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py index dd0ff51cd8..54587a55e1 100644 --- a/python/packages/redis/tests/test_providers.py +++ b/python/packages/redis/tests/test_providers.py @@ -475,7 +475,7 @@ class TestRedisHistoryProviderClear: class TestRedisHistoryProviderBeforeAfterRun: - """Test before_run/after_run integration via BaseHistoryProvider defaults.""" + """Test before_run/after_run integration via HistoryProvider defaults.""" async def test_before_run_loads_history(self, mock_redis_client: MagicMock): msg = Message(role="user", contents=["old msg"]) diff --git a/python/samples/01-get-started/04_memory.py b/python/samples/01-get-started/04_memory.py index 763a872ca7..7e0b1e2d5f 100644 --- a/python/samples/01-get-started/04_memory.py +++ b/python/samples/01-get-started/04_memory.py @@ -3,7 +3,7 @@ import asyncio from typing import Any -from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext +from agent_framework import Agent, AgentSession, ContextProvider, SessionContext from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential @@ -17,7 +17,7 @@ responses — the name persists across turns via the session. # -class UserMemoryProvider(BaseContextProvider): +class UserMemoryProvider(ContextProvider): """A context provider that remembers user info in session state.""" DEFAULT_SOURCE_ID = "user_memory" diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index e037877291..80b1e0ea1a 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -9,6 +9,7 @@ This folder contains examples for direct chat client usage patterns. | [`built_in_chat_clients.py`](built_in_chat_clients.py) | Consolidated sample for built-in chat clients. Uses `get_client()` to create the selected client and pass it to `main()`. | | [`chat_response_cancellation.py`](chat_response_cancellation.py) | Demonstrates how to cancel chat responses during streaming, showing proper cancellation handling and cleanup. | | [`custom_chat_client.py`](custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `Agent` using the `as_agent()` method. | +| [`require_per_service_call_history_persistence.py`](require_per_service_call_history_persistence.py) | Compares two otherwise identical `FoundryChatClient` agents with `store=False`; the only difference is whether `require_per_service_call_history_persistence` is enabled, and only the run without it stores the synthesized tool result when middleware terminates the loop early. | ## Selecting a built-in client @@ -35,6 +36,15 @@ Example: uv run samples/02-agents/chat_client/built_in_chat_clients.py ``` +The `require_per_service_call_history_persistence.py` sample uses `FoundryChatClient`, so set the usual Foundry settings first and sign in with the Azure CLI: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="" +az login +uv run samples/02-agents/chat_client/require_per_service_call_history_persistence.py +``` + ## Environment Variables Depending on the selected client, set the appropriate environment variables: diff --git a/python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py b/python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py new file mode 100644 index 0000000000..f3a9a9ddde --- /dev/null +++ b/python/samples/02-agents/chat_client/require_per_service_call_history_persistence.py @@ -0,0 +1,194 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Annotated + +from agent_framework import ( + Agent, + FunctionInvocationContext, + FunctionMiddleware, + InMemoryHistoryProvider, + Message, + MiddlewareTermination, +) +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +""" +Compare Foundry agents with and without per-service-call chat history persistence. + +This sample runs two otherwise identical Foundry agents with ``store=False`` so +history stays local for both runs. + +The sample adds a function middleware that raises ``MiddlewareTermination`` +immediately after the tool runs, so the request stops before a second model +call. + +That early termination is the important difference: + +- Without per-service-call chat history persistence, the synthesized tool result is + still written to local history. +- With ``require_per_service_call_history_persistence=True``, that synthesized tool result is + not written to local history. + +The per-service-call persistence case matches service-side storage behavior. When a terminated +request never sends the tool result back to the service, that result also never +becomes part of the service-managed history. +""" + +# Load environment variables from .env file +load_dotenv() + + +def lookup_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Return a deterministic weather result for the requested location.""" + return f"The weather in {location} is sunny." + + +class TerminateAfterToolMiddleware(FunctionMiddleware): + """Stop the tool loop after the first tool finishes.""" + + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + """Run the tool, then terminate the loop with that tool result.""" + await call_next() + raise MiddlewareTermination(result=context.result) + + +def _describe_message(message: Message) -> str: + """Render one stored message in a compact, readable format.""" + parts: list[str] = [] + for content in message.contents: + if content.type == "text" and content.text: + parts.append(content.text) + elif content.type == "function_call": + parts.append(f"function_call -> {content.name}({content.arguments})") + elif content.type == "function_result": + parts.append(f"function_result -> {content.result}") + else: + parts.append(content.type) + + return f"{message.role}: {' | '.join(parts)}" + + +def _includes_tool_result(messages: list[Message]) -> bool: + """Return whether any stored message contains a tool result.""" + return any(content.type == "function_result" for message in messages for content in message.contents) + + +async def main() -> None: + """Run both comparison scenarios.""" + print("=== require_per_service_call_history_persistence when middleware terminates the tool loop ===\n") + + # 1. Create one Foundry chat client that both agents will share. + client = FoundryChatClient(credential=AzureCliCredential()) + query = "What is the weather in Seattle, and should I bring sunglasses?" + + # 2. Create and run the agent without per-service-call persistence. + agent_without_persistence = Agent( + client=client, + instructions=( + "You are a weather assistant. Call lookup_weather exactly once before answering " + "any weather question, then summarize the tool result in one short paragraph." + ), + tools=[lookup_weather], + context_providers=[InMemoryHistoryProvider()], + middleware=[TerminateAfterToolMiddleware()], + default_options={"tool_choice": "required", "store": False}, + ) + session_without_persistence = agent_without_persistence.create_session() + await agent_without_persistence.run( + query, + session=session_without_persistence, + ) + stored_messages_without_persistence = session_without_persistence.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID][ + "messages" + ] + + print("=== Without per-service-call persistence ===") + print("Loop terminated immediately after the tool finished.") + print(f"Stored synthesized tool result: {_includes_tool_result(stored_messages_without_persistence)}") + print("Stored history:") + for index, message in enumerate(stored_messages_without_persistence, start=1): + print(f" {index}. {_describe_message(message)}") + print() + + # 3. Create and run the agent with per-service-call persistence enabled. + agent_with_persistence = Agent( + client=client, + instructions=( + "You are a weather assistant. Call lookup_weather exactly once before answering " + "any weather question, then summarize the tool result in one short paragraph." + ), + tools=[lookup_weather], + context_providers=[InMemoryHistoryProvider()], + middleware=[TerminateAfterToolMiddleware()], + require_per_service_call_history_persistence=True, + default_options={"tool_choice": "required", "store": False}, + ) + session_with_persistence = agent_with_persistence.create_session() + await agent_with_persistence.run( + query, + session=session_with_persistence, + ) + stored_messages_with_persistence = session_with_persistence.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID][ + "messages" + ] + + print("=== With per-service-call persistence ===") + print("Loop terminated immediately after the tool finished.") + print(f"Stored synthesized tool result: {_includes_tool_result(stored_messages_with_persistence)}") + print("Stored history:") + for index, message in enumerate(stored_messages_with_persistence, start=1): + print(f" {index}. {_describe_message(message)}") + print() + + # 4. Summarize the effect of the flag. + print( + "Both runs used FoundryChatClient with store=False and terminated right after the tool. " + "Without per-service-call persistence, local history still stored the synthesized tool result. " + "With per-service-call persistence, local history stopped at the assistant function-call message instead, " + "which matches service-side storage because the terminated tool result is never sent back to the service." + ) + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +=== require_per_service_call_history_persistence when middleware terminates the tool loop === + +=== Without per-service-call persistence === +Loop terminated immediately after the tool finished. +Stored synthesized tool result: True +Stored history: + 1. user: What is the weather in Seattle, and should I bring sunglasses? + 2. assistant: function_call -> lookup_weather({"location":"Seattle"}) + 3. tool: function_result -> The weather in Seattle is sunny. + +=== With per-service-call persistence === +Loop terminated immediately after the tool finished. +Stored synthesized tool result: False +Stored history: + 1. user: What is the weather in Seattle, and should I bring sunglasses? + 2. assistant: function_call -> lookup_weather({"location":"Seattle"}) + +Both runs used FoundryChatClient with store=False and terminated right after +the tool. Without per-service-call persistence, local history still stored the +synthesized tool result. With per-service-call persistence, local history +stopped at the assistant function-call message instead, which matches +service-side storage because the terminated tool result is never sent back to +the service. +""" diff --git a/python/samples/02-agents/context_providers/simple_context_provider.py b/python/samples/02-agents/context_providers/simple_context_provider.py index 265a93a73e..5f2a0f409a 100644 --- a/python/samples/02-agents/context_providers/simple_context_provider.py +++ b/python/samples/02-agents/context_providers/simple_context_provider.py @@ -5,7 +5,7 @@ import os from contextlib import suppress from typing import Any -from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext, SupportsChatGetResponse +from agent_framework import Agent, AgentSession, ContextProvider, SessionContext, SupportsChatGetResponse from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -20,7 +20,7 @@ class UserInfo(BaseModel): age: int | None = None -class UserInfoMemory(BaseContextProvider): +class UserInfoMemory(ContextProvider): DEFAULT_SOURCE_ID = "user_info_memory" def __init__(self, source_id: str = DEFAULT_SOURCE_ID, *, client: SupportsChatGetResponse, **kwargs: Any): diff --git a/python/samples/02-agents/conversations/custom_history_provider.py b/python/samples/02-agents/conversations/custom_history_provider.py index 59d63b70c0..674e056da2 100644 --- a/python/samples/02-agents/conversations/custom_history_provider.py +++ b/python/samples/02-agents/conversations/custom_history_provider.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Sequence from typing import Any -from agent_framework import Agent, AgentSession, BaseHistoryProvider, Message +from agent_framework import Agent, AgentSession, HistoryProvider, Message from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv @@ -20,7 +20,7 @@ preferred storage solution (database, file system, etc.). """ -class CustomHistoryProvider(BaseHistoryProvider): +class CustomHistoryProvider(HistoryProvider): """Implementation of custom history provider. In real applications, this can be an implementation of relational database or vector store.""" diff --git a/python/samples/05-end-to-end/hosted_agents/README.md b/python/samples/05-end-to-end/hosted_agents/README.md index c343fa66b2..5ee8a7b1b2 100644 --- a/python/samples/05-end-to-end/hosted_agents/README.md +++ b/python/samples/05-end-to-end/hosted_agents/README.md @@ -7,7 +7,7 @@ These samples demonstrate how to build and host AI agents in Python using the [A | Sample | Description | | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`agent_with_hosted_mcp`](./agent_with_hosted_mcp/) | Hosted MCP tool that connects to Microsoft Learn via `https://learn.microsoft.com/api/mcp` | -| [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `BaseContextProvider` with Contoso Outdoors sample data | +| [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `ContextProvider` with Contoso Outdoors sample data | | [`agents_in_workflow`](./agents_in_workflow/) | Concurrent workflow that combines researcher, marketer, and legal specialist agents | | [`agent_with_local_tools`](./agent_with_local_tools/) | Local Python tool execution for Seattle hotel search | | [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `FoundryChatClient` | diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py index ef91d227f4..d7a8bfbf73 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py @@ -5,7 +5,7 @@ import sys from dataclasses import dataclass from typing import Any -from agent_framework import Agent, AgentSession, BaseContextProvider, Message, SessionContext +from agent_framework import Agent, AgentSession, ContextProvider, Message, SessionContext from agent_framework.foundry import FoundryChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] from azure.identity import DefaultAzureCredential @@ -28,7 +28,7 @@ class TextSearchResult: text: str -class TextSearchContextProvider(BaseContextProvider): +class TextSearchContextProvider(ContextProvider): """A simple context provider that simulates text search results based on keywords in the user's message.""" def __init__(self): diff --git a/python/samples/05-end-to-end/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md index 6962a53229..ded1c63f09 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -7,7 +7,7 @@ This sample demonstrates a simple Weather Forecast Agent built with the Python M - Python 3.11+ - [uv](https://github.com/astral-sh/uv) for fast dependency management - [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) -- [Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit) for playground/testing +- `agentsplayground` for playground/testing - Access to OpenAI or Azure OpenAI with a model like `gpt-4o-mini` ## Configuration From 86f8efc8ff9aff74a632b1e1afddd05faf1f8ef3 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Wed, 1 Apr 2026 10:12:03 -0700 Subject: [PATCH 14/20] Python: Fix observability samples (#5016) * Fix observability samples * Update python/samples/02-agents/observability/configure_otel_providers_with_parameters.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/samples/02-agents/observability/agent_observability.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/samples/02-agents/observability/agent_observability.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../advanced_manual_setup_console_output.py | 13 +++++++++---- .../observability/advanced_zero_code.py | 14 +++++++++++--- .../observability/agent_observability.py | 9 ++++++++- .../configure_otel_providers_with_env_var.py | 19 +++++++++++++------ ...onfigure_otel_providers_with_parameters.py | 18 ++++++++++++++---- 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py index f326733b5a..3f16a47455 100644 --- a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py +++ b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py @@ -8,11 +8,12 @@ from typing import Annotated from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import enable_instrumentation +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry._logs import set_logger_provider from opentelemetry.metrics import set_meter_provider from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogRecordExporter from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader from opentelemetry.sdk.resources import Resource @@ -37,7 +38,7 @@ def setup_logging(): # Create and set a global logger provider for the application. logger_provider = LoggerProvider(resource=resource) # Log processors are initialized with an exporter which is responsible - logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) + logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogRecordExporter())) # Sets the global default logger provider set_logger_provider(logger_provider) # Create a logging handler to write logging records, in OTLP format, to the exporter. @@ -115,11 +116,15 @@ async def run_chat_client() -> None: 2 spans with gen_ai.operation.name=execute_tool """ - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) message = "What's the weather in Amsterdam and in Paris?" print(f"User: {message}") print("Assistant: ", end="") - async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True): + async for chunk in client.get_response( + [Message(role="user", text=message)], + stream=True, + options={"tools": [get_weather]}, + ): if chunk.text: print(chunk.text, end="") print("") diff --git a/python/samples/02-agents/observability/advanced_zero_code.py b/python/samples/02-agents/observability/advanced_zero_code.py index 45696baa7b..8f356f4d2e 100644 --- a/python/samples/02-agents/observability/advanced_zero_code.py +++ b/python/samples/02-agents/observability/advanced_zero_code.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Annotated from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id @@ -90,12 +91,19 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals print(f"User: {message}") if stream: print("Assistant: ", end="") - async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True): + async for chunk in client.get_response( + [Message(role="user", text=message)], + stream=True, + options={"tools": [get_weather]}, + ): if chunk.text: print(chunk.text, end="") print("") else: - response = await client.get_response([Message(role="user", text=message)], tools=get_weather) + response = await client.get_response( + [Message(role="user", text=message)], + options={"tools": [get_weather]}, + ) print(f"Assistant: {response}") @@ -103,7 +111,7 @@ async def main() -> None: with get_tracer().start_as_current_span("Zero Code", kind=SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) await run_chat_client(client, stream=True) await run_chat_client(client, stream=False) diff --git a/python/samples/02-agents/observability/agent_observability.py b/python/samples/02-agents/observability/agent_observability.py index 695ff9a43f..5517b32933 100644 --- a/python/samples/02-agents/observability/agent_observability.py +++ b/python/samples/02-agents/observability/agent_observability.py @@ -7,6 +7,7 @@ from typing import Annotated from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id @@ -18,6 +19,12 @@ load_dotenv() """ This sample shows how you can observe an agent in Agent Framework by using the same observability setup function. + +Pre-requisites: +- A Foundry project +- An observability backend to receive traces and metrics (for example, a local or remote + OpenTelemetry Collector, another OTLP-compatible backend, or console exporters enabled + via environment variables). """ @@ -47,7 +54,7 @@ async def main(): print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") agent = Agent( - client=FoundryChatClient(), + client=FoundryChatClient(credential=AzureCliCredential()), tools=get_weather, name="WeatherAgent", instructions="You are a weather assistant.", diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py index 1400c8d839..b9e56df8ff 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Annotated, Literal from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.trace.span import format_trace_id @@ -24,8 +25,9 @@ This sample shows how you can configure observability of an application via the When you run this sample with an OTLP endpoint or an Application Insights connection string, you should see traces, logs, and metrics in the configured backend. -If no OTLP endpoint or Application Insights connection string is configured, the sample will -output traces, logs, and metrics to the console. +Pre-requisites: +- A Foundry project +- A local OpenTelemetry Collector instance to receive the traces and metrics. """ # Load environment variables from .env file @@ -78,13 +80,18 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals if stream: print("Assistant: ", end="") async for chunk in client.get_response( - [Message(role="user", text=message)], tools=get_weather, stream=True + [Message(role="user", text=message)], + stream=True, + options={"tools": [get_weather]}, ): if chunk.text: print(chunk.text, end="") print("") else: - response = await client.get_response([Message(role="user", text=message)], tools=get_weather) + response = await client.get_response( + [Message(role="user", text=message)], + options={"tools": [get_weather]}, + ) print(f"Assistant: {response}") @@ -101,7 +108,7 @@ async def run_tool() -> None: with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): print("Running scenario: AI Function") weather = await get_weather.invoke(location="Amsterdam") - print(f"Weather in Amsterdam:\n{weather}") + print(f"Weather in Amsterdam:\n{weather[-1]}") async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"): @@ -114,7 +121,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. if scenario == "tool" or scenario == "all": diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py index 6bf7a2fcd6..8fd6d76cd0 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Annotated, Literal from agent_framework import Message, tool from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.trace.span import format_trace_id @@ -27,6 +28,10 @@ and allows you to add multiple exporters programmatically. For standard OTLP setup, it's recommended to use environment variables (see configure_otel_providers_with_env_var.py). Use this approach when you need custom exporter configuration beyond what environment variables provide. + +Pre-requisites: +- A Foundry project +- A local OpenTelemetry Collector instance to receive the traces and metrics. """ # Load environment variables from .env file @@ -79,13 +84,18 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals if stream: print("Assistant: ", end="") async for chunk in client.get_response( - [Message(role="user", text=message)], stream=True, tools=get_weather + [Message(role="user", text=message)], + stream=True, + options={"tools": [get_weather]}, ): if chunk.text: print(chunk.text, end="") print("") else: - response = await client.get_response([Message(role="user", text=message)], tools=get_weather) + response = await client.get_response( + [Message(role="user", text=message)], + options={"tools": [get_weather]}, + ) print(f"Assistant: {response}") @@ -102,7 +112,7 @@ async def run_tool() -> None: with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): print("Running scenario: AI Function") weather = await get_weather.invoke(location="Amsterdam") - print(f"Weather in Amsterdam:\n{weather}") + print(f"Weather in Amsterdam:\n{weather[-1]}") async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"): @@ -153,7 +163,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = FoundryChatClient() + client = FoundryChatClient(credential=AzureCliCredential()) # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. if scenario == "tool" or scenario == "all": From 95550dd0dc46ae5efab60af0f1bc4dae8eefb6dd Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Wed, 1 Apr 2026 10:37:27 -0700 Subject: [PATCH 15/20] Python: Enforce Foundry package unit test coverage (#5036) * Enforce Foundry package unit test coverage * Sort ENFORCED_TARGETS alphabetically in python-check-coverage.py Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ed0b81ed-c267-4ee0-9655-56c4b3066fad Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com> --- .github/workflows/python-check-coverage.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-check-coverage.py b/.github/workflows/python-check-coverage.py index af6d38ffea..e40c497c86 100644 --- a/.github/workflows/python-check-coverage.py +++ b/.github/workflows/python-check-coverage.py @@ -34,14 +34,15 @@ from dataclasses import dataclass # (e.g., "packages/core/agent_framework/observability.py") # ============================================================================= ENFORCED_TARGETS: set[str] = { - # Packages + # Packages (sorted alphabetically) + "packages.anthropic.agent_framework_anthropic", + "packages.azure-ai-search.agent_framework_azure_ai_search", "packages.azure-ai.agent_framework_azure_ai", "packages.core.agent_framework", "packages.core.agent_framework._workflows", - "packages.purview.agent_framework_purview", - "packages.anthropic.agent_framework_anthropic", - "packages.azure-ai-search.agent_framework_azure_ai_search", + "packages.foundry.agent_framework_foundry", "packages.openai.agent_framework_openai", + "packages.purview.agent_framework_purview", # Individual files (if you want to enforce specific files instead of whole packages) "packages/core/agent_framework/observability.py", # Add more targets here as coverage improves From 6acab3d1d6e310c319859c585844871f56a0a8c7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 21:00:18 +0200 Subject: [PATCH 16/20] Python: [BREAKING] Standardize model selection on model (#4999) * Refactor Anthropic model option and provider clients Rename the Anthropic client model option from model_id to model, add provider-specific Anthropic wrappers for Foundry, Bedrock, and Vertex, and expose them through the Anthropic, Foundry, Amazon, and Google namespaces. Update core option handling, docs, samples, and tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Anthropic skills sample typing Cast the Anthropic beta client to Any in the skills sample so the pre-commit sample pyright check no longer fails on beta skills and files endpoints that are not exposed by the current SDK stubs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * undo sample mypy * Retry CI after transient external failures Retrigger PR validation after an unrelated Copilot review workflow SAML failure and a transient external tau2 git fetch failure in the Windows Python test setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on model option merging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Anthropic compatibility review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * moved all to `model` * fixes for azure ai search * Python: standardize remaining sample env var names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix foundry-local pyright compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated env vars in cicd --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 16 +- .github/workflows/python-merge-tests.yml | 16 +- .../workflows/python-sample-validation.yml | 62 +++---- python/.env.example | 4 +- python/CODING_STANDARD.md | 8 +- python/README.md | 2 +- .../ag-ui/agent_framework_ag_ui/_types.py | 2 +- .../packages/ag-ui/getting_started/README.md | 12 +- .../packages/ag-ui/getting_started/server.py | 8 +- python/packages/anthropic/AGENTS.md | 5 +- python/packages/anthropic/README.md | 6 + .../agent_framework_anthropic/__init__.py | 9 + .../_bedrock_client.py | 168 ++++++++++++++++++ .../agent_framework_anthropic/_chat_client.py | 96 +++++----- .../_foundry_client.py | 166 +++++++++++++++++ .../_vertex_client.py | 160 +++++++++++++++++ python/packages/anthropic/tests/conftest.py | 2 +- .../anthropic/tests/test_anthropic_client.py | 60 +++++-- .../tests/test_anthropic_provider_clients.py | 156 ++++++++++++++++ .../_context_provider.py | 51 ++---- .../tests/test_aisearch_context_provider.py | 44 +++-- python/packages/azure-ai/AGENTS.md | 2 +- .../_embedding_client.py | 68 +++---- .../agent_framework_azure_ai/_shared.py | 10 +- ...est_azure_ai_inference_embedding_client.py | 52 +++--- python/packages/azure-ai/tests/conftest.py | 2 +- .../samples/cosmos_history_provider.py | 6 +- .../tests/integration_tests/.env.example | 2 +- .../tests/integration_tests/README.md | 2 +- .../tests/integration_tests/conftest.py | 4 +- python/packages/bedrock/AGENTS.md | 2 +- .../agent_framework_bedrock/_chat_client.py | 34 ++-- .../_embedding_client.py | 34 ++-- .../bedrock/test_bedrock_embedding_client.py | 28 +-- .../bedrock/tests/test_bedrock_client.py | 6 +- .../bedrock/tests/test_bedrock_settings.py | 6 +- python/packages/core/README.md | 2 +- .../packages/core/agent_framework/_agents.py | 19 +- .../packages/core/agent_framework/_clients.py | 4 +- python/packages/core/agent_framework/_mcp.py | 2 +- .../core/agent_framework/_middleware.py | 2 +- .../core/agent_framework/_serialization.py | 2 +- .../core/agent_framework/_settings.py | 10 +- .../packages/core/agent_framework/_types.py | 69 ++----- .../core/agent_framework/amazon/__init__.py | 34 ++-- .../core/agent_framework/amazon/__init__.pyi | 3 + .../agent_framework/anthropic/__init__.py | 14 ++ .../agent_framework/anthropic/__init__.pyi | 14 ++ .../core/agent_framework/foundry/__init__.py | 7 +- .../core/agent_framework/foundry/__init__.pyi | 3 + .../core/agent_framework/google/__init__.py | 35 ++++ .../core/agent_framework/google/__init__.pyi | 8 + .../core/agent_framework/observability.py | 17 +- .../packages/core/tests/core/test_agents.py | 32 ++++ .../core/tests/core/test_embedding_client.py | 6 +- .../core/tests/core/test_embedding_types.py | 24 +-- python/packages/core/tests/core/test_mcp.py | 20 +-- .../core/tests/core/test_observability.py | 77 ++++---- python/packages/core/tests/core/test_tools.py | 2 +- python/packages/core/tests/core/test_types.py | 24 ++- .../agent_framework_declarative/_loader.py | 32 ++-- .../tests/test_declarative_loader.py | 4 +- .../devui/agent_framework_devui/_discovery.py | 4 +- .../devui/agent_framework_devui/_mapper.py | 20 +-- .../devui/agent_framework_devui/_session.py | 10 +- .../devui/agent_framework_devui/_utils.py | 14 +- .../models/_discovery_models.py | 2 +- .../agent_framework_devui/ui/assets/index.js | 4 +- python/packages/devui/dev.md | 2 +- .../components/layout/deployment-modal.tsx | 2 +- .../src/data/gallery/sample-entities.ts | 2 +- .../packages/devui/tests/devui/test_server.py | 6 +- .../tests/integration_tests/.env.example | 2 +- .../tests/integration_tests/README.md | 4 +- .../tests/integration_tests/conftest.py | 4 +- .../agent_framework_foundry/_chat_client.py | 17 +- python/packages/foundry_local/AGENTS.md | 2 +- .../_foundry_local_client.py | 21 +-- .../tests/test_foundry_local_client.py | 12 +- python/packages/lab/lightning/README.md | 2 +- .../lab/lightning/samples/train_math_agent.py | 2 +- .../lab/lightning/samples/train_tau2_agent.py | 4 +- python/packages/lab/tau2/README.md | 4 +- .../lab/tau2/samples/run_benchmark.py | 12 +- python/packages/ollama/AGENTS.md | 2 +- .../agent_framework_ollama/_chat_client.py | 29 ++- .../_embedding_client.py | 36 ++-- .../ollama/test_ollama_embedding_client.py | 32 ++-- .../ollama/tests/test_ollama_chat_client.py | 22 +-- python/packages/openai/README.md | 14 +- .../agent_framework_openai/_chat_client.py | 36 ++-- .../_chat_completion_client.py | 38 ++-- .../_embedding_client.py | 39 ++-- .../openai/agent_framework_openai/_shared.py | 47 +++-- .../packages/openai/tests/openai/conftest.py | 43 ++--- .../tests/openai/test_openai_chat_client.py | 18 +- .../openai/test_openai_chat_client_azure.py | 33 ++-- .../test_openai_chat_completion_client.py | 24 +-- ...est_openai_chat_completion_client_azure.py | 30 ++-- .../openai/test_openai_embedding_client.py | 2 +- .../test_openai_embedding_client_azure.py | 37 ++-- python/packages/purview/README.md | 2 +- .../samples/02-agents/chat_client/README.md | 8 +- .../chat_client/chat_response_cancellation.py | 2 +- .../chat_client/custom_chat_client.py | 4 +- .../compaction/tiktoken_tokenizer.py | 6 +- .../azure_ai_foundry_memory.py | 4 +- .../search_context_semantic.py | 4 +- .../samples/02-agents/declarative/README.md | 4 +- python/samples/02-agents/devui/.env.example | 4 +- python/samples/02-agents/devui/README.md | 6 +- .../devui/workflow_with_agents/.env.example | 4 +- .../devui/workflow_with_agents/workflow.py | 2 +- .../azure_ai_inference_embeddings.py | 6 +- .../embeddings/azure_openai_embeddings.py | 4 +- .../evaluation/evaluate_with_expected.py | 2 +- .../02-agents/multimodal_input/README.md | 4 +- .../multimodal_input/azure_chat_multimodal.py | 2 +- .../azure_responses_multimodal.py | 2 +- .../02-agents/providers/amazon/README.md | 4 +- .../providers/amazon/bedrock_chat_client.py | 2 +- .../02-agents/providers/anthropic/README.md | 4 +- .../providers/anthropic/anthropic_basic.py | 4 +- .../providers/anthropic/anthropic_foundry.py | 7 +- .../providers/anthropic/anthropic_skills.py | 4 +- .../02-agents/providers/azure/README.md | 2 +- .../openai_chat_completion_client_basic.py | 4 +- ...ompletion_client_with_explicit_settings.py | 2 +- .../providers/azure/openai_client_basic.py | 4 +- .../02-agents/providers/foundry/README.md | 2 +- .../02-agents/providers/ollama/README.md | 4 +- .../providers/ollama/ollama_agent_basic.py | 2 +- .../ollama/ollama_agent_reasoning.py | 2 +- .../providers/ollama/ollama_chat_client.py | 2 +- .../ollama/ollama_chat_multimodal.py | 2 +- .../skills/code_defined_skill/README.md | 2 +- .../skills/file_based_skill/README.md | 2 +- .../02-agents/skills/mixed_skills/README.md | 2 +- .../skills/script_approval/README.md | 2 +- python/samples/02-agents/typed_options.py | 8 +- python/samples/04-hosting/a2a/a2a_server.py | 6 +- .../02_multi_agent/function_app.py | 2 +- .../11_workflow_parallel/.env.template | 2 +- .../11_workflow_parallel/README.md | 2 +- .../11_workflow_parallel/function_app.py | 4 +- .../local.settings.json.sample | 2 +- .../durabletask/02_multi_agent/client.py | 2 +- .../durabletask/02_multi_agent/sample.py | 2 +- .../durabletask/02_multi_agent/worker.py | 2 +- .../README.md | 2 +- .../client.py | 2 +- .../sample.py | 2 +- .../worker.py | 6 +- .../chatkit-integration/README.md | 2 +- .../foundry_evals/evaluate_agent_sample.py | 2 +- .../foundry_evals/evaluate_mixed_sample.py | 2 +- .../evaluate_multiturn_sample.py | 4 +- .../evaluate_tool_calls_sample.py | 4 +- .../foundry_evals/evaluate_traces_sample.py | 4 +- .../foundry_evals/evaluate_workflow_sample.py | 4 +- .../evaluation/red_teaming/.env.example | 2 +- .../evaluation/red_teaming/README.md | 2 +- .../red_teaming/red_team_agent_sample.py | 2 +- .../05-end-to-end/hosted_agents/README.md | 2 +- .../agent_with_hosted_mcp/agent.yaml | 2 +- .../agent_with_local_tools/.env.sample | 4 +- .../agent_with_local_tools/README.md | 12 +- .../agent_with_local_tools/agent.yaml | 8 +- .../agent_with_local_tools/main.py | 10 +- .../agent_with_text_search_rag/agent.yaml | 2 +- .../agents_in_workflow/agent.yaml | 2 +- .../.env.sample | 4 +- .../README.md | 12 +- .../agent.yaml | 8 +- .../main.py | 16 +- .../05-end-to-end/purview_agent/README.md | 2 +- .../purview_agent/sample_purview_agent.py | 10 +- .../workflow_evaluation/create_workflow.py | 12 +- .../workflow_evaluation/run_evaluation.py | 18 +- python/samples/README.md | 20 +-- .../01_basic_responses_agent.py | 4 +- .../02_responses_agent_with_tool.py | 4 +- .../03_responses_agent_structured_output.py | 4 +- .../orchestrations/magentic.py | 6 +- 184 files changed, 1749 insertions(+), 1025 deletions(-) create mode 100644 python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py create mode 100644 python/packages/anthropic/agent_framework_anthropic/_foundry_client.py create mode 100644 python/packages/anthropic/agent_framework_anthropic/_vertex_client.py create mode 100644 python/packages/anthropic/tests/test_anthropic_provider_clients.py create mode 100644 python/packages/core/agent_framework/google/__init__.py create mode 100644 python/packages/core/agent_framework/google/__init__.pyi diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 71f4267e41..06720cc7dd 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -95,10 +95,10 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -139,7 +139,7 @@ jobs: timeout-minutes: 60 env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -207,8 +207,8 @@ jobs: OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" @@ -256,7 +256,7 @@ jobs: timeout-minutes: 60 env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + AZURE_AI_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 8c8abec84a..12b602d0eb 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -192,10 +192,10 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -255,7 +255,7 @@ jobs: environment: integration env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -336,8 +336,8 @@ jobs: OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" @@ -398,7 +398,7 @@ jobs: environment: integration env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + AZURE_AI_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml index bbb3195d1a..57746935ee 100644 --- a/.github/workflows/python-sample-validation.yml +++ b/.github/workflows/python-sample-validation.yml @@ -65,12 +65,13 @@ jobs: FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} # GitHub MCP GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} @@ -95,11 +96,12 @@ jobs: echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env + echo "AZURE_OPENAI_RESPONSES_MODEL=$AZURE_OPENAI_RESPONSES_MODEL" >> .env + echo "AZURE_OPENAI_EMBEDDING_MODEL=$AZURE_OPENAI_EMBEDDING_MODEL" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env + echo "OPENAI_RESPONSES_MODEL=$OPENAI_RESPONSES_MODEL" >> .env echo "GITHUB_PAT=$GITHUB_PAT" >> .env - name: Run sample validation @@ -120,8 +122,8 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_MODEL: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} defaults: run: working-directory: python @@ -140,8 +142,8 @@ jobs: run: | echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env echo "OPENAI_MODEL=$OPENAI_MODEL" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env + echo "OPENAI_RESPONSES_MODEL=$OPENAI_RESPONSES_MODEL" >> .env - name: Run sample validation run: | @@ -160,7 +162,7 @@ jobs: environment: integration env: AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION || '' }} defaults: run: @@ -179,7 +181,7 @@ jobs: - name: Create .env for samples run: | echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env echo "AZURE_OPENAI_API_VERSION=$AZURE_OPENAI_API_VERSION" >> .env - name: Run sample validation @@ -199,7 +201,7 @@ jobs: environment: integration env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} defaults: run: working-directory: python @@ -217,7 +219,7 @@ jobs: - name: Create .env for samples run: | echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> .env - echo "ANTHROPIC_CHAT_MODEL_ID=$ANTHROPIC_CHAT_MODEL_ID" >> .env + echo "ANTHROPIC_CHAT_MODEL=$ANTHROPIC_CHAT_MODEL" >> .env - name: Run sample validation run: | @@ -265,7 +267,7 @@ jobs: runs-on: ubuntu-latest environment: integration env: - BEDROCK_CHAT_MODEL_ID: ${{ vars.BEDROCK__CHATMODELID }} + BEDROCK_CHAT_MODEL: ${{ vars.BEDROCK__CHATMODELID }} defaults: run: working-directory: python @@ -518,7 +520,7 @@ jobs: FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure AI Search (for evaluation samples) AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }} AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }} @@ -560,11 +562,11 @@ jobs: FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} defaults: run: @@ -585,10 +587,10 @@ jobs: echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env + echo "OPENAI_RESPONSES_MODEL=$OPENAI_RESPONSES_MODEL" >> .env - name: Run sample validation run: | @@ -610,11 +612,11 @@ jobs: FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} # Copilot Studio COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }} @@ -640,10 +642,10 @@ jobs: echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_MODEL=$AZURE_OPENAI_MODEL" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env - echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env - echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env + echo "OPENAI_RESPONSES_MODEL=$OPENAI_RESPONSES_MODEL" >> .env echo "COPILOTSTUDIOAGENT__ENVIRONMENTID=$COPILOTSTUDIOAGENT__ENVIRONMENTID" >> .env echo "COPILOTSTUDIOAGENT__SCHEMANAME=$COPILOTSTUDIOAGENT__SCHEMANAME" >> .env echo "COPILOTSTUDIOAGENT__TENANTID=$COPILOTSTUDIOAGENT__TENANTID" >> .env diff --git a/python/.env.example b/python/.env.example index 4e7ba727e5..e5ae5f09d7 100644 --- a/python/.env.example +++ b/python/.env.example @@ -17,8 +17,8 @@ OPENAI_CHAT_MODEL="" OPENAI_RESPONSES_MODEL="" # Azure OpenAI AZURE_OPENAI_ENDPOINT="" -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="" -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="" +AZURE_OPENAI_CHAT_MODEL="" +AZURE_OPENAI_RESPONSES_MODEL="" # Mem0 MEM0_API_KEY="" # Copilot Studio diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index 22173cfb9a..3ffeb3562d 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -480,7 +480,7 @@ A more complete example with keyword arguments and code samples: ```python def create_client( - model_id: str | None = None, + model: str | None = None, *, timeout: float | None = None, env_file_path: str | None = None, @@ -489,7 +489,7 @@ def create_client( """Create a new client with the specified configuration. Args: - model_id: The model ID to use. If not provided, + model: The model ID to use. If not provided, it will be loaded from settings. Keyword Args: @@ -501,14 +501,14 @@ def create_client( A configured client instance. Raises: - ValueError: If the model_id is invalid. + ValueError: If the model is invalid. Examples: .. code-block:: python # Create a client with default settings: - client = create_client(model_id="gpt-4o") + client = create_client(model="gpt-4o") # Or load from environment: client = create_client(env_file_path=".env") diff --git a/python/README.md b/python/README.md index 1f1977358c..d9d65247ab 100644 --- a/python/README.md +++ b/python/README.md @@ -51,7 +51,7 @@ OPENAI_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... -AZURE_OPENAI_DEPLOYMENT_NAME=... +AZURE_OPENAI_MODEL=... ... FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_MODEL=... diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py index 4c5467bd64..c8ee728df3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_types.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -108,7 +108,7 @@ class AGUIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], tota Keys: # Inherited from ChatOptions (forwarded to remote server): - model_id: The model identifier (forwarded as-is to server). + model: The model identifier (forwarded as-is to server). temperature: Sampling temperature. top_p: Nucleus sampling parameter. max_tokens: Maximum tokens to generate. diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md index 6c414e9c14..71bd855c3a 100644 --- a/python/packages/ag-ui/getting_started/README.md +++ b/python/packages/ag-ui/getting_started/README.md @@ -191,13 +191,13 @@ from fastapi import FastAPI # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") -deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") +model = os.environ.get("AZURE_OPENAI_MODEL") api_key = os.environ.get("AZURE_OPENAI_API_KEY") if not endpoint: raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") -if not deployment_name: - raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") +if not model: + raise ValueError("AZURE_OPENAI_MODEL environment variable is required") if not api_key: raise ValueError("AZURE_OPENAI_API_KEY environment variable is required") @@ -207,7 +207,7 @@ agent = Agent( instructions="You are a helpful assistant.", client=OpenAIChatCompletionClient( azure_endpoint=endpoint, - model=deployment_name, + model=model, api_key=api_key, ), ) @@ -230,7 +230,7 @@ if __name__ == "__main__": - **`Agent`**: The agent that will handle incoming requests - **FastAPI Integration**: Uses FastAPI's native async support for streaming responses - **Instructions**: The agent is created with default instructions, which can be overridden by client messages -- **Configuration**: `OpenAIChatCompletionClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly +- **Configuration**: `OpenAIChatCompletionClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_MODEL`, `AZURE_OPENAI_API_KEY`) or accept parameters directly **Alternative (simpler)**: Use environment variables only: @@ -249,7 +249,7 @@ Set the required environment variables: ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_MODEL="gpt-4o-mini" # Optional: Set API key if not using DefaultAzureCredential # export AZURE_OPENAI_API_KEY="your-api-key" ``` diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py index dd1f4b7326..4dfe0b3614 100644 --- a/python/packages/ag-ui/getting_started/server.py +++ b/python/packages/ag-ui/getting_started/server.py @@ -26,12 +26,12 @@ logger = logging.getLogger(__name__) # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") -deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") +model = os.environ.get("AZURE_OPENAI_MODEL") if not endpoint: raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") -if not deployment_name: - raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") +if not model: + raise ValueError("AZURE_OPENAI_MODEL environment variable is required") # ============================================================================ @@ -121,7 +121,7 @@ agent = Agent( instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.", client=OpenAIChatCompletionClient( azure_endpoint=endpoint, - model=deployment_name, + model=model, ), tools=[get_time_zone], # ONLY server-side tools ) diff --git a/python/packages/anthropic/AGENTS.md b/python/packages/anthropic/AGENTS.md index 748f9a26f0..ce70e9d9a0 100644 --- a/python/packages/anthropic/AGENTS.md +++ b/python/packages/anthropic/AGENTS.md @@ -5,6 +5,9 @@ Integration with Anthropic's Claude API. ## Main Classes - **`AnthropicClient`** - Chat client for Anthropic Claude models +- **`AnthropicFoundryClient`** - Anthropic chat client for Azure AI Foundry's Anthropic-compatible endpoint +- **`AnthropicBedrockClient`** - Anthropic chat client for Amazon Bedrock +- **`AnthropicVertexClient`** - Anthropic chat client for Google Vertex AI - **`AnthropicChatOptions`** - Options TypedDict for Anthropic-specific parameters ## Usage @@ -12,7 +15,7 @@ Integration with Anthropic's Claude API. ```python from agent_framework.anthropic import AnthropicClient -client = AnthropicClient(model_id="claude-sonnet-4-20250514") +client = AnthropicClient(model="claude-sonnet-4-20250514") response = await client.get_response("Hello") ``` diff --git a/python/packages/anthropic/README.md b/python/packages/anthropic/README.md index 2507837d2c..f27b53acdc 100644 --- a/python/packages/anthropic/README.md +++ b/python/packages/anthropic/README.md @@ -10,6 +10,12 @@ pip install agent-framework-anthropic --pre The Anthropic integration enables communication with the Anthropic API, allowing your Agent Framework applications to leverage Anthropic's capabilities. +The package also includes Anthropic-hosted transport wrappers for: + +- Azure AI Foundry via `AnthropicFoundryClient` +- Amazon Bedrock via `AnthropicBedrockClient` +- Google Vertex AI via `AnthropicVertexClient` + ### Basic Usage Example See the [Anthropic agent examples](../../samples/02-agents/providers/anthropic/) which demonstrate: diff --git a/python/packages/anthropic/agent_framework_anthropic/__init__.py b/python/packages/anthropic/agent_framework_anthropic/__init__.py index ad0cff9648..0f4c1c9148 100644 --- a/python/packages/anthropic/agent_framework_anthropic/__init__.py +++ b/python/packages/anthropic/agent_framework_anthropic/__init__.py @@ -2,7 +2,10 @@ import importlib.metadata +from ._bedrock_client import AnthropicBedrockClient, RawAnthropicBedrockClient from ._chat_client import AnthropicChatOptions, AnthropicClient, RawAnthropicClient +from ._foundry_client import AnthropicFoundryClient, RawAnthropicFoundryClient +from ._vertex_client import AnthropicVertexClient, RawAnthropicVertexClient try: __version__ = importlib.metadata.version(__name__) @@ -10,8 +13,14 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode __all__ = [ + "AnthropicBedrockClient", "AnthropicChatOptions", "AnthropicClient", + "AnthropicFoundryClient", + "AnthropicVertexClient", + "RawAnthropicBedrockClient", "RawAnthropicClient", + "RawAnthropicFoundryClient", + "RawAnthropicVertexClient", "__version__", ] diff --git a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py new file mode 100644 index 0000000000..8b7b6bd71b --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, +) +from agent_framework._settings import SecretString, load_settings +from agent_framework.observability import ChatTelemetryLayer +from anthropic import AsyncAnthropicBedrock + +from ._chat_client import AnthropicOptionsT, RawAnthropicClient + + +class AnthropicBedrockSettings(TypedDict, total=False): + """Resolved settings for Anthropic Bedrock wrappers.""" + + aws_access_key_id: SecretString | None + aws_secret_access_key: SecretString | None + aws_region: str | None + aws_profile: str | None + aws_session_token: SecretString | None + anthropic_bedrock_base_url: str | None + anthropic_chat_model: str | None + + +class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[AnthropicOptionsT]): + """Raw Anthropic Bedrock chat client without middleware, telemetry, or function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "aws.bedrock" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model: str | None = None, + aws_secret_key: str | None = None, + aws_access_key: str | None = None, + aws_region: str | None = None, + aws_profile: str | None = None, + aws_session_token: str | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicBedrock | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw Anthropic Bedrock client. + + Keyword Args: + model: The Anthropic model to use. + aws_secret_key: AWS secret access key. + aws_access_key: AWS access key ID. + aws_region: AWS region. + aws_profile: AWS profile name. + aws_session_token: AWS session token. + base_url: Optional custom Anthropic Bedrock base URL. + anthropic_client: Existing AsyncAnthropicBedrock client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + settings = load_settings( + AnthropicBedrockSettings, + env_prefix="", + aws_access_key_id=aws_access_key, + aws_secret_access_key=aws_secret_key, + aws_region=aws_region, + aws_profile=aws_profile, + aws_session_token=aws_session_token, + anthropic_bedrock_base_url=base_url, + anthropic_chat_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + model_setting = settings.get("anthropic_chat_model") + access_key_secret = settings.get("aws_access_key_id") + secret_key_secret = settings.get("aws_secret_access_key") + session_token_secret = settings.get("aws_session_token") + + if anthropic_client is None: + anthropic_client = AsyncAnthropicBedrock( + aws_secret_key=secret_key_secret.get_secret_value() if secret_key_secret is not None else None, + aws_access_key=access_key_secret.get_secret_value() if access_key_secret is not None else None, + aws_region=settings.get("aws_region"), + aws_profile=settings.get("aws_profile"), + aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None, + base_url=settings.get("anthropic_bedrock_base_url"), + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + super().__init__( + model=model_setting, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + ) + + +class AnthropicBedrockClient( # type: ignore[misc] + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicBedrockClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic Bedrock chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + model: str | None = None, + aws_secret_key: str | None = None, + aws_access_key: str | None = None, + aws_region: str | None = None, + aws_profile: str | None = None, + aws_session_token: str | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicBedrock | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic Bedrock client. + + Keyword Args: + model: The Anthropic model to use. + aws_secret_key: AWS secret access key. + aws_access_key: AWS access key ID. + aws_region: AWS region. + aws_profile: AWS profile name. + aws_session_token: AWS session token. + base_url: Optional custom Anthropic Bedrock base URL. + anthropic_client: Existing AsyncAnthropicBedrock client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__( + model=model, + aws_secret_key=aws_secret_key, + aws_access_key=aws_access_key, + aws_region=aws_region, + aws_profile=aws_profile, + aws_session_token=aws_session_token, + base_url=base_url, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index b3b61a4640..ebe03411a3 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -31,7 +31,7 @@ from agent_framework._settings import SecretString, load_settings from agent_framework._tools import SHELL_TOOL_KIND_VALUE from agent_framework._types import _get_data_bytes_as_str # type: ignore from agent_framework.observability import ChatTelemetryLayer -from anthropic import AsyncAnthropic +from anthropic import AsyncAnthropic, AsyncAnthropicBedrock, AsyncAnthropicFoundry, AsyncAnthropicVertex from anthropic.types.beta import ( BetaContentBlock, BetaMessage, @@ -79,6 +79,7 @@ BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08 STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13" ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex # region Anthropic Chat Options TypedDict @@ -113,8 +114,6 @@ class AnthropicChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], a default of 1024 will be used. Keys: - model_id: The model to use for the request, - translates to ``model`` in Anthropic API. temperature: Sampling temperature between 0 and 1. top_p: Nucleus sampling parameter. max_tokens: Maximum number of tokens to generate (REQUIRED). @@ -169,12 +168,24 @@ AnthropicOptionsT = TypeVar( # Translation between framework options keys and Anthropic Messages API OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", "stop": "stop_sequences", "instructions": "system", } +def _apply_option_translations(options: dict[str, Any]) -> None: + """Translate framework option keys to Anthropic request keys in-place. + + When both the old and new key are present, the new key wins and the old key + is discarded to preserve explicit overrides. + """ + for old_key, new_key in OPTION_TRANSLATIONS.items(): + if old_key not in options or old_key == new_key: + continue + old_value = options.pop(old_key) + options.setdefault(new_key, old_value) + + # region Role and Finish Reason Maps @@ -204,11 +215,11 @@ class AnthropicSettings(TypedDict, total=False): Keys: api_key: The Anthropic API key. - chat_model_id: The Anthropic chat model ID. + chat_model: The Anthropic chat model. """ api_key: SecretString | None - chat_model_id: str | None + chat_model: str | None class RawAnthropicClient( @@ -236,8 +247,8 @@ class RawAnthropicClient( self, *, api_key: str | None = None, - model_id: str | None = None, - anthropic_client: AsyncAnthropic | None = None, + model: str | None = None, + anthropic_client: AnthropicAsyncClient | None = None, additional_beta_flags: list[str] | None = None, additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, @@ -247,7 +258,7 @@ class RawAnthropicClient( Keyword Args: api_key: The Anthropic API key to use for authentication. - model_id: The ID of the model to use. + model: The model to use. anthropic_client: An existing Anthropic client to use. If not provided, one will be created. This can be used to further configure the client before passing it in. For instance if you need to set a different base_url for testing or private deployments. @@ -265,11 +276,11 @@ class RawAnthropicClient( # Using environment variables # Set ANTHROPIC_API_KEY=your_anthropic_api_key - # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + # ANTHROPIC_CHAT_MODEL=claude-sonnet-4-5-20250929 # Or passing parameters directly client = RawAnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", api_key="your_anthropic_api_key", ) @@ -283,7 +294,7 @@ class RawAnthropicClient( api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" ) client = RawAnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", anthropic_client=anthropic_client, ) @@ -296,7 +307,7 @@ class RawAnthropicClient( my_custom_option: str - client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model_id="claude-sonnet-4-5-20250929") + client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model="claude-sonnet-4-5-20250929") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ @@ -304,13 +315,13 @@ class RawAnthropicClient( AnthropicSettings, env_prefix="ANTHROPIC_", api_key=api_key, - chat_model_id=model_id, + chat_model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) api_key_secret = anthropic_settings.get("api_key") - model_id_setting = anthropic_settings.get("chat_model_id") + model_setting = anthropic_settings.get("chat_model") if anthropic_client is None: if api_key_secret is None: @@ -332,7 +343,7 @@ class RawAnthropicClient( # Initialize instance variables self.anthropic_client = anthropic_client self.additional_beta_flags = additional_beta_flags or [] - self.model_id = model_id_setting + self.model = model_setting # streaming requires tracking the last function call ID, name, and content type self._last_call_id_name: tuple[str, str] | None = None self._last_call_content_type: str | None = None @@ -513,7 +524,7 @@ class RawAnthropicClient( if stream: # Streaming mode async def _stream() -> AsyncIterable[ChatResponseUpdate]: - async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): + async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): # type: ignore[misc] parsed_chunk = self._process_stream_event(chunk) if parsed_chunk: yield parsed_chunk @@ -522,7 +533,7 @@ class RawAnthropicClient( # Non-streaming mode async def _get_response() -> ChatResponse: - message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) # type: ignore[misc] return self._process_message(message, options) return _get_response() @@ -561,16 +572,22 @@ class RawAnthropicClient( # Stream mode is controlled explicitly at call sites. run_options.pop("stream", None) - # Translation between options keys and Anthropic Messages API - for old_key, new_key in OPTION_TRANSLATIONS.items(): - if old_key in run_options and old_key != new_key: - run_options[new_key] = run_options.pop(old_key) + _apply_option_translations(run_options) - # model id + # Filter out framework kwargs that should not be passed to the Anthropic API. + # This includes underscore-prefixed internal objects (like _function_middleware_pipeline) + # and framework kwargs like 'thread' and 'middleware'. + filtered_kwargs = { + k: v for k, v in kwargs.items() if not k.startswith("_") and k not in {"thread", "middleware"} + } + _apply_option_translations(filtered_kwargs) + run_options.update(filtered_kwargs) + + # model if not run_options.get("model"): - if not self.model_id: - raise ValueError("model_id must be a non-empty string") - run_options["model"] = self.model_id + if not self.model: + raise ValueError("model must be a non-empty string") + run_options["model"] = self.model # max_tokens - Anthropic requires this, default if not provided if not run_options.get("max_tokens"): @@ -607,13 +624,6 @@ class RawAnthropicClient( # Add the structured outputs beta flag run_options["betas"].add(STRUCTURED_OUTPUTS_BETA_FLAG) - # Filter out framework kwargs that should not be passed to the Anthropic API. - # This includes underscore-prefixed internal objects (like _function_middleware_pipeline) - # and framework kwargs like 'thread' and 'middleware'. - filtered_kwargs = { - k: v for k, v in kwargs.items() if not k.startswith("_") and k not in {"thread", "middleware"} - } - run_options.update(filtered_kwargs) return run_options def _prepare_betas(self, options: Mapping[str, Any]) -> set[str]: @@ -918,7 +928,7 @@ class RawAnthropicClient( ) ], usage_details=self._parse_usage_from_anthropic(message.usage), - model_id=message.model, + model=message.model, finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None, response_format=options.get("response_format"), raw_representation=message, @@ -946,7 +956,7 @@ class RawAnthropicClient( *self._parse_contents_from_anthropic(event.message.content), *usage_details, ], - model_id=event.message.model, + model=event.message.model, finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason) if event.message.stop_reason else None, @@ -1396,8 +1406,8 @@ class AnthropicClient( self, *, api_key: str | None = None, - model_id: str | None = None, - anthropic_client: AsyncAnthropic | None = None, + model: str | None = None, + anthropic_client: AnthropicAsyncClient | None = None, additional_beta_flags: list[str] | None = None, additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -1409,7 +1419,7 @@ class AnthropicClient( Keyword Args: api_key: The Anthropic API key to use for authentication. - model_id: The ID of the model to use. + model: The model to use. anthropic_client: An existing Anthropic client to use. If not provided, one will be created. This can be used to further configure the client before passing it in. For instance if you need to set a different base_url for testing or private deployments. @@ -1428,11 +1438,11 @@ class AnthropicClient( # Using environment variables # Set ANTHROPIC_API_KEY=your_anthropic_api_key - # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + # ANTHROPIC_CHAT_MODEL=claude-sonnet-4-5-20250929 # Or passing parameters directly client = AnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", api_key="your_anthropic_api_key", ) @@ -1446,7 +1456,7 @@ class AnthropicClient( api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" ) client = AnthropicClient( - model_id="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-5-20250929", anthropic_client=anthropic_client, ) @@ -1459,12 +1469,12 @@ class AnthropicClient( my_custom_option: str - client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client: AnthropicClient[MyOptions] = AnthropicClient(model="claude-sonnet-4-5-20250929") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ super().__init__( api_key=api_key, - model_id=model_id, + model=model, anthropic_client=anthropic_client, additional_beta_flags=additional_beta_flags, additional_properties=additional_properties, diff --git a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py new file mode 100644 index 0000000000..f5fe37296f --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, +) +from agent_framework._settings import SecretString, load_settings +from agent_framework.observability import ChatTelemetryLayer +from anthropic import AsyncAnthropicFoundry + +from ._chat_client import AnthropicOptionsT, RawAnthropicClient + +AnthropicFoundryAzureADTokenProvider = Callable[[], str | Awaitable[str]] + + +class AnthropicFoundrySettings(TypedDict, total=False): + """Resolved settings for Anthropic Foundry wrappers.""" + + anthropic_foundry_api_key: SecretString | None + anthropic_foundry_resource: str | None + anthropic_foundry_base_url: str | None + anthropic_chat_model: str | None + + +class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[AnthropicOptionsT]): + """Raw Anthropic Foundry chat client without middleware, telemetry, or function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model: str | None = None, + resource: str | None = None, + api_key: str | None = None, + azure_ad_token_provider: AnthropicFoundryAzureADTokenProvider | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicFoundry | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw Anthropic Foundry client. + + Keyword Args: + model: The Anthropic model to use. + resource: The Foundry resource name. + api_key: The Foundry Anthropic API key. + azure_ad_token_provider: Azure AD token provider used by the Anthropic SDK. + base_url: Full Anthropic-compatible Foundry base URL. + anthropic_client: Existing AsyncAnthropicFoundry client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + settings = load_settings( + AnthropicFoundrySettings, + env_prefix="", + anthropic_foundry_api_key=api_key, + anthropic_foundry_resource=resource, + anthropic_foundry_base_url=base_url, + anthropic_chat_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + api_key_secret = settings.get("anthropic_foundry_api_key") + model_setting = settings.get("anthropic_chat_model") + resource_setting = settings.get("anthropic_foundry_resource") + base_url_setting = settings.get("anthropic_foundry_base_url") + api_key_value = api_key_secret.get_secret_value() if api_key_secret is not None else None + + if anthropic_client is None: + if base_url_setting is None and resource_setting is None: + message = ( + "Anthropic Foundry requires either `resource`/`ANTHROPIC_FOUNDRY_RESOURCE` " + "or `base_url`/`ANTHROPIC_FOUNDRY_BASE_URL`." + ) + raise ValueError(message) + if base_url_setting is not None: + anthropic_client = AsyncAnthropicFoundry( + base_url=base_url_setting, + api_key=api_key_value, + azure_ad_token_provider=azure_ad_token_provider, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + else: + anthropic_client = AsyncAnthropicFoundry( + resource=resource_setting, + api_key=api_key_value, + azure_ad_token_provider=azure_ad_token_provider, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + super().__init__( + model=model_setting, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + ) + + +class AnthropicFoundryClient( # type: ignore[misc] + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicFoundryClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic Foundry chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + model: str | None = None, + resource: str | None = None, + api_key: str | None = None, + azure_ad_token_provider: AnthropicFoundryAzureADTokenProvider | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicFoundry | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic Foundry client. + + Keyword Args: + model: The Anthropic model to use. + resource: The Foundry resource name. + api_key: The Foundry Anthropic API key. + azure_ad_token_provider: Azure AD token provider used by the Anthropic SDK. + base_url: Full Anthropic-compatible Foundry base URL. + anthropic_client: Existing AsyncAnthropicFoundry client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__( + model=model, + resource=resource, + api_key=api_key, + azure_ad_token_provider=azure_ad_token_provider, + base_url=base_url, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py new file mode 100644 index 0000000000..73b16af4a7 --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py @@ -0,0 +1,160 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, +) +from agent_framework._settings import load_settings +from agent_framework.observability import ChatTelemetryLayer +from anthropic import NOT_GIVEN, AsyncAnthropicVertex + +from ._chat_client import AnthropicOptionsT, RawAnthropicClient + +if TYPE_CHECKING: + from google.auth.credentials import Credentials as GoogleCredentials + + +class AnthropicVertexSettings(TypedDict, total=False): + """Resolved settings for Anthropic Vertex wrappers.""" + + cloud_ml_region: str | None + anthropic_vertex_project_id: str | None + anthropic_vertex_base_url: str | None + anthropic_chat_model: str | None + + +class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[AnthropicOptionsT]): + """Raw Anthropic Vertex chat client without middleware, telemetry, or function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "google.vertex.ai" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model: str | None = None, + region: str | None = None, + project_id: str | None = None, + access_token: str | None = None, + credentials: GoogleCredentials | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicVertex | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw Anthropic Vertex client. + + Keyword Args: + model: The Anthropic model to use. + region: Vertex region. Falls back to `CLOUD_ML_REGION`. + project_id: Vertex project ID. Falls back to `ANTHROPIC_VERTEX_PROJECT_ID`. + access_token: Explicit OAuth access token. + credentials: Google credentials object. + base_url: Optional custom Anthropic Vertex base URL. + anthropic_client: Existing AsyncAnthropicVertex client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + settings = load_settings( + AnthropicVertexSettings, + env_prefix="", + cloud_ml_region=region, + anthropic_vertex_project_id=project_id, + anthropic_vertex_base_url=base_url, + anthropic_chat_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + model_setting = settings.get("anthropic_chat_model") + region_setting = settings.get("cloud_ml_region") + project_id_setting = settings.get("anthropic_vertex_project_id") + + if anthropic_client is None: + resolved_region = region_setting if region_setting is not None else NOT_GIVEN + resolved_project_id = project_id_setting if project_id_setting is not None else NOT_GIVEN + anthropic_client = AsyncAnthropicVertex( + region=resolved_region, + project_id=resolved_project_id, + access_token=access_token, + credentials=credentials, + base_url=settings.get("anthropic_vertex_base_url"), + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + super().__init__( + model=model_setting, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + ) + + +class AnthropicVertexClient( # type: ignore[misc] + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicVertexClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic Vertex chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + model: str | None = None, + region: str | None = None, + project_id: str | None = None, + access_token: str | None = None, + credentials: GoogleCredentials | None = None, + base_url: str | None = None, + anthropic_client: AsyncAnthropicVertex | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic Vertex client. + + Keyword Args: + model: The Anthropic model to use. + region: Vertex region. Falls back to `CLOUD_ML_REGION`. + project_id: Vertex project ID. Falls back to `ANTHROPIC_VERTEX_PROJECT_ID`. + access_token: Explicit OAuth access token. + credentials: Google credentials object. + base_url: Optional custom Anthropic Vertex base URL. + anthropic_client: Existing AsyncAnthropicVertex client to reuse. + additional_beta_flags: Additional beta flags to enable on the client. + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__( + model=model, + region=region, + project_id=project_id, + access_token=access_token, + credentials=credentials, + base_url=base_url, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/tests/conftest.py b/python/packages/anthropic/tests/conftest.py index a2313f4d39..bbe92abea8 100644 --- a/python/packages/anthropic/tests/conftest.py +++ b/python/packages/anthropic/tests/conftest.py @@ -28,7 +28,7 @@ def anthropic_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): env_vars = { "ANTHROPIC_API_KEY": "test-api-key-12345", - "ANTHROPIC_CHAT_MODEL_ID": "claude-3-5-sonnet-20241022", + "ANTHROPIC_CHAT_MODEL": "claude-3-5-sonnet-20241022", } env_vars.update(override_env_param_dict) # type: ignore diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 258cc275ca..b9e3689e85 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -40,7 +40,7 @@ skip_if_anthropic_integration_tests_disabled = pytest.mark.skipif( def create_test_anthropic_client( mock_anthropic_client: MagicMock, - model_id: str | None = None, + model: str | None = None, anthropic_settings: AnthropicSettings | None = None, ) -> AnthropicClient: """Helper function to create AnthropicClient instances for testing, bypassing normal validation.""" @@ -51,7 +51,7 @@ def create_test_anthropic_client( AnthropicSettings, env_prefix="ANTHROPIC_", api_key="test-api-key-12345", - chat_model_id="claude-3-5-sonnet-20241022", + chat_model="claude-3-5-sonnet-20241022", ) # Create client instance directly @@ -59,7 +59,7 @@ def create_test_anthropic_client( # Set attributes directly client.anthropic_client = mock_anthropic_client - client.model_id = model_id or anthropic_settings["chat_model_id"] + client.model = model or anthropic_settings["chat_model"] client._last_call_id_name = None client._tool_name_aliases = {} client.additional_properties = {} @@ -83,7 +83,7 @@ def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> Non assert settings["api_key"] is not None assert settings["api_key"].get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] - assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert settings["chat_model"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] def test_anthropic_settings_init_with_explicit_values() -> None: @@ -92,12 +92,12 @@ def test_anthropic_settings_init_with_explicit_values() -> None: AnthropicSettings, env_prefix="ANTHROPIC_", api_key="custom-api-key", - chat_model_id="claude-3-opus-20240229", + chat_model="claude-3-opus-20240229", ) assert settings["api_key"] is not None assert settings["api_key"].get_secret_value() == "custom-api-key" - assert settings["chat_model_id"] == "claude-3-opus-20240229" + assert settings["chat_model"] == "claude-3-opus-20240229" @pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) @@ -107,7 +107,7 @@ def test_anthropic_settings_missing_api_key( """Test AnthropicSettings when API key is missing.""" settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_") assert settings["api_key"] is None - assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert settings["chat_model"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] # Client Initialization Tests @@ -115,10 +115,10 @@ def test_anthropic_settings_missing_api_key( def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> None: """Test AnthropicClient initialization with existing anthropic_client.""" - client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022") + client = create_test_anthropic_client(mock_anthropic_client, model="claude-3-5-sonnet-20241022") assert client.anthropic_client is mock_anthropic_client - assert client.model_id == "claude-3-5-sonnet-20241022" + assert client.model == "claude-3-5-sonnet-20241022" assert isinstance(client, SupportsChatGetResponse) @@ -141,11 +141,11 @@ def test_anthropic_client_init_auto_create_client( """Test AnthropicClient initialization with auto-created anthropic_client.""" client = AnthropicClient( api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], - model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"], + model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"], ) assert client.anthropic_client is not None - assert client.model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert client.model == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] def test_anthropic_client_init_missing_api_key() -> None: @@ -153,7 +153,7 @@ def test_anthropic_client_init_missing_api_key() -> None: with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load: mock_load.return_value = { "api_key": None, - "chat_model_id": "claude-3-5-sonnet-20241022", + "chat_model": "claude-3-5-sonnet-20241022", } with pytest.raises(ValueError, match="Anthropic API key is required"): @@ -740,7 +740,7 @@ async def test_prepare_options_basic(mock_anthropic_client: MagicMock) -> None: run_options = client._prepare_options(messages, chat_options) - assert run_options["model"] == client.model_id + assert run_options["model"] == client.model assert run_options["max_tokens"] == 100 assert run_options["temperature"] == 0.7 assert "messages" in run_options @@ -980,7 +980,7 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: response = client._process_message(mock_message, {}) assert response.response_id == "msg_123" - assert response.model_id == "claude-3-5-sonnet-20241022" + assert response.model == "claude-3-5-sonnet-20241022" assert len(response.messages) == 1 assert response.messages[0].role == "assistant" assert len(response.messages[0].contents) == 1 @@ -2036,10 +2036,10 @@ def test_prepare_options_with_instructions(mock_anthropic_client: MagicMock) -> assert result["max_tokens"] == 1024 -def test_prepare_options_missing_model_id(mock_anthropic_client: MagicMock) -> None: - """Test prepare_options raises error when model_id is missing.""" +def test_prepare_options_missing_model(mock_anthropic_client: MagicMock) -> None: + """Test prepare_options raises error when model is missing.""" client = create_test_anthropic_client(mock_anthropic_client) - client.model_id = "" # Set empty model_id + client.model = "" # Set empty model messages = [Message(role="user", contents=[Content.from_text("Hello")])] options = {} @@ -2048,7 +2048,31 @@ def test_prepare_options_missing_model_id(mock_anthropic_client: MagicMock) -> N client._prepare_options(messages, options) raise AssertionError("Expected ValueError") except ValueError as e: - assert "model_id must be a non-empty string" in str(e) + assert "model must be a non-empty string" in str(e) + + +def test_prepare_options_translates_model_option(mock_anthropic_client: MagicMock) -> None: + """Test prepare_options translates model to model for runtime option compatibility.""" + client = create_test_anthropic_client(mock_anthropic_client) + + messages = [Message(role="user", contents=[Content.from_text("Hello")])] + + result = client._prepare_options(messages, {"model": "claude-3-5-sonnet-20241022"}) + + assert result["model"] == "claude-3-5-sonnet-20241022" + assert "model_id" not in result + + +def test_prepare_options_translates_model_kwarg(mock_anthropic_client: MagicMock) -> None: + """Test prepare_options translates model passed as a direct keyword argument.""" + client = create_test_anthropic_client(mock_anthropic_client) + + messages = [Message(role="user", contents=[Content.from_text("Hello")])] + + result = client._prepare_options(messages, {}, model="claude-3-5-sonnet-20241022") + + assert result["model"] == "claude-3-5-sonnet-20241022" + assert "model_id" not in result def test_prepare_options_with_user_metadata(mock_anthropic_client: MagicMock) -> None: diff --git a/python/packages/anthropic/tests/test_anthropic_provider_clients.py b/python/packages/anthropic/tests/test_anthropic_provider_clients.py new file mode 100644 index 0000000000..d076062fe6 --- /dev/null +++ b/python/packages/anthropic/tests/test_anthropic_provider_clients.py @@ -0,0 +1,156 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework.observability import ChatTelemetryLayer + +from agent_framework_anthropic import ( + AnthropicBedrockClient, + AnthropicFoundryClient, + AnthropicVertexClient, + RawAnthropicBedrockClient, + RawAnthropicFoundryClient, + RawAnthropicVertexClient, +) + + +def _create_mock_transport(base_url: str) -> MagicMock: + transport = MagicMock() + transport.base_url = base_url + transport.beta = MagicMock() + transport.beta.messages = MagicMock() + transport.beta.messages.create = AsyncMock() + return transport + + +@pytest.mark.parametrize( + ("public_client", "raw_client"), + [ + (AnthropicFoundryClient, RawAnthropicFoundryClient), + (AnthropicBedrockClient, RawAnthropicBedrockClient), + (AnthropicVertexClient, RawAnthropicVertexClient), + ], +) +def test_provider_client_wraps_raw_client_with_standard_layer_order(public_client, raw_client) -> None: + assert issubclass(public_client, raw_client) + mro = public_client.__mro__ + assert mro.index(FunctionInvocationLayer) < mro.index(ChatMiddlewareLayer) + assert mro.index(ChatMiddlewareLayer) < mro.index(ChatTelemetryLayer) + assert mro.index(ChatTelemetryLayer) < mro.index(raw_client) + + +def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_CHAT_MODEL=claude-foundry-test\n" + "ANTHROPIC_FOUNDRY_API_KEY=test-key\n" + "ANTHROPIC_FOUNDRY_RESOURCE=test-resource\n" + ) + mock_transport = _create_mock_transport("https://test-resource.services.ai.azure.com/anthropic/") + + with patch( + "agent_framework_anthropic._foundry_client.AsyncAnthropicFoundry", return_value=mock_transport + ) as factory: + client = RawAnthropicFoundryClient(env_file_path=str(env_file)) + + assert client.model == "claude-foundry-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + resource="test-resource", + api_key="test-key", + azure_ad_token_provider=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + +def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_CHAT_MODEL=claude-foundry-test\n" + "ANTHROPIC_FOUNDRY_API_KEY=test-key\n" + "ANTHROPIC_FOUNDRY_BASE_URL=https://test-resource.services.ai.azure.com/anthropic/\n" + ) + mock_transport = _create_mock_transport("https://test-resource.services.ai.azure.com/anthropic/") + + with patch( + "agent_framework_anthropic._foundry_client.AsyncAnthropicFoundry", return_value=mock_transport + ) as factory: + client = RawAnthropicFoundryClient(env_file_path=str(env_file)) + + assert client.model == "claude-foundry-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + base_url="https://test-resource.services.ai.azure.com/anthropic/", + api_key="test-key", + azure_ad_token_provider=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + +def test_raw_anthropic_foundry_client_requires_resource_or_base_url() -> None: + with patch("agent_framework_anthropic._foundry_client.load_settings") as mock_load: + mock_load.return_value = { + "anthropic_foundry_api_key": None, + "anthropic_foundry_resource": None, + "anthropic_foundry_base_url": None, + "anthropic_chat_model": None, + } + + with pytest.raises( + ValueError, + match=( + "Anthropic Foundry requires either `resource`/`ANTHROPIC_FOUNDRY_RESOURCE` " + "or `base_url`/`ANTHROPIC_FOUNDRY_BASE_URL`\\." + ), + ): + RawAnthropicFoundryClient() + + +def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> None: + mock_transport = _create_mock_transport("https://bedrock-runtime.us-east-1.amazonaws.com") + + with patch( + "agent_framework_anthropic._bedrock_client.AsyncAnthropicBedrock", return_value=mock_transport + ) as factory: + client = RawAnthropicBedrockClient( + model="claude-bedrock-test", + aws_access_key="access-key", + aws_secret_key="secret-key", + aws_region="us-east-1", + ) + + assert client.model == "claude-bedrock-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + aws_secret_key="secret-key", + aws_access_key="access-key", + aws_region="us-east-1", + aws_profile=None, + aws_session_token=None, + base_url=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + +def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None: + mock_transport = _create_mock_transport("https://us-central1-aiplatform.googleapis.com/v1") + + with patch("agent_framework_anthropic._vertex_client.AsyncAnthropicVertex", return_value=mock_transport) as factory: + client = RawAnthropicVertexClient( + model="claude-vertex-test", + region="us-central1", + project_id="test-project", + ) + + assert client.model == "claude-vertex-test" + assert client.anthropic_client is mock_transport + factory.assert_called_once_with( + region="us-central1", + project_id="test-project", + access_token=None, + credentials=None, + base_url=None, + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index c10e7cb9b8..f27df92a7b 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -180,8 +180,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str | None = None, - model_deployment_name: str | None = None, - model_name: str | None = None, + model: str | None = None, knowledge_base_name: None = None, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, @@ -206,8 +205,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: Embedding provider used for vector search. context_prompt: Custom prompt to prepend to retrieved context. azure_openai_resource_url: Unused in semantic mode. - model_deployment_name: Unused in semantic mode. - model_name: Unused in semantic mode. + model: Unused in semantic mode. knowledge_base_name: Must be ``None`` for this overload. retrieval_instructions: Unused in semantic mode. azure_openai_api_key: Unused in semantic mode. @@ -235,8 +233,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str, - model_deployment_name: str, - model_name: str | None = None, + model: str, knowledge_base_name: None = None, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, @@ -261,8 +258,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: Embedding provider used for vector search. context_prompt: Custom prompt to prepend to retrieved context. azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base creation. - model_deployment_name: Azure OpenAI deployment used by the generated Knowledge Base. - model_name: Underlying model name for the Knowledge Base model configuration. + model: Model used by the generated Knowledge Base. knowledge_base_name: Must be ``None`` for this overload. retrieval_instructions: Custom instructions for Knowledge Base retrieval. azure_openai_api_key: Optional Azure OpenAI API key for Knowledge Base creation. @@ -290,8 +286,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str | None = None, - model_deployment_name: str | None = None, - model_name: str | None = None, + model: str | None = None, knowledge_base_name: str, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, @@ -317,8 +312,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: Embedding provider used for vector search. context_prompt: Custom prompt to prepend to retrieved context. azure_openai_resource_url: Unused when connecting to an existing Knowledge Base. - model_deployment_name: Unused when connecting to an existing Knowledge Base. - model_name: Unused when connecting to an existing Knowledge Base. + model: Unused when connecting to an existing Knowledge Base. retrieval_instructions: Custom instructions for Knowledge Base retrieval. azure_openai_api_key: Unused when connecting to an existing Knowledge Base. knowledge_base_output_mode: Output mode for Knowledge Base retrieval. @@ -345,8 +339,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str | None = None, - model_deployment_name: str | None = None, - model_name: str | None = None, + model: str | None = None, knowledge_base_name: None = None, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, @@ -375,8 +368,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: Embedding provider used for vector search. context_prompt: Custom prompt to prepend to retrieved context. azure_openai_resource_url: Azure OpenAI resource URL when creating a Knowledge Base from an index. - model_deployment_name: Azure OpenAI deployment when creating a Knowledge Base from an index. - model_name: Underlying model name for Knowledge Base model configuration. + model: Model used when creating a Knowledge Base from an index. knowledge_base_name: Resolved from ``env_file_path`` or ``AZURE_SEARCH_KNOWLEDGE_BASE_NAME``. retrieval_instructions: Custom instructions for Knowledge Base retrieval. azure_openai_api_key: Optional Azure OpenAI API key for Knowledge Base creation. @@ -403,8 +395,7 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: EmbeddingFunction | None = None, context_prompt: str | None = None, azure_openai_resource_url: str | None = None, - model_deployment_name: str | None = None, - model_name: str | None = None, + model: str | None = None, knowledge_base_name: str | None = None, retrieval_instructions: str | None = None, azure_openai_api_key: str | None = None, @@ -432,11 +423,8 @@ class AzureAISearchContextProvider(ContextProvider): embedding_function: Async function to generate embeddings or a SupportsGetEmbeddings instance. context_prompt: Custom prompt to prepend to retrieved context. azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base. - model_deployment_name: Model deployment name in Azure OpenAI. - model_name: The underlying model name. - knowledge_base_name: Name of an existing Knowledge Base to use. In agentic mode, - providing this explicitly selects the Knowledge Base-backed setup and ignores any - environment-provided index name. + model: Model name to use for Azure OpenAI vectorization. + knowledge_base_name: Name of an existing Knowledge Base to use. retrieval_instructions: Custom instructions for Knowledge Base retrieval. azure_openai_api_key: Azure OpenAI API key. knowledge_base_output_mode: Output mode for Knowledge Base retrieval. @@ -483,10 +471,8 @@ class AzureAISearchContextProvider(ContextProvider): if ignored_agentic_field is not None: settings[ignored_agentic_field] = None - if mode == "agentic" and settings.get("index_name") and not model_deployment_name: - raise ValueError( - "model_deployment_name is required for agentic mode when creating Knowledge Base from index." - ) + if mode == "agentic" and settings.get("index_name") and not model: + raise ValueError("model is required for agentic mode when creating Knowledge Base from index.") resolved_credential: AzureKeyCredential | AsyncTokenCredential if credential: @@ -512,8 +498,7 @@ class AzureAISearchContextProvider(ContextProvider): self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT self.azure_openai_resource_url = azure_openai_resource_url - self.azure_openai_deployment_name = model_deployment_name - self.model_name = model_name or model_deployment_name + self.azure_openai_model = model self.knowledge_base_name = settings.get("knowledge_base_name") self.retrieval_instructions = retrieval_instructions self.azure_openai_api_key = azure_openai_api_key @@ -762,8 +747,8 @@ class AzureAISearchContextProvider(ContextProvider): raise ValueError("Index client is required when creating Knowledge Base from index") if not self.azure_openai_resource_url: raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required when creating Knowledge Base from index") + if not self.azure_openai_model: + raise ValueError("model is required when creating Knowledge Base from index") if not self.index_name: raise ValueError("index_name is required when creating Knowledge Base from index") @@ -782,8 +767,8 @@ class AzureAISearchContextProvider(ContextProvider): aoai_params = AzureOpenAIVectorizerParameters( resource_url=self.azure_openai_resource_url, - deployment_name=self.azure_openai_deployment_name, - model_name=self.model_name, + deployment_name=self.azure_openai_model, + model_name=self.azure_openai_model, api_key=self.azure_openai_api_key, ) diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 6b87b502d9..945c5079f6 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -155,14 +155,14 @@ class TestInitSemantic: provider = _make_provider(context_prompt="Custom prompt:") assert provider.context_prompt == "Custom prompt:" - def test_model_name_falls_back_to_deployment_name(self) -> None: - """model_name defaults to model_deployment_name when not explicitly set.""" - provider = _make_provider(model_deployment_name="my-deploy") - assert provider.model_name == "my-deploy" + def test_model_is_stored(self) -> None: + """Model is stored on the provider for Azure OpenAI vectorization.""" + provider = _make_provider(model="my-deploy") + assert provider.azure_openai_model == "my-deploy" - def test_model_name_explicit(self) -> None: - provider = _make_provider(model_deployment_name="deploy", model_name="gpt-4") - assert provider.model_name == "gpt-4" + def test_model_explicit(self) -> None: + provider = _make_provider(model="gpt-4") + assert provider.azure_openai_model == "gpt-4" # -- Initialization: credential resolution ------------------------------------ @@ -214,7 +214,7 @@ class TestInitAgenticValidation: knowledge_base_name="kb", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", azure_openai_resource_url="https://aoai.openai.azure.com", ) @@ -227,8 +227,8 @@ class TestInitAgenticValidation: mode="agentic", ) - def test_missing_model_deployment_name_raises(self) -> None: - with pytest.raises(ValueError, match="model_deployment_name"): + def test_missing_model_raises(self) -> None: + with pytest.raises(ValueError, match="model"): AzureAISearchContextProvider( source_id="s", endpoint="https://test.search.windows.net", @@ -256,7 +256,7 @@ class TestInitAgenticValidation: index_name="idx", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", ) def test_agentic_with_kb_name_sets_use_existing(self) -> None: @@ -277,7 +277,7 @@ class TestInitAgenticValidation: index_name="idx", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", azure_openai_resource_url="https://aoai.openai.azure.com", ) assert provider._use_existing_knowledge_base is False @@ -306,7 +306,7 @@ class TestInitAgenticValidation: index_name="idx", api_key="key", mode="agentic", - model_deployment_name="deploy", + model="deploy", azure_openai_resource_url="https://aoai.openai.azure.com", ) @@ -1011,9 +1011,9 @@ class TestEnsureKnowledgeBase: provider.knowledge_base_name = "test-kb" provider._index_client = AsyncMock() provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = None + provider.azure_openai_model = None - with pytest.raises(ValueError, match="model_deployment_name is required"): + with pytest.raises(ValueError, match="model is required"): await provider._ensure_knowledge_base() async def test_missing_index_name_raises(self) -> None: @@ -1023,7 +1023,7 @@ class TestEnsureKnowledgeBase: provider.knowledge_base_name = "test-kb" provider._index_client = AsyncMock() provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" + provider.azure_openai_model = "deploy" provider.index_name = None with pytest.raises(ValueError, match="index_name is required"): @@ -1037,8 +1037,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" mock_index_client = AsyncMock() @@ -1061,8 +1060,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" mock_index_client = AsyncMock() @@ -1083,8 +1081,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" provider.knowledge_base_output_mode = "answer_synthesis" @@ -1105,8 +1102,7 @@ class TestEnsureKnowledgeBase: provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" provider.azure_openai_resource_url = "https://aoai.openai.azure.com" - provider.azure_openai_deployment_name = "deploy" - provider.model_name = "gpt-4" + provider.azure_openai_model = "gpt-4" provider.index_name = "test-index" provider.retrieval_reasoning_effort = "medium" diff --git a/python/packages/azure-ai/AGENTS.md b/python/packages/azure-ai/AGENTS.md index 6a89bec1da..b8244fd480 100644 --- a/python/packages/azure-ai/AGENTS.md +++ b/python/packages/azure-ai/AGENTS.md @@ -18,7 +18,7 @@ from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient client = AzureAIInferenceEmbeddingClient( endpoint="https://.inference.ai.azure.com", api_key="...", - model_id="text-embedding-3-large", + model="text-embedding-3-large", ) result = await client.get_embeddings(["Hello"]) ``` diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py index 3daa678333..ee8709d410 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py @@ -44,7 +44,7 @@ class AzureAIInferenceEmbeddingOptions(EmbeddingGenerationOptions, total=False): from agent_framework_azure_ai import AzureAIInferenceEmbeddingOptions options: AzureAIInferenceEmbeddingOptions = { - "model_id": "text-embedding-3-small", + "model": "text-embedding-3-small", "dimensions": 1536, "input_type": "document", "encoding_format": "float", @@ -54,8 +54,8 @@ class AzureAIInferenceEmbeddingOptions(EmbeddingGenerationOptions, total=False): input_type: str """Input type hint for the model. Common values: ``"text"``, ``"query"``, ``"document"``.""" - image_model_id: str - """Override model for image embeddings. Falls back to the client's ``image_model_id``.""" + image_model: str + """Override model for image embeddings. Falls back to the client's ``image_model``.""" encoding_format: str """Output encoding format. @@ -81,8 +81,8 @@ class AzureAIInferenceEmbeddingSettings(TypedDict, total=False): endpoint: str | None api_key: str | None - embedding_model_id: str | None - image_embedding_model_id: str | None + embedding_model: str | None + image_embedding_model: str | None class RawAzureAIInferenceEmbeddingClient( @@ -97,11 +97,11 @@ class RawAzureAIInferenceEmbeddingClient( are reassembled in the original input order. Keyword Args: - model_id: The text embedding model deployment name (e.g. "text-embedding-3-small"). - Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID. - image_model_id: The image embedding model deployment name (e.g. "Cohere-embed-v3-english"). - Can also be set via environment variable AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID. - Falls back to ``model_id`` if not provided. + model: The text embedding model (e.g. "text-embedding-3-small"). + Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL. + image_model: The image embedding model (e.g. "Cohere-embed-v3-english"). + Can also be set via environment variable AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL. + Falls back to ``model`` if not provided. endpoint: The Azure AI Inference endpoint URL. Can also be set via environment variable AZURE_AI_INFERENCE_ENDPOINT. api_key: API key for authentication. @@ -117,8 +117,8 @@ class RawAzureAIInferenceEmbeddingClient( def __init__( self, *, - model_id: str | None = None, - image_model_id: str | None = None, + model: str | None = None, + image_model: str | None = None, endpoint: str | None = None, api_key: str | None = None, text_client: EmbeddingsClient | None = None, @@ -132,17 +132,17 @@ class RawAzureAIInferenceEmbeddingClient( settings = load_settings( AzureAIInferenceEmbeddingSettings, env_prefix="AZURE_AI_INFERENCE_", - required_fields=["endpoint", "embedding_model_id"], + required_fields=["endpoint", "embedding_model"], endpoint=endpoint, api_key=api_key, - embedding_model_id=model_id, - image_embedding_model_id=image_model_id, + embedding_model=model, + image_embedding_model=image_model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - self.model_id = settings["embedding_model_id"] # type: ignore[reportTypedDictNotRequiredAccess] - self.image_model_id: str = settings.get("image_embedding_model_id") or self.model_id # type: ignore[assignment] + self.model = settings["embedding_model"] # type: ignore[reportTypedDictNotRequiredAccess] + self.image_model: str = settings.get("image_embedding_model") or self.model # type: ignore[assignment] resolved_endpoint = settings["endpoint"] # type: ignore[reportTypedDictNotRequiredAccess] if credential is None and settings.get("api_key"): @@ -202,7 +202,7 @@ class RawAzureAIInferenceEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or an unsupported content type is encountered. + ValueError: If model is not provided or an unsupported content type is encountered. """ if not values: return GeneratedEmbeddings([], options=options) # type: ignore[reportReturnType] @@ -254,8 +254,8 @@ class RawAzureAIInferenceEmbeddingClient( # Embed text inputs. if text_items: - if not (text_model := opts.get("model_id") or self.model_id): - raise ValueError("An model_id is required, either in the client or options, for text inputs.") + if not (text_model := opts.get("model") or self.model): + raise ValueError("A model is required, either in the client or options, for text inputs.") text_inputs = [t for _, t in text_items] response = await self._text_client.embed( input=text_inputs, @@ -268,7 +268,7 @@ class RawAzureAIInferenceEmbeddingClient( embeddings[original_idx] = Embedding( vector=vector, dimensions=len(vector), - model_id=response.model or text_model, + model=response.model or text_model, ) if response.usage: usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( @@ -280,8 +280,8 @@ class RawAzureAIInferenceEmbeddingClient( # Embed image inputs. if image_items: - if not (image_model := opts.get("image_model_id") or self.image_model_id): - raise ValueError("An image_model_id is required, either in the client or options, for image inputs.") + if not (image_model := opts.get("image_model") or self.image_model): + raise ValueError("An image_model is required, either in the client or options, for image inputs.") image_inputs = [img for _, img in image_items] response = await self._image_client.embed( input=image_inputs, @@ -294,7 +294,7 @@ class RawAzureAIInferenceEmbeddingClient( embeddings[original_idx] = Embedding( vector=image_vector, dimensions=len(image_vector), - model_id=response.model or image_model, + model=response.model or image_model, ) if response.usage: usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( @@ -322,11 +322,11 @@ class AzureAIInferenceEmbeddingClient( ``Content.from_data()``. Keyword Args: - model_id: The text embedding model deployment name (e.g. "text-embedding-3-small"). - Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID. - image_model_id: The image embedding model deployment name + model: The text embedding model (e.g. "text-embedding-3-small"). + Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL. + image_model: The image embedding model (e.g. "Cohere-embed-v3-english"). Can also be set via environment variable - AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID. Falls back to ``model_id``. + AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL. Falls back to ``model``. endpoint: The Azure AI Inference endpoint URL. Can also be set via environment variable AZURE_AI_INFERENCE_ENDPOINT. api_key: API key for authentication. @@ -346,8 +346,8 @@ class AzureAIInferenceEmbeddingClient( # Using environment variables # Set AZURE_AI_INFERENCE_ENDPOINT=https://your-endpoint.inference.ai.azure.com # Set AZURE_AI_INFERENCE_API_KEY=your-key - # Set AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID=text-embedding-3-small - # Set AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID=Cohere-embed-v3-english + # Set AZURE_AI_INFERENCE_EMBEDDING_MODEL=text-embedding-3-small + # Set AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL=Cohere-embed-v3-english client = AzureAIInferenceEmbeddingClient() # Text embeddings @@ -368,8 +368,8 @@ class AzureAIInferenceEmbeddingClient( def __init__( self, *, - model_id: str | None = None, - image_model_id: str | None = None, + model: str | None = None, + image_model: str | None = None, endpoint: str | None = None, api_key: str | None = None, text_client: EmbeddingsClient | None = None, @@ -382,8 +382,8 @@ class AzureAIInferenceEmbeddingClient( ) -> None: """Initialize an Azure AI Inference embedding client.""" super().__init__( - model_id=model_id, - image_model_id=image_model_id, + model=model, + image_model=image_model, endpoint=endpoint, api_key=api_key, text_client=text_client, diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 66b92a9fd6..ce167a1f82 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -20,8 +20,8 @@ class AzureAISettings(TypedDict, total=False): Keyword Args: project_endpoint: The Azure AI Project endpoint URL. Can be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - model_deployment_name: The name of the model deployment to use. - Can be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. + model: The name of the model to use. + Can be set via environment variable AZURE_AI_MODEL. env_file_path: If provided, the .env settings are read from this file path location. env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. @@ -32,12 +32,12 @@ class AzureAISettings(TypedDict, total=False): # Using environment variables # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 + # Set AZURE_AI_MODEL=gpt-4 settings = AzureAISettings() # Or passing parameters directly settings = AzureAISettings( - project_endpoint="https://your-project.cognitiveservices.azure.com", model_deployment_name="gpt-4" + project_endpoint="https://your-project.cognitiveservices.azure.com", model="gpt-4" ) # Or loading from a .env file @@ -45,4 +45,4 @@ class AzureAISettings(TypedDict, total=False): """ project_endpoint: str | None - model_deployment_name: str | None + model: str | None diff --git a/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py b/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py index 0ec6a8b811..812c2eaf29 100644 --- a/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py +++ b/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py @@ -60,7 +60,7 @@ def mock_image_client() -> AsyncMock: def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawAzureAIInferenceEmbeddingClient[Any]: """Create a RawAzureAIInferenceEmbeddingClient with mocked SDK clients.""" return RawAzureAIInferenceEmbeddingClient( - model_id="test-model", + model="test-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -72,7 +72,7 @@ def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> Raw def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> AzureAIInferenceEmbeddingClient[Any]: """Create an AzureAIInferenceEmbeddingClient with mocked SDK clients.""" return AzureAIInferenceEmbeddingClient( - model_id="test-model", + model="test-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -162,8 +162,8 @@ class TestRawAzureAIInferenceEmbeddingClient: async def test_model_override_in_options( self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock ) -> None: - """model_id in options overrides the default.""" - options: AzureAIInferenceEmbeddingOptions = {"model_id": "custom-model"} + """model in options overrides the default.""" + options: AzureAIInferenceEmbeddingOptions = {"model": "custom-model"} await raw_client.get_embeddings(["hello"], options=options) call_kwargs = mock_text_client.embed.call_args @@ -196,55 +196,55 @@ class TestRawAzureAIInferenceEmbeddingClient: { "AZURE_AI_INFERENCE_ENDPOINT": "https://env.inference.ai.azure.com", "AZURE_AI_INFERENCE_API_KEY": "env-key", - "AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID": "env-model", + "AZURE_AI_INFERENCE_EMBEDDING_MODEL": "env-model", }, ), patch("agent_framework_azure_ai._embedding_client.EmbeddingsClient"), patch("agent_framework_azure_ai._embedding_client.ImageEmbeddingsClient"), ): client = RawAzureAIInferenceEmbeddingClient() - assert client.model_id == "env-model" - assert client.image_model_id == "env-model" # falls back to model_id + assert client.model == "env-model" + assert client.image_model == "env-model" # falls back to model - def test_image_model_id_from_env(self) -> None: - """image_model_id is loaded from its own environment variable.""" + def test_image_model_from_env(self) -> None: + """image_model is loaded from its own environment variable.""" with ( patch.dict( os.environ, { "AZURE_AI_INFERENCE_ENDPOINT": "https://env.inference.ai.azure.com", "AZURE_AI_INFERENCE_API_KEY": "env-key", - "AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID": "text-model", - "AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID": "image-model", + "AZURE_AI_INFERENCE_EMBEDDING_MODEL": "text-model", + "AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL": "image-model", }, ), patch("agent_framework_azure_ai._embedding_client.EmbeddingsClient"), patch("agent_framework_azure_ai._embedding_client.ImageEmbeddingsClient"), ): client = RawAzureAIInferenceEmbeddingClient() - assert client.model_id == "text-model" - assert client.image_model_id == "image-model" + assert client.model == "text-model" + assert client.image_model == "image-model" - def test_image_model_id_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: - """image_model_id can be set explicitly.""" + def test_image_model_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: + """image_model can be set explicitly.""" client = RawAzureAIInferenceEmbeddingClient( - model_id="text-model", - image_model_id="image-model", + model="text-model", + image_model="image-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, image_client=mock_image_client, ) - assert client.model_id == "text-model" - assert client.image_model_id == "image-model" + assert client.model == "text-model" + assert client.image_model == "image-model" - async def test_image_model_id_sent_to_image_client( + async def test_image_model_sent_to_image_client( self, mock_text_client: AsyncMock, mock_image_client: AsyncMock ) -> None: - """image_model_id is passed to the image client embed call.""" + """image_model is passed to the image client embed call.""" client = RawAzureAIInferenceEmbeddingClient( - model_id="text-model", - image_model_id="image-model", + model="text-model", + image_model="image-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -274,7 +274,7 @@ class TestAzureAIInferenceEmbeddingClient: async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: """OTEL provider name can be overridden.""" client = AzureAIInferenceEmbeddingClient( - model_id="test-model", + model="test-model", endpoint="https://test.inference.ai.azure.com", api_key="test-key", text_client=mock_text_client, @@ -291,7 +291,7 @@ def _integration_tests_enabled() -> bool: return bool( os.environ.get("AZURE_AI_INFERENCE_ENDPOINT") and os.environ.get("AZURE_AI_INFERENCE_API_KEY") - and os.environ.get("AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID") + and os.environ.get("AZURE_AI_INFERENCE_EMBEDDING_MODEL") ) @@ -313,4 +313,4 @@ class TestAzureAIInferenceEmbeddingIntegration: result = await client.get_embeddings(["Hello, world!"]) assert len(result) == 1 assert len(result[0].vector) > 0 - assert result[0].model_id is not None + assert result[0].model is not None diff --git a/python/packages/azure-ai/tests/conftest.py b/python/packages/azure-ai/tests/conftest.py index 3f635e8213..62c09226c4 100644 --- a/python/packages/azure-ai/tests/conftest.py +++ b/python/packages/azure-ai/tests/conftest.py @@ -29,7 +29,7 @@ def azure_ai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): env_vars = { "AZURE_AI_PROJECT_ENDPOINT": "https://test-project.cognitiveservices.azure.com/", - "AZURE_AI_MODEL_DEPLOYMENT_NAME": "test-gpt-4o", + "AZURE_AI_MODEL": "test-gpt-4o", } env_vars.update(override_env_param_dict) # type: ignore diff --git a/python/packages/azure-cosmos/samples/cosmos_history_provider.py b/python/packages/azure-cosmos/samples/cosmos_history_provider.py index 8dd38a7cdc..67235cbf14 100644 --- a/python/packages/azure-cosmos/samples/cosmos_history_provider.py +++ b/python/packages/azure-cosmos/samples/cosmos_history_provider.py @@ -35,7 +35,7 @@ Optional: async def main() -> None: """Run the Cosmos history provider sample with an Agent.""" project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") - deployment_name = os.getenv("FOUNDRY_MODEL") + model = os.getenv("FOUNDRY_MODEL") cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") @@ -43,7 +43,7 @@ async def main() -> None: if ( not project_endpoint - or not deployment_name + or not model or not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name @@ -58,7 +58,7 @@ async def main() -> None: async with AzureCliCredential() as credential: client = FoundryChatClient( project_endpoint=project_endpoint, - model=deployment_name, + model=model, credential=credential, ) diff --git a/python/packages/azurefunctions/tests/integration_tests/.env.example b/python/packages/azurefunctions/tests/integration_tests/.env.example index e956fe48e6..75baa4aa27 100644 --- a/python/packages/azurefunctions/tests/integration_tests/.env.example +++ b/python/packages/azurefunctions/tests/integration_tests/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_MODEL=your-deployment-name FUNCTIONS_WORKER_RUNTIME=python # Azure Functions Configuration diff --git a/python/packages/azurefunctions/tests/integration_tests/README.md b/python/packages/azurefunctions/tests/integration_tests/README.md index 8b1f05f6b0..291d7347f3 100644 --- a/python/packages/azurefunctions/tests/integration_tests/README.md +++ b/python/packages/azurefunctions/tests/integration_tests/README.md @@ -14,7 +14,7 @@ cp .env.example .env Required variables: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` - `AZURE_OPENAI_API_KEY` - `AzureWebJobsStorage` - `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` diff --git a/python/packages/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py index 98ce922f7e..4f180d4921 100644 --- a/python/packages/azurefunctions/tests/integration_tests/conftest.py +++ b/python/packages/azurefunctions/tests/integration_tests/conftest.py @@ -115,7 +115,7 @@ def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]: os.getenv("FOUNDRY_MODEL", "").strip() ) has_azure_openai_config = bool(os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()) and bool( - os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "").strip() + os.getenv("AZURE_OPENAI_MODEL", "").strip() ) if not has_foundry_config and not has_azure_openai_config: return ( @@ -339,7 +339,7 @@ def _load_and_validate_env(sample_path: Path) -> None: "FUNCTIONS_WORKER_RUNTIME", ] if sample_path.name == "11_workflow_parallel": - required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"]) + required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"]) else: required_env_vars.extend(["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"]) diff --git a/python/packages/bedrock/AGENTS.md b/python/packages/bedrock/AGENTS.md index 0ab072ed24..00245229f5 100644 --- a/python/packages/bedrock/AGENTS.md +++ b/python/packages/bedrock/AGENTS.md @@ -14,7 +14,7 @@ Integration with AWS Bedrock for LLM inference. ```python from agent_framework.amazon import BedrockChatClient -client = BedrockChatClient(model_id="anthropic.claude-3-sonnet-20240229-v1:0") +client = BedrockChatClient(model="anthropic.claude-3-sonnet-20240229-v1:0") response = await client.get_response("Hello") ``` diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 0aefbe12f3..3606cdf26b 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -101,7 +101,7 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t Keys: # Inherited from ChatOptions (mapped to Bedrock): - model_id: The Bedrock model identifier, + model: The Bedrock model identifier, translates to ``modelId`` in Bedrock API. temperature: Sampling temperature, translates to ``inferenceConfig.temperature``. @@ -175,7 +175,7 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t BEDROCK_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "modelId", + "model": "modelId", "max_tokens": "maxTokens", "top_p": "topP", "stop": "stopSequences", @@ -209,7 +209,7 @@ class BedrockSettings(TypedDict, total=False): """Bedrock configuration settings pulled from environment variables or .env files.""" region: str | None - chat_model_id: str | None + chat_model: str | None access_key: SecretString | None secret_key: SecretString | None session_token: SecretString | None @@ -230,7 +230,7 @@ class BedrockChatClient( self, *, region: str | None = None, - model_id: str | None = None, + model: str | None = None, access_key: str | None = None, secret_key: str | None = None, session_token: str | None = None, @@ -246,7 +246,7 @@ class BedrockChatClient( Args: region: Region to send Bedrock requests to; falls back to BEDROCK_REGION. - model_id: Default model identifier; falls back to BEDROCK_CHAT_MODEL_ID. + model: Default model identifier; falls back to BEDROCK_CHAT_MODEL. access_key: Optional AWS access key for manual credential injection. secret_key: Optional AWS secret key paired with ``access_key``. session_token: Optional AWS session token for temporary credentials. @@ -264,7 +264,7 @@ class BedrockChatClient( from agent_framework.amazon import BedrockChatClient # Basic usage with default credentials - client = BedrockChatClient(model_id="") + client = BedrockChatClient(model="") # Using custom ChatOptions with type safety: from typing import TypedDict @@ -275,14 +275,14 @@ class BedrockChatClient( my_custom_option: str - client = BedrockChatClient[MyOptions](model_id="") + client = BedrockChatClient[MyOptions](model="") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ settings = load_settings( BedrockSettings, env_prefix="BEDROCK_", region=region, - chat_model_id=model_id, + chat_model=model, access_key=access_key, secret_key=secret_key, session_token=session_token, @@ -290,7 +290,7 @@ class BedrockChatClient( env_file_encoding=env_file_encoding, ) region = settings.get("region") or DEFAULT_REGION - chat_model_id = settings.get("chat_model_id") + chat_model = settings.get("chat_model") if client: self._bedrock_client = client @@ -307,7 +307,7 @@ class BedrockChatClient( middleware=middleware, function_invocation_configuration=function_invocation_configuration, ) - self.model_id = chat_model_id + self.model = chat_model self.region = region @staticmethod @@ -355,7 +355,7 @@ class BedrockChatClient( yield ChatResponseUpdate( response_id=parsed_response.response_id, contents=contents, - model_id=parsed_response.model_id, + model=parsed_response.model, finish_reason=finish_reason, raw_representation=parsed_response.raw_representation, ) @@ -375,10 +375,10 @@ class BedrockChatClient( options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: - model_id = options.get("model_id") or self.model_id - if not model_id: + model = options.get("model") or self.model + if not model: raise ValueError( - "Bedrock model_id is required. Set via chat options or BEDROCK_CHAT_MODEL_ID environment variable." + "Bedrock model is required. Set via chat options or BEDROCK_CHAT_MODEL environment variable." ) system_prompts, conversation = self._prepare_bedrock_messages(messages) @@ -389,7 +389,7 @@ class BedrockChatClient( system_prompts = [{"text": instructions}, *system_prompts] run_options: dict[str, Any] = { - "modelId": model_id, + "modelId": model, "messages": conversation, "inferenceConfig": {"maxTokens": options.get("max_tokens", DEFAULT_MAX_TOKENS)}, } @@ -633,12 +633,12 @@ class BedrockChatClient( usage_details = self._parse_usage(usage_source) finish_reason = self._map_finish_reason(output.get("completionReason") or response.get("stopReason")) response_id = response.get("responseId") or message.get("id") - model_id = response.get("modelId") or output.get("modelId") or self.model_id + model = response.get("modelId") or output.get("modelId") or self.model return ChatResponse( response_id=response_id, messages=[chat_message], usage_details=usage_details, - model_id=model_id, + model=model, finish_reason=finish_reason, raw_representation=response, ) diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py index 3161ed4c88..99250b8248 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -39,7 +39,7 @@ class BedrockEmbeddingSettings(TypedDict, total=False): """Bedrock embedding settings.""" region: str | None - embedding_model_id: str | None + embedding_model: str | None access_key: SecretString | None secret_key: SecretString | None session_token: SecretString | None @@ -56,7 +56,7 @@ class BedrockEmbeddingOptions(EmbeddingGenerationOptions, total=False): from agent_framework_bedrock import BedrockEmbeddingOptions options: BedrockEmbeddingOptions = { - "model_id": "amazon.titan-embed-text-v2:0", + "model": "amazon.titan-embed-text-v2:0", "dimensions": 1024, "normalize": True, } @@ -80,8 +80,8 @@ class RawBedrockEmbeddingClient( """Raw Bedrock embedding client without telemetry. Keyword Args: - model_id: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). - Can also be set via environment variable BEDROCK_EMBEDDING_MODEL_ID. + model: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). + Can also be set via environment variable BEDROCK_EMBEDDING_MODEL. region: AWS region. Will try to load from BEDROCK_REGION env var, if not set, the regular Boto3 configuration/loading applies (which may include other env vars, config files, or instance metadata). @@ -98,7 +98,7 @@ class RawBedrockEmbeddingClient( self, *, region: str | None = None, - model_id: str | None = None, + model: str | None = None, access_key: str | None = None, secret_key: str | None = None, session_token: str | None = None, @@ -112,9 +112,9 @@ class RawBedrockEmbeddingClient( settings = load_settings( BedrockEmbeddingSettings, env_prefix="BEDROCK_", - required_fields=["embedding_model_id"], + required_fields=["embedding_model"], region=region, - embedding_model_id=model_id, + embedding_model=model, access_key=access_key, secret_key=secret_key, session_token=session_token, @@ -143,7 +143,7 @@ class RawBedrockEmbeddingClient( config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), ) - self.model_id: str = settings["embedding_model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] + self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] self.region = resolved_region super().__init__(additional_properties=additional_properties) @@ -170,15 +170,15 @@ class RawBedrockEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or values is empty. + ValueError: If model is not provided or values is empty. """ if not values: return GeneratedEmbeddings([], options=options) opts: dict[str, Any] = dict(options) if options else {} - model = opts.get("model_id") or self.model_id + model = opts.get("model") or self.model if not model: - raise ValueError("model_id is required") + raise ValueError("model is required") embedding_results = await asyncio.gather( *(self._generate_embedding_for_text(opts, model, text) for text in values) @@ -218,7 +218,7 @@ class RawBedrockEmbeddingClient( embedding = Embedding( vector=response_body["embedding"], dimensions=len(response_body["embedding"]), - model_id=model, + model=model, ) input_tokens = int(response_body.get("inputTextTokenCount", 0)) return embedding, input_tokens @@ -234,8 +234,8 @@ class BedrockEmbeddingClient( Uses the Amazon Titan Embeddings model via Bedrock's invoke_model API. Keyword Args: - model_id: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). - Can also be set via environment variable BEDROCK_EMBEDDING_MODEL_ID. + model: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). + Can also be set via environment variable BEDROCK_EMBEDDING_MODEL. region: AWS region. Defaults to "us-east-1". Can also be set via environment variable BEDROCK_REGION. access_key: AWS access key for manual credential injection. @@ -253,7 +253,7 @@ class BedrockEmbeddingClient( # Using default AWS credentials client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", ) # Generate embeddings @@ -267,7 +267,7 @@ class BedrockEmbeddingClient( self, *, region: str | None = None, - model_id: str | None = None, + model: str | None = None, access_key: str | None = None, secret_key: str | None = None, session_token: str | None = None, @@ -281,7 +281,7 @@ class BedrockEmbeddingClient( """Initialize a Bedrock embedding client.""" super().__init__( region=region, - model_id=model_id, + model=model, access_key=access_key, secret_key=secret_key, session_token=session_token, diff --git a/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py b/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py index f6f96254a7..afb32c4158 100644 --- a/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py +++ b/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py @@ -39,17 +39,17 @@ async def test_bedrock_embedding_construction() -> None: """Test construction with explicit parameters.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) - assert client.model_id == "amazon.titan-embed-text-v2:0" + assert client.model == "amazon.titan-embed-text-v2:0" assert client.region == "us-west-2" async def test_bedrock_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that missing model_id raises an error.""" - monkeypatch.delenv("BEDROCK_EMBEDDING_MODEL_ID", raising=False) + """Test that missing model raises an error.""" + monkeypatch.delenv("BEDROCK_EMBEDDING_MODEL", raising=False) from agent_framework.exceptions import SettingNotFoundError with pytest.raises(SettingNotFoundError): @@ -60,7 +60,7 @@ async def test_bedrock_embedding_get_embeddings() -> None: """Test generating embeddings via the Bedrock invoke_model API.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) @@ -71,7 +71,7 @@ async def test_bedrock_embedding_get_embeddings() -> None: assert len(result) == 2 assert len(result[0].vector) == 3 assert len(result[1].vector) == 3 - assert result[0].model_id == "amazon.titan-embed-text-v2:0" + assert result[0].model == "amazon.titan-embed-text-v2:0" assert result.usage == {"input_token_count": 10} # Two calls since Titan processes one input at a time @@ -84,7 +84,7 @@ async def test_bedrock_embedding_get_embeddings_empty_input() -> None: """Test generating embeddings with empty input.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) @@ -100,7 +100,7 @@ async def test_bedrock_embedding_get_embeddings_with_options() -> None: """Test generating embeddings with custom options.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) @@ -120,16 +120,16 @@ async def test_bedrock_embedding_get_embeddings_with_options() -> None: async def test_bedrock_embedding_get_embeddings_no_model_raises() -> None: - """Test that missing model_id at call time raises ValueError.""" + """Test that missing model at call time raises ValueError.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", region="us-west-2", client=stub, ) - client.model_id = None # type: ignore[assignment] + client.model = None # type: ignore[assignment] - with pytest.raises(ValueError, match="model_id is required"): + with pytest.raises(ValueError, match="model is required"): await client.get_embeddings(["hello"]) @@ -137,7 +137,7 @@ async def test_bedrock_embedding_default_region() -> None: """Test that default region is us-east-1.""" stub = _StubBedrockEmbeddingRuntime() client = BedrockEmbeddingClient( - model_id="amazon.titan-embed-text-v2:0", + model="amazon.titan-embed-text-v2:0", client=stub, ) assert client.region == "us-east-1" @@ -146,7 +146,7 @@ async def test_bedrock_embedding_default_region() -> None: # region: Integration Tests skip_if_bedrock_embedding_integration_tests_disabled = pytest.mark.skipif( - os.getenv("BEDROCK_EMBEDDING_MODEL_ID", "") in ("", "test-model") + os.getenv("BEDROCK_EMBEDDING_MODEL", "") in ("", "test-model") or not (os.getenv("AWS_ACCESS_KEY_ID") or os.getenv("BEDROCK_ACCESS_KEY")), reason="No real Bedrock embedding model or AWS credentials provided; skipping integration tests.", ) diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index 1566bff234..fbc241b24c 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -34,7 +34,7 @@ class _StubBedrockRuntime: def _make_client() -> BedrockChatClient: """Create a BedrockChatClient with a stub runtime for unit tests.""" return BedrockChatClient( - model_id="amazon.titan-text", + model="amazon.titan-text", region="us-west-2", client=_StubBedrockRuntime(), ) @@ -43,7 +43,7 @@ def _make_client() -> BedrockChatClient: async def test_get_response_invokes_bedrock_runtime() -> None: stub = _StubBedrockRuntime() client = BedrockChatClient( - model_id="amazon.titan-text", + model="amazon.titan-text", region="us-west-2", client=stub, ) @@ -65,7 +65,7 @@ async def test_get_response_invokes_bedrock_runtime() -> None: def test_build_request_requires_non_system_messages() -> None: client = BedrockChatClient( - model_id="amazon.titan-text", + model="amazon.titan-text", region="us-west-2", client=_StubBedrockRuntime(), ) diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py index 85e417602a..c828c2dc94 100644 --- a/python/packages/bedrock/tests/test_bedrock_settings.py +++ b/python/packages/bedrock/tests/test_bedrock_settings.py @@ -24,7 +24,7 @@ class _WeatherArgs(BaseModel): def _build_client() -> BedrockChatClient: fake_runtime = MagicMock() fake_runtime.converse.return_value = {} - return BedrockChatClient(model_id="test-model", client=fake_runtime) + return BedrockChatClient(model="test-model", client=fake_runtime) def _dummy_weather(location: str) -> str: # pragma: no cover - helper @@ -33,10 +33,10 @@ def _dummy_weather(location: str) -> str: # pragma: no cover - helper def test_settings_load_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BEDROCK_REGION", "us-west-2") - monkeypatch.setenv("BEDROCK_CHAT_MODEL_ID", "anthropic.claude-v2") + monkeypatch.setenv("BEDROCK_CHAT_MODEL", "anthropic.claude-v2") settings = load_settings(BedrockSettings, env_prefix="BEDROCK_") assert settings["region"] == "us-west-2" - assert settings["chat_model_id"] == "anthropic.claude-v2" + assert settings["chat_model"] == "anthropic.claude-v2" def test_build_request_includes_tool_config() -> None: diff --git a/python/packages/core/README.md b/python/packages/core/README.md index cb03025db5..d817b3b3be 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -39,7 +39,7 @@ OPENAI_RESPONSES_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... -AZURE_OPENAI_DEPLOYMENT_NAME=... +AZURE_OPENAI_MODEL=... ``` You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index b857a6377f..6f1be6c323 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -100,6 +100,7 @@ def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, A new merged options dict. """ result = dict(base) + for key, value in override.items(): if value is None: continue @@ -596,7 +597,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] from agent_framework.openai import OpenAIChatClient # Create a basic chat agent - client = OpenAIChatClient(model_id="gpt-4") + client = OpenAIChatClient(model="gpt-4") agent = Agent(client=client, name="assistant", description="A helpful assistant") # Run the agent with a simple message @@ -634,7 +635,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] from agent_framework import Agent from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions - client = OpenAIChatClient(model_id="gpt-4o") + client = OpenAIChatClient(model="gpt-4o") agent: Agent[OpenAIChatOptions] = Agent( client=client, name="reasoning-agent", @@ -692,7 +693,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] service-managed conversation instead. default_options: A TypedDict containing chat options. When using a typed agent like ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for - provider-specific options including temperature, max_tokens, model_id, + provider-specific options including temperature, max_tokens, model, tool_choice, and provider-specific options like reasoning_effort. You can also create your own TypedDict for custom chat clients. Note: response_format typing does not flow into run outputs when set via default_options. @@ -736,9 +737,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] self.mcp_tools: list[MCPTool] = [tool for tool in normalized_tools if isinstance(tool, MCPTool)] agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)] + model = opts.pop("model", None) or getattr(self.client, "model", None) + # Build chat options dict self.default_options: dict[str, Any] = { - "model_id": opts.pop("model_id", None) or (getattr(self.client, "model_id", None)), "allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None), "conversation_id": opts.pop("conversation_id", None), "frequency_penalty": opts.pop("frequency_penalty", None), @@ -758,6 +760,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "user": opts.pop("user", None), **opts, # Remaining options are provider-specific } + if model is not None: + self.default_options["model"] = model # Remove None values from chat_options self.default_options = {k: v for k, v in self.default_options.items() if v is not None} self._async_exit_stack = AsyncExitStack() @@ -914,7 +918,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tools: The tools to use for this specific run (merged with default tools). options: A TypedDict containing chat options. When using a typed agent like ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for - provider-specific options including temperature, max_tokens, model_id, + provider-specific options including temperature, max_tokens, model, tool_choice, and provider-specific options like reasoning_effort. compaction_strategy: Optional per-run compaction override passed to ``client.get_response()``. When omitted, the agent-level override @@ -1243,9 +1247,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] ) additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} + model = opts.pop("model", None) + # Build options dict from run() options merged with provided options run_opts: dict[str, Any] = { - "model_id": opts.pop("model_id", None), "conversation_id": active_session.service_session_id if active_session else opts.pop("conversation_id", None), @@ -1266,6 +1271,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "user": opts.pop("user", None), **opts, # Remaining options are provider-specific } + if model is not None: + run_opts["model"] = model # Remove None values and merge with chat_options run_opts = {k: v for k, v in run_opts.items() if v is not None} co = _merge_options(chat_options, run_opts) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 41bcf25883..cd810f8675 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -592,7 +592,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): tools: The tools to use for the request. default_options: A TypedDict containing chat options. When using a typed client like ``OpenAIChatClient``, this enables IDE autocomplete for provider-specific options - including temperature, max_tokens, model_id, tool_choice, and more. + including temperature, max_tokens, model, tool_choice, and more. Note: response_format typing does not flow into run outputs when set via default_options, and dict literals are accepted without specialized option typing. context_providers: Context providers to include during agent invocation. @@ -617,7 +617,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): from agent_framework.openai import OpenAIChatClient # Create a client - client = OpenAIChatClient(model_id="gpt-4") + client = OpenAIChatClient(model="gpt-4") # Create an agent using the convenience method agent = client.as_agent( diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index e40bb46f00..9dfb29932f 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -819,7 +819,7 @@ class MCPTool: return types.CreateMessageResult( role="assistant", content=mcp_content, - model=response.model_id or "unknown", + model=response.model or "unknown", ) async def logging_callback(self, params: types.LoggingMessageNotificationParams) -> None: diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 31950e0d7b..eb87ba1946 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -294,7 +294,7 @@ class ChatContext: async def process(self, context: ChatContext, call_next): print(f"Chat client: {context.chat_client.__class__.__name__}") print(f"Messages: {len(context.messages)}") - print(f"Model: {context.options.get('model_id')}") + print(f"Model: {context.options.get('model')}") # Store metadata context.metadata["input_tokens"] = self.count_tokens(context.messages) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 30c5b80fa1..3313bf04a7 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -431,7 +431,7 @@ class SerializationMixin: # Serialized data contains only the model configuration client_data = { "type": "open_ai_chat_client", - "model_id": "gpt-4o-mini", + "model": "gpt-4o-mini", # client is excluded from serialization } diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py index 4eecf3434d..9f9ab29a39 100644 --- a/python/packages/core/agent_framework/_settings.py +++ b/python/packages/core/agent_framework/_settings.py @@ -11,21 +11,21 @@ Usage:: class MySettings(TypedDict, total=False): api_key: str | None # optional — resolves to None if not set - model_id: str | None # optional by default + model: str | None # optional by default source_a: str | None source_b: str | None - # Make model_id required; require exactly one of source_a / source_b: + # Make model required; require exactly one of source_a / source_b: settings = load_settings( MySettings, env_prefix="MY_APP_", - required_fields=["model_id", ("source_a", "source_b")], - model_id="gpt-4", + required_fields=["model", ("source_a", "source_b")], + model="gpt-4", source_a="value", ) settings["api_key"] # type-checked dict access - settings["model_id"] # str | None per type, but guaranteed not None at runtime + settings["model"] # str | None per type, but guaranteed not None at runtime """ from __future__ import annotations diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index bd468b8450..ccad977f21 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1872,8 +1872,8 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse response.conversation_id = update.conversation_id if update.finish_reason is not None: response.finish_reason = update.finish_reason - if update.model_id is not None: - response.model_id = update.model_id + if update.model is not None: + response.model = update.model response.continuation_token = update.continuation_token @@ -1956,7 +1956,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): messages: The list of chat messages in the response. response_id: The ID of the chat response. conversation_id: An identifier for the state of the conversation. - model_id: The model ID used in the creation of the chat response. + model: The model used in the creation of the chat response. created_at: A timestamp for the chat response. finish_reason: The reason for the chat response. usage_details: The usage details for the chat response. @@ -1979,7 +1979,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): response = ChatResponse( messages=[msg], finish_reason="stop", - model_id="gpt-4", + model="gpt-4", ) print(response.text) # "The weather is sunny." @@ -1989,13 +1989,13 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): # Serialization - to_dict and from_dict response_dict = response.to_dict() - # {'type': 'chat_response', 'messages': [...], 'model_id': 'gpt-4', 'finish_reason': 'stop'} + # {'type': 'chat_response', 'messages': [...], 'model': 'gpt-4', 'finish_reason': 'stop'} restored_response = ChatResponse.from_dict(response_dict) - print(restored_response.model_id) # "gpt-4" + print(restored_response.model) # "gpt-4" # Serialization - to_json and from_json response_json = response.to_json() - # '{"type": "chat_response", "messages": [...], "model_id": "gpt-4", ...}' + # '{"type": "chat_response", "messages": [...], "model": "gpt-4", ...}' restored_from_json = ChatResponse.from_json(response_json) print(restored_from_json.text) # "The weather is sunny." """ @@ -2010,7 +2010,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): response_id: str | None = None, conversation_id: str | None = None, model: str | None = None, - model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, usage_details: UsageDetails | None = None, @@ -2027,7 +2026,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): response_id: Optional ID of the chat response. conversation_id: Optional identifier for the state of the conversation. model: Optional model used in the creation of the chat response. - model_id: Deprecated alias for ``model``. created_at: Optional timestamp for when the response was created. finish_reason: Optional reason for the chat response (e.g., "stop", "length", "tool_calls"). usage_details: Optional usage details for the chat response. @@ -2038,8 +2036,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): additional_properties: Optional additional properties associated with the chat response. raw_representation: Optional raw representation of the chat response from an underlying implementation. """ - if model_id is not None and model is None: - model = model_id if messages is None: self.messages: list[Message] = [] elif isinstance(messages, Message): @@ -2082,15 +2078,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """Return whether conversation_id is internal control-flow state.""" return bool(self.additional_properties.get(self._INTERNAL_CONVERSATION_ID_KEY, False)) - @property - def model_id(self) -> str | None: - """Deprecated alias for :attr:`model`.""" - return self.model - - @model_id.setter - def model_id(self, value: str | None) -> None: - self.model = value - @overload @classmethod def from_updates( @@ -2243,7 +2230,7 @@ class ChatResponseUpdate(SerializationMixin): response_id: The ID of the response of which this update is a part. message_id: The ID of the message of which this update is a part. conversation_id: An identifier for the state of the conversation of which this update is a part. - model_id: The model ID associated with this response update. + model: The model associated with this response update. created_at: A timestamp for the chat response update. finish_reason: The finish reason for the operation. additional_properties: Any additional properties associated with the chat response update. @@ -2289,7 +2276,6 @@ class ChatResponseUpdate(SerializationMixin): message_id: str | None = None, conversation_id: str | None = None, model: str | None = None, - model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, continuation_token: ContinuationToken | None = None, @@ -2306,7 +2292,6 @@ class ChatResponseUpdate(SerializationMixin): message_id: Optional ID of the message of which this update is a part. conversation_id: Optional identifier for the state of the conversation of which this update is a part model: Optional model associated with this response update. - model_id: Deprecated alias for ``model``. created_at: Optional timestamp for the chat response update. finish_reason: Optional finish reason for the operation. continuation_token: Optional token for resuming a long-running background operation. @@ -2316,8 +2301,6 @@ class ChatResponseUpdate(SerializationMixin): from an underlying implementation. """ - if model_id is not None and model is None: - model = model_id # Handle contents - support dict conversion for from_dict if contents is None: self.contents: list[Content] = [] @@ -2347,15 +2330,6 @@ class ChatResponseUpdate(SerializationMixin): ) self.raw_representation = raw_representation - @property - def model_id(self) -> str | None: - """Deprecated alias for :attr:`model`.""" - return self.model - - @model_id.setter - def model_id(self, value: str | None) -> None: - self.model = value - @property def text(self) -> str: """Returns the concatenated text of all contents in the update.""" @@ -3121,12 +3095,12 @@ class _ChatOptionsBase(TypedDict, total=False): options: ChatOptions = { "temperature": 0.7, "max_tokens": 1000, - "model_id": "gpt-4", + "model": "gpt-4", } # With tools options_with_tools: ChatOptions = { - "model_id": "gpt-4", + "model": "gpt-4", "tool_choice": "auto", "temperature": 0.7, } @@ -3136,8 +3110,7 @@ class _ChatOptionsBase(TypedDict, total=False): """ # Model selection - model_id: str - + model: str # Generation parameters temperature: float top_p: float @@ -3373,10 +3346,10 @@ def merge_chat_options( from agent_framework import merge_chat_options - base = {"temperature": 0.5, "model_id": "gpt-4"} + base = {"temperature": 0.5, "model": "gpt-4"} override = {"temperature": 0.7, "max_tokens": 1000} merged = merge_chat_options(base, override) - # {"temperature": 0.7, "model_id": "gpt-4", "max_tokens": 1000} + # {"temperature": 0.7, "model": "gpt-4", "max_tokens": 1000} """ if not base: return dict(override) if override else {} @@ -3453,12 +3426,12 @@ class EmbeddingGenerationOptions(TypedDict, total=False): from agent_framework import EmbeddingGenerationOptions options: EmbeddingGenerationOptions = { - "model_id": "text-embedding-3-small", + "model": "text-embedding-3-small", "dimensions": 1536, } """ - model_id: str + model: str dimensions: int @@ -3492,13 +3465,10 @@ class Embedding(Generic[EmbeddingT]): vector: EmbeddingT, *, model: str | None = None, - model_id: str | None = None, dimensions: int | None = None, created_at: datetime | None = None, additional_properties: dict[str, Any] | None = None, ) -> None: - if model_id is not None and model is None: - model = model_id self.vector = vector self._dimensions = dimensions self.model = model @@ -3507,15 +3477,6 @@ class Embedding(Generic[EmbeddingT]): _restore_compaction_annotation_in_additional_properties(additional_properties) or {} ) - @property - def model_id(self) -> str | None: - """Deprecated alias for :attr:`model`.""" - return self.model - - @model_id.setter - def model_id(self, value: str | None) -> None: - self.model = value - @property def dimensions(self) -> int | None: """Return the number of dimensions in the embedding vector. diff --git a/python/packages/core/agent_framework/amazon/__init__.py b/python/packages/core/agent_framework/amazon/__init__.py index e9282b8873..92eaa1ca5e 100644 --- a/python/packages/core/agent_framework/amazon/__init__.py +++ b/python/packages/core/agent_framework/amazon/__init__.py @@ -3,9 +3,11 @@ """Amazon Bedrock integration namespace for optional Agent Framework connectors. This module lazily re-exports objects from: +- ``agent-framework-anthropic`` - ``agent-framework-bedrock`` Supported classes: +- AnthropicBedrockClient - BedrockChatClient - BedrockChatOptions - BedrockEmbeddingClient @@ -13,34 +15,36 @@ Supported classes: - BedrockEmbeddingSettings - BedrockGuardrailConfig - BedrockSettings +- RawAnthropicBedrockClient """ import importlib from typing import Any -IMPORT_PATH = "agent_framework_bedrock" -PACKAGE_NAME = "agent-framework-bedrock" -_IMPORTS = [ - "BedrockChatClient", - "BedrockChatOptions", - "BedrockEmbeddingClient", - "BedrockEmbeddingOptions", - "BedrockEmbeddingSettings", - "BedrockGuardrailConfig", - "BedrockSettings", -] +_IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "BedrockChatClient": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockChatOptions": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockEmbeddingClient": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockEmbeddingOptions": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockEmbeddingSettings": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockGuardrailConfig": ("agent_framework_bedrock", "agent-framework-bedrock"), + "BedrockSettings": ("agent_framework_bedrock", "agent-framework-bedrock"), + "RawAnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), +} def __getattr__(name: str) -> Any: if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] try: - return getattr(importlib.import_module(IMPORT_PATH), name) + return getattr(importlib.import_module(import_path), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" + f"The '{package_name}' package is not installed, please do `pip install {package_name}`" ) from exc - raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") + raise AttributeError(f"Module `amazon` has no attribute {name}.") def __dir__() -> list[str]: - return _IMPORTS + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/amazon/__init__.pyi b/python/packages/core/agent_framework/amazon/__init__.pyi index a9dd7a9117..064639232a 100644 --- a/python/packages/core/agent_framework/amazon/__init__.pyi +++ b/python/packages/core/agent_framework/amazon/__init__.pyi @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +from agent_framework_anthropic import AnthropicBedrockClient, RawAnthropicBedrockClient from agent_framework_bedrock import ( BedrockChatClient, BedrockChatOptions, @@ -11,6 +12,7 @@ from agent_framework_bedrock import ( ) __all__ = [ + "AnthropicBedrockClient", "BedrockChatClient", "BedrockChatOptions", "BedrockEmbeddingClient", @@ -18,4 +20,5 @@ __all__ = [ "BedrockEmbeddingSettings", "BedrockGuardrailConfig", "BedrockSettings", + "RawAnthropicBedrockClient", ] diff --git a/python/packages/core/agent_framework/anthropic/__init__.py b/python/packages/core/agent_framework/anthropic/__init__.py index 8be2a7d208..9d2baaa088 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.py +++ b/python/packages/core/agent_framework/anthropic/__init__.py @@ -7,22 +7,36 @@ This module lazily re-exports objects from: - ``agent-framework-claude`` Supported classes: +- AnthropicBedrockClient - AnthropicClient - AnthropicChatOptions +- AnthropicFoundryClient +- AnthropicVertexClient - ClaudeAgent - ClaudeAgentOptions +- RawAnthropicBedrockClient +- RawAnthropicClient +- RawAnthropicFoundryClient - RawClaudeAgent +- RawAnthropicVertexClient """ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "AnthropicClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "AnthropicChatOptions": ("agent_framework_anthropic", "agent-framework-anthropic"), + "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "AnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "ClaudeAgent": ("agent_framework_claude", "agent-framework-claude"), "ClaudeAgentOptions": ("agent_framework_claude", "agent-framework-claude"), + "RawAnthropicBedrockClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "RawAnthropicClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "RawClaudeAgent": ("agent_framework_claude", "agent-framework-claude"), + "RawAnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), } diff --git a/python/packages/core/agent_framework/anthropic/__init__.pyi b/python/packages/core/agent_framework/anthropic/__init__.pyi index 29d70f62b8..1fa62c7f1b 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.pyi +++ b/python/packages/core/agent_framework/anthropic/__init__.pyi @@ -1,14 +1,28 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework_anthropic import ( + AnthropicBedrockClient, AnthropicChatOptions, AnthropicClient, + AnthropicFoundryClient, + AnthropicVertexClient, + RawAnthropicBedrockClient, + RawAnthropicClient, + RawAnthropicFoundryClient, + RawAnthropicVertexClient, ) from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions __all__ = [ + "AnthropicBedrockClient", "AnthropicChatOptions", "AnthropicClient", + "AnthropicFoundryClient", + "AnthropicVertexClient", "ClaudeAgent", "ClaudeAgentOptions", + "RawAnthropicBedrockClient", + "RawAnthropicClient", + "RawAnthropicFoundryClient", + "RawAnthropicVertexClient", ] diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 0ebf0a9389..6e922f4b3a 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -2,13 +2,17 @@ """Foundry integration namespace for optional Agent Framework connectors. -This module lazily re-exports objects from cloud Foundry and Foundry Local connector packages. +This module lazily re-exports objects from: +- ``agent-framework-anthropic`` +- ``agent-framework-foundry`` +- ``agent-framework-foundry-local`` """ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"), @@ -17,6 +21,7 @@ _IMPORTS: dict[str, tuple[str, str]] = { "FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"), "FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"), "FoundryLocalSettings": ("agent_framework_foundry_local", "agent-framework-foundry-local"), + "RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "RawFoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 534b7fa5bc..0d37d3a7d2 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -3,6 +3,7 @@ # Type stubs for the agent_framework.foundry lazy-loading namespace. # Install the relevant packages for full type support. +from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient from agent_framework_foundry import ( FoundryAgent, FoundryChatClient, @@ -22,6 +23,7 @@ from agent_framework_foundry_local import ( ) __all__ = [ + "AnthropicFoundryClient", "FoundryAgent", "FoundryChatClient", "FoundryChatOptions", @@ -30,6 +32,7 @@ __all__ = [ "FoundryLocalClient", "FoundryLocalSettings", "FoundryMemoryProvider", + "RawAnthropicFoundryClient", "RawFoundryAgent", "RawFoundryAgentChatClient", "RawFoundryChatClient", diff --git a/python/packages/core/agent_framework/google/__init__.py b/python/packages/core/agent_framework/google/__init__.py new file mode 100644 index 0000000000..20bcc591c8 --- /dev/null +++ b/python/packages/core/agent_framework/google/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Google integration namespace for optional Agent Framework connectors. + +This module lazily re-exports Google-hosted Anthropic clients from: +- ``agent-framework-anthropic`` + +Supported classes: +- AnthropicVertexClient +- RawAnthropicVertexClient +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "AnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "RawAnthropicVertexClient": ("agent_framework_anthropic", "agent-framework-anthropic"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{package_name}' package is not installed, please do `pip install {package_name}`" + ) from exc + raise AttributeError(f"Module `google` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/google/__init__.pyi b/python/packages/core/agent_framework/google/__init__.pyi new file mode 100644 index 0000000000..350f3cd328 --- /dev/null +++ b/python/packages/core/agent_framework/google/__init__.pyi @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_anthropic import AnthropicVertexClient, RawAnthropicVertexClient + +__all__ = [ + "AnthropicVertexClient", + "RawAnthropicVertexClient", +] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index cdc6179602..8d2eb05136 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1265,15 +1265,13 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): opts: dict[str, Any] = options or {} # type: ignore[assignment] provider_name = str(getattr(self, "otel_provider_name", "unknown")) - model_id = ( - merged_client_kwargs.get("model_id") or opts.get("model_id") or getattr(self, "model_id", None) or "unknown" - ) + model = merged_client_kwargs.get("model") or opts.get("model") or getattr(self, "model", None) or "unknown" service_url_func = getattr(self, "service_url", None) service_url = str(service_url_func() if callable(service_url_func) else "unknown") attributes = _get_span_attributes( operation_name=OtelAttr.CHAT_COMPLETION_OPERATION, provider_name=provider_name, - model=model_id, + model=model, service_url=service_url, **merged_client_kwargs, ) @@ -1449,13 +1447,13 @@ class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOpti opts: dict[str, Any] = options or {} # type: ignore[assignment] provider_name = str(getattr(self, "otel_provider_name", "unknown")) - model_id = opts.get("model_id") or getattr(self, "model_id", None) or "unknown" + model = opts.get("model") or getattr(self, "model", None) or "unknown" service_url_func = getattr(self, "service_url", None) service_url = str(service_url_func() if callable(service_url_func) else "unknown") attributes = _get_span_attributes( operation_name=OtelAttr.EMBEDDING_OPERATION, provider_name=provider_name, - model=model_id, + model=model, service_url=service_url, ) @@ -1866,8 +1864,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non "agent_id": (OtelAttr.AGENT_ID, None, False, None), "agent_name": (OtelAttr.AGENT_NAME, None, False, None), "agent_description": (OtelAttr.AGENT_DESCRIPTION, None, False, None), - # Multiple source keys - checks model_id in options, then model in kwargs, then model_id in kwargs - ("model_id", "model"): (OtelAttr.REQUEST_MODEL, None, True, None), + "model": (OtelAttr.REQUEST_MODEL, None, True, None), # Tools with validation - returns None if no valid tools "tools": ( OtelAttr.TOOL_DEFINITIONS, @@ -2054,8 +2051,8 @@ def _get_response_attributes( ) if finish_reason: attributes[OtelAttr.FINISH_REASONS] = json.dumps([finish_reason]) - if model_id := getattr(response, "model_id", None): - attributes[OtelAttr.RESPONSE_MODEL] = model_id + if model := getattr(response, "model", None): + attributes[OtelAttr.RESPONSE_MODEL] = model if capture_usage and (usage := response.usage_details): input_tokens = usage.get("input_token_count") if input_tokens: diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 015de2a2e3..f61221b526 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -154,6 +154,24 @@ def test_chat_client_agent_type(client: SupportsChatGetResponse) -> None: assert isinstance(chat_client_agent, SupportsAgentRun) +def test_chat_client_agent_uses_client_model_attribute(chat_client_base) -> None: + chat_client_base.model = "claude-model" # type: ignore[attr-defined] + + agent = Agent(client=chat_client_base) + + assert agent.default_options["model"] == "claude-model" + assert "model_id" not in agent.default_options + + +def test_chat_client_agent_prefers_default_model_over_client_model(chat_client_base) -> None: + chat_client_base.model = "legacy-model" # type: ignore[attr-defined] + + agent = Agent(client=chat_client_base, default_options={"model": "claude-model"}) + + assert agent.default_options["model"] == "claude-model" + assert "model_id" not in agent.default_options + + def test_agent_init_docstring_surfaces_raw_agent_constructor_docs() -> None: docstring = inspect.getdoc(Agent.__init__) @@ -1926,6 +1944,20 @@ def test_merge_options_none_values_ignored(): assert result["key2"] == "value2" +def test_merge_options_runtime_model_overrides_default_model() -> None: + """Test _merge_options lets a runtime model override a default model.""" + result = _merge_options({"model": "default-model"}, {"model": "runtime-model"}) + + assert result["model"] == "runtime-model" + + +def test_merge_options_preserves_base_model_without_override() -> None: + """Test _merge_options preserves the base model when there is no override.""" + result = _merge_options({"model": "preferred-model"}, {}) + + assert result["model"] == "preferred-model" + + def test_merge_options_tools_combined(): """Test _merge_options raises when distinct tools share the same name.""" diff --git a/python/packages/core/tests/core/test_embedding_client.py b/python/packages/core/tests/core/test_embedding_client.py index 1c49c1d012..38a7b74bdc 100644 --- a/python/packages/core/tests/core/test_embedding_client.py +++ b/python/packages/core/tests/core/test_embedding_client.py @@ -25,7 +25,7 @@ class MockEmbeddingClient(BaseEmbeddingClient): options: EmbeddingGenerationOptions | None = None, ) -> GeneratedEmbeddings[list[float]]: return GeneratedEmbeddings( - [Embedding(vector=[0.1, 0.2, 0.3], model_id="mock-model") for _ in values], + [Embedding(vector=[0.1, 0.2, 0.3], model="mock-model") for _ in values], usage={"prompt_tokens": len(values), "total_tokens": len(values)}, ) @@ -38,12 +38,12 @@ async def test_base_get_embeddings() -> None: result = await client.get_embeddings(["hello", "world"]) assert len(result) == 2 assert result[0].vector == [0.1, 0.2, 0.3] - assert result[0].model_id == "mock-model" + assert result[0].model == "mock-model" async def test_base_get_embeddings_with_options() -> None: client = MockEmbeddingClient() - options: EmbeddingGenerationOptions = {"model_id": "test", "dimensions": 3} + options: EmbeddingGenerationOptions = {"model": "test", "dimensions": 3} result = await client.get_embeddings(["hello"], options=options) assert len(result) == 1 diff --git a/python/packages/core/tests/core/test_embedding_types.py b/python/packages/core/tests/core/test_embedding_types.py index 0d6db6b27e..95c5ede612 100644 --- a/python/packages/core/tests/core/test_embedding_types.py +++ b/python/packages/core/tests/core/test_embedding_types.py @@ -12,7 +12,7 @@ from agent_framework import Embedding, EmbeddingGenerationOptions, GeneratedEmbe def test_embedding_basic_construction() -> None: embedding = Embedding(vector=[0.1, 0.2, 0.3]) assert embedding.vector == [0.1, 0.2, 0.3] - assert embedding.model_id is None + assert embedding.model is None assert embedding.created_at is None assert embedding.additional_properties == {} @@ -21,11 +21,11 @@ def test_embedding_construction_with_metadata() -> None: now = datetime.now() embedding = Embedding( vector=[0.1, 0.2], - model_id="text-embedding-3-small", + model="text-embedding-3-small", created_at=now, additional_properties={"key": "value"}, ) - assert embedding.model_id == "text-embedding-3-small" + assert embedding.model == "text-embedding-3-small" assert embedding.created_at == now assert embedding.additional_properties == {"key": "value"} @@ -96,7 +96,7 @@ def test_generated_construction_with_usage() -> None: [ Embedding( vector=[0.1], - model_id="test-model", + model="test-model", ) ], usage=usage, @@ -113,13 +113,13 @@ def test_generated_construction_with_additional_properties() -> None: def test_generated_construction_with_options() -> None: - opts: EmbeddingGenerationOptions = {"model_id": "text-embedding-3-small", "dimensions": 256} + opts: EmbeddingGenerationOptions = {"model": "text-embedding-3-small", "dimensions": 256} embeddings = GeneratedEmbeddings( [Embedding(vector=[0.1])], options=opts, ) assert embeddings.options is not None - assert embeddings.options["model_id"] == "text-embedding-3-small" + assert embeddings.options["model"] == "text-embedding-3-small" assert embeddings.options["dimensions"] == 256 @@ -160,12 +160,12 @@ def test_generated_none_embeddings_creates_empty_list() -> None: def test_options_empty() -> None: options: EmbeddingGenerationOptions = {} - assert "model_id" not in options + assert "model" not in options -def test_options_with_model_id() -> None: - options: EmbeddingGenerationOptions = {"model_id": "text-embedding-3-small"} - assert options["model_id"] == "text-embedding-3-small" +def test_options_with_model() -> None: + options: EmbeddingGenerationOptions = {"model": "text-embedding-3-small"} + assert options["model"] == "text-embedding-3-small" def test_options_with_dimensions() -> None: @@ -175,8 +175,8 @@ def test_options_with_dimensions() -> None: def test_options_with_all_fields() -> None: options: EmbeddingGenerationOptions = { - "model_id": "text-embedding-3-small", + "model": "text-embedding-3-small", "dimensions": 1536, } - assert options["model_id"] == "text-embedding-3-small" + assert options["model"] == "text-embedding-3-small" assert options["dimensions"] == 1536 diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 72b219b7a6..01cf1717bd 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1727,7 +1727,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): ], ) ] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1778,7 +1778,7 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre tool.client.get_response.return_value = Mock( messages=[Message(role="assistant", contents=[Content.from_text("Hello")])], - model_id="test-model", + model="test-model", ) success = await tool.sampling_callback(Mock(), params) @@ -1808,7 +1808,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1843,7 +1843,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1889,7 +1889,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1924,7 +1924,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1959,7 +1959,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -1994,7 +1994,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options( mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -2035,7 +2035,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client @@ -2072,7 +2072,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] - mock_response.model_id = "test-model" + mock_response.model = "test-model" mock_chat_client.get_response.return_value = mock_response tool.client = mock_chat_client diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 332ee2b6e6..ec99c73af1 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -207,7 +207,7 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo messages = [Message(role="user", text="Test message")] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -222,6 +222,23 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None +@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) +async def test_chat_client_observability_accepts_model_option( + mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data +): + """Test that telemetry also captures the modern model option.""" + client = mock_chat_client() + + messages = [Message(role="user", text="Test message")] + span_exporter.clear() + response = await client.get_response(messages=messages, options={"model": "Test"}) + assert response is not None + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.attributes[OtelAttr.REQUEST_MODEL] == "Test" + + @pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) async def test_chat_client_streaming_observability( mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data @@ -232,7 +249,7 @@ async def test_chat_client_streaming_observability( span_exporter.clear() # Collect all yielded updates updates = [] - stream = client.get_response(stream=True, messages=messages, options={"model_id": "Test"}) + stream = client.get_response(stream=True, messages=messages, options={"model": "Test"}) async for update in stream: updates.append(update) await stream.get_final_response() @@ -260,7 +277,7 @@ async def test_chat_client_observability_with_instructions( client = mock_chat_client() messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test", "instructions": "You are a helpful assistant."} + options = {"model": "Test", "instructions": "You are a helpful assistant."} span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -289,7 +306,7 @@ async def test_chat_client_streaming_observability_with_instructions( client = mock_chat_client() messages = [Message(role="user", text="Test")] - options = {"model_id": "Test", "instructions": "You are a helpful assistant."} + options = {"model": "Test", "instructions": "You are a helpful assistant."} span_exporter.clear() updates = [] @@ -318,7 +335,7 @@ async def test_chat_client_observability_without_instructions( client = mock_chat_client() messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test"} # No instructions + options = {"model": "Test"} # No instructions span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -339,7 +356,7 @@ async def test_chat_client_observability_with_empty_instructions( client = mock_chat_client() messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test", "instructions": ""} # Empty string + options = {"model": "Test", "instructions": ""} # Empty string span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -362,7 +379,7 @@ async def test_chat_client_observability_with_list_instructions( client = mock_chat_client() messages = [Message(role="user", text="Test message")] - options = {"model_id": "Test", "instructions": ["Instruction 1", "Instruction 2"]} + options = {"model": "Test", "instructions": ["Instruction 1", "Instruction 2"]} span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -379,8 +396,8 @@ async def test_chat_client_observability_with_list_instructions( assert system_instructions[1]["content"] == "Instruction 2" -async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter): - """Test telemetry shouldn't fail when the model_id is not provided for unknown reason.""" +async def test_chat_client_without_model_observability(mock_chat_client, span_exporter: InMemorySpanExporter): + """Test telemetry shouldn't fail when the model is not provided for unknown reason.""" client = mock_chat_client() messages = [Message(role="user", text="Test")] span_exporter.clear() @@ -396,10 +413,8 @@ async def test_chat_client_without_model_id_observability(mock_chat_client, span assert span.attributes[OtelAttr.REQUEST_MODEL] == "unknown" -async def test_chat_client_streaming_without_model_id_observability( - mock_chat_client, span_exporter: InMemorySpanExporter -): - """Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason.""" +async def test_chat_client_streaming_without_model_observability(mock_chat_client, span_exporter: InMemorySpanExporter): + """Test streaming telemetry shouldn't fail when the model is not provided for unknown reason.""" client = mock_chat_client() messages = [Message(role="user", text="Test")] span_exporter.clear() @@ -441,7 +456,7 @@ def mock_chat_agent(): self.id = "test_agent_id" self.name = "test_agent" self.description = "Test agent description" - self.default_options: dict[str, Any] = {"model_id": "TestModel"} + self.default_options: dict[str, Any] = {"model": "TestModel"} def run(self, messages=None, *, session=None, stream=False, **kwargs): if stream: @@ -1540,7 +1555,7 @@ async def test_chat_client_observability_exception(mock_chat_client, span_export span_exporter.clear() with pytest.raises(ValueError, match="Test error"): - await client.get_response(messages=messages, options={"model_id": "Test"}) + await client.get_response(messages=messages, options={"model": "Test"}) spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -1570,7 +1585,7 @@ async def test_chat_client_streaming_observability_exception(mock_chat_client, s span_exporter.clear() with pytest.raises(ValueError, match="Streaming error"): - async for _ in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): + async for _ in client.get_response(messages=messages, stream=True, options={"model": "Test"}): pass spans = span_exporter.get_finished_spans() @@ -1651,8 +1666,8 @@ def test_get_response_attributes_with_finish_reason(): assert OtelAttr.FINISH_REASONS in result -def test_get_response_attributes_with_model_id(): - """Test _get_response_attributes includes model_id.""" +def test_get_response_attributes_with_model(): + """Test _get_response_attributes includes model.""" from unittest.mock import Mock from agent_framework.observability import _get_response_attributes @@ -1662,7 +1677,7 @@ def test_get_response_attributes_with_model_id(): response.finish_reason = None response.raw_representation = None response.usage_details = None - response.model_id = "gpt-4" + response.model = "gpt-4" attrs = {} result = _get_response_attributes(attrs, response) @@ -2075,7 +2090,7 @@ async def test_capture_messages_with_finish_reason(mock_chat_client, span_export messages = [Message(role="user", text="Test")] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None assert response.finish_reason == "stop" @@ -2165,7 +2180,7 @@ async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemo messages = [Message(role="user", text="Test")] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() @@ -2181,7 +2196,7 @@ async def test_chat_client_streaming_when_disabled(mock_chat_client, span_export span_exporter.clear() updates = [] - async for update in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): + async for update in client.get_response(messages=messages, stream=True, options={"model": "Test"}): updates.append(update) assert len(updates) == 2 # Still works functionally @@ -2501,7 +2516,7 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: def __init__(self): super().__init__() self.call_count = 0 - self.model_id = "test-model" + self.model = "test-model" def service_url(self): return "https://test.example.com" @@ -2608,7 +2623,7 @@ async def test_agent_and_chat_spans_do_not_duplicate_response_telemetry( id="nested_agent_id", name="nested_agent", description="Nested telemetry agent", - default_options={"model_id": "NestedModel"}, + default_options={"model": "NestedModel"}, ) span_exporter.clear() @@ -2661,7 +2676,7 @@ async def test_capture_messages_preserves_non_ascii_characters(mock_chat_client, messages = [Message(role="user", text=japanese_text)] span_exporter.clear() - response = await client.get_response(messages=messages, options={"model_id": "Test"}) + response = await client.get_response(messages=messages, options={"model": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() @@ -2825,7 +2840,7 @@ async def test_agent_instructions_from_default_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default system instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default system instructions."} messages = [Message(role="user", text="Test message")] span_exporter.clear() @@ -2851,7 +2866,7 @@ async def test_agent_instructions_from_options_override( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel"} # No default instructions + agent.default_options = {"model": "TestModel"} # No default instructions messages = [Message(role="user", text="Test message")] span_exporter.clear() @@ -2876,7 +2891,7 @@ async def test_agent_instructions_merged_from_default_and_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default instructions."} messages = [Message(role="user", text="Test message")] span_exporter.clear() @@ -2903,7 +2918,7 @@ async def test_agent_streaming_instructions_from_default_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default streaming instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default streaming instructions."} messages = [Message(role="user", text="Test message")] span_exporter.clear() @@ -2932,7 +2947,7 @@ async def test_agent_streaming_instructions_merged_from_default_and_options( import json agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel", "instructions": "Default instructions."} + agent.default_options = {"model": "TestModel", "instructions": "Default instructions."} messages = [Message(role="user", text="Test message")] span_exporter.clear() @@ -2960,7 +2975,7 @@ async def test_agent_no_instructions_in_default_or_options( ): """Test that system_instructions is not set when neither default_options nor options have instructions.""" agent = mock_chat_agent() - agent.default_options = {"model_id": "TestModel"} # No instructions + agent.default_options = {"model": "TestModel"} # No instructions messages = [Message(role="user", text="Test message")] span_exporter.clear() diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 0f87219690..143aa95727 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -608,7 +608,7 @@ async def test_tool_invoke_rejects_unexpected_runtime_kwargs() -> None: await simple_tool.invoke( arguments=args, api_token="secret-token", - options={"model_id": "dummy"}, + options={"model": "dummy"}, ) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 9d1b39f29e..101379b8f9 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -754,6 +754,14 @@ def test_chat_response(): assert str(response) == response.text +def test_chat_response_accepts_model_alias() -> None: + """Test ChatResponse accepts model and exposes it through model alias.""" + response = ChatResponse(messages=Message(role="assistant", text="Hello"), model="claude-test") + + assert response.model == "claude-test" + assert response.model == "claude-test" + + class OutputModel(BaseModel): response: str @@ -851,6 +859,14 @@ def test_chat_response_update(): assert response_update.text == "I'm doing well, thank you!" +def test_chat_response_update_accepts_model_alias() -> None: + """Test ChatResponseUpdate accepts model and exposes it through model alias.""" + response_update = ChatResponseUpdate(contents=[Content.from_text("Hello")], model="claude-test") + + assert response_update.model == "claude-test" + assert response_update.model == "claude-test" + + def test_chat_response_updates_to_chat_response_one(): """Test converting ChatResponseUpdate to ChatResponse.""" # Create a Message @@ -1374,7 +1390,7 @@ def test_response_update_propagates_fields_and_metadata(): response_id="rid", message_id="mid", conversation_id="cid", - model_id="model-x", + model="model-x", created_at="t0", finish_reason="stop", additional_properties={"k": "v"}, @@ -1383,7 +1399,7 @@ def test_response_update_propagates_fields_and_metadata(): assert resp.response_id == "rid" assert resp.created_at == "t0" assert resp.conversation_id == "cid" - assert resp.model_id == "model-x" + assert resp.model == "model-x" assert resp.finish_reason == "stop" assert resp.additional_properties and resp.additional_properties["k"] == "v" assert resp.messages[0].role == "assistant" @@ -1935,7 +1951,7 @@ def test_chat_response_complex_serialization(): assert isinstance(response.messages[0], Message) assert isinstance(response.finish_reason, str) # FinishReason is now a NewType of str assert isinstance(response.usage_details, dict) - assert response.model_id == "gpt-4" # Should be stored as model_id + assert response.model == "gpt-4" # Should be stored as model # Test to_dict with complex objects response_dict = response.to_dict() @@ -1943,7 +1959,7 @@ def test_chat_response_complex_serialization(): assert isinstance(response_dict["messages"][0], dict) assert isinstance(response_dict["finish_reason"], str) # FinishReason serializes to string assert isinstance(response_dict["usage_details"], dict) - assert response_dict["model"] == "gpt-4" # Should serialize as model_id + assert response_dict["model"] == "gpt-4" # Should serialize as model def test_chat_response_update_all_content_types(): diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 874d5704d4..635d825cdc 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -46,7 +46,7 @@ else: class ProviderTypeMapping(TypedDict, total=True): package: str name: str - model_id_field: str + model_field: str endpoint_field: str | None api_key_field: str | None @@ -55,63 +55,63 @@ PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = { "AzureOpenAI": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "azure_endpoint", "api_key_field": "api_key", }, "AzureOpenAI.Chat": { "package": "agent_framework.openai", "name": "OpenAIChatCompletionClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "azure_endpoint", "api_key_field": "api_key", }, "AzureOpenAI.Responses": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "azure_endpoint", "api_key_field": "api_key", }, "Foundry": { "package": "agent_framework.foundry", "name": "FoundryChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "project_endpoint", "api_key_field": None, }, "OpenAI.Chat": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "base_url", "api_key_field": "api_key", }, "OpenAI.Responses": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "base_url", "api_key_field": "api_key", }, "OpenAI": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "base_url", "api_key_field": "api_key", }, "Foundry.Chat": { "package": "agent_framework.foundry", "name": "FoundryChatClient", - "model_id_field": "model", + "model_field": "model", "endpoint_field": "project_endpoint", "api_key_field": None, }, "Anthropic.Chat": { "package": "agent_framework.anthropic", "name": "AnthropicChatClient", - "model_id_field": "model_id", + "model_field": "model", "endpoint_field": None, "api_key_field": "api_key", }, @@ -210,7 +210,7 @@ class AgentFactory: "Provider.ApiType": { "package": "package.name", "name": "ClassName", - "model_id_field": "field_name_in_constructor", + "model_field": "field_name_in_constructor", "endpoint_field": "endpoint_kwarg_name_or_null", "api_key_field": "api_key_kwarg_name_or_null", }, @@ -220,7 +220,7 @@ class AgentFactory: Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the model, "Provider" is also allowed. Package refers to which model needs to be imported, Name is the class name of the - SupportsChatGetResponse implementation, and model_id_field is the name of the field in the + SupportsChatGetResponse implementation, and model_field is the name of the field in the constructor that accepts the model.id value. default_provider: The default provider used when model.provider is not specified, default is "OpenAI". @@ -264,7 +264,7 @@ class AgentFactory: "CustomProvider.Chat": { "package": "my_package.clients", "name": "CustomChatClient", - "model_id_field": "model_name", + "model_field": "model", }, }, ) @@ -690,7 +690,7 @@ class AgentFactory: # if prompt_agent.model is defined, but no id, use the supplied client if self.client: return self.client - # or raise, since we cannot create a client without model id + # or raise, since we cannot create a client without a model raise DeclarativeLoaderError( "ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent." ) @@ -699,7 +699,7 @@ class AgentFactory: class_name = mapping["name"] module = __import__(module_name, fromlist=[class_name]) agent_class = getattr(module, class_name) - setup_dict[mapping["model_id_field"]] = prompt_agent.model.id + setup_dict[mapping["model_field"]] = prompt_agent.model.id return agent_class(**setup_dict) # type: ignore[no-any-return] def _parse_chat_options(self, model: Model | None) -> dict[str, Any]: @@ -841,7 +841,7 @@ class AgentFactory: model: The Model instance containing provider and apiType information. Returns: - A dictionary containing the package, name, and model_id_field for the provider. + A dictionary containing the package, name, and model_field for the provider. Raises: ProviderLookupError: If the provider type is not supported or can't be found. diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 2ca87bfa65..02485b9e9c 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -632,7 +632,7 @@ class TestAgentFactorySafeMode: from agent_framework_declarative._loader import AgentFactory - monkeypatch.setenv("TEST_MODEL_ID", "gpt-4-from-env") + monkeypatch.setenv("TEST_MODEL", "gpt-4-from-env") # Create a mock chat client to avoid needing real provider mock_client = MagicMock() @@ -1131,7 +1131,7 @@ model: "CustomProvider.Chat": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model_id", + "model_field": "model", }, } diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 372e870c15..f7b462933d 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -391,7 +391,7 @@ class EntityDiscovery: source=source, # IMPORTANT: Pass the source parameter tools=[str(tool) for tool in (tools_list or [])], instructions=instructions, - model_id=model, + model=model, chat_client_type=chat_client_type, context_provider=context_provider_list, middleware=middlewares_list, @@ -846,7 +846,7 @@ class EntityDiscovery: description=description, tools=tools_union, instructions=instructions, - model_id=model, + model=model, chat_client_type=chat_client_type, context_provider=context_provider_list, middleware=middlewares_list, diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index 9e79b308c5..07f87fec3f 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -770,9 +770,9 @@ class MessageMapper: from .models._openai_custom import AgentCompletedEvent, AgentFailedEvent, AgentStartedEvent try: - # Get model name from request or use 'devui' as default + # Get model from request or use 'devui' as default request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" + model = request_obj.model if request_obj and request_obj.model else "devui" if isinstance(event, AgentStartedEvent): execution_id = f"agent_{uuid4().hex[:12]}" @@ -783,7 +783,7 @@ class MessageMapper: id=f"resp_{execution_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], status="in_progress", parallel_tool_calls=False, @@ -818,7 +818,7 @@ class MessageMapper: id=f"resp_{execution_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], status="failed", error=response_error, @@ -865,16 +865,16 @@ class MessageMapper: # Return proper OpenAI event objects events: list[Any] = [] - # Get model name from request or use 'devui' as default + # Get model from request or use 'devui' as default request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" + model = request_obj.model if request_obj and request_obj.model else "devui" # Create a full Response object with all required fields response_obj = Response( id=f"resp_{workflow_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], # Empty output list initially status="in_progress", # Required fields with safe defaults @@ -989,9 +989,9 @@ class MessageMapper: # Import Response and ResponseError types from openai.types.responses import Response, ResponseError - # Get model name from request or use 'devui' as default + # Get model from request or use 'devui' as default request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" + model = request_obj.model if request_obj and request_obj.model else "devui" # Extract error message from WorkflowErrorDetails if details: @@ -1013,7 +1013,7 @@ class MessageMapper: id=f"resp_{workflow_id}", object="response", created_at=float(time.time()), - model=model_name, + model=model, output=[], status="failed", error=response_error, diff --git a/python/packages/devui/agent_framework_devui/_session.py b/python/packages/devui/agent_framework_devui/_session.py index 93ac9b31e4..9d657a2d30 100644 --- a/python/packages/devui/agent_framework_devui/_session.py +++ b/python/packages/devui/agent_framework_devui/_session.py @@ -20,7 +20,7 @@ class RequestRecord(TypedDict): entity_id: str executor: str input: Any - model_id: str + model: str stream: bool execution_time: NotRequired[float] status: NotRequired[str] @@ -91,7 +91,7 @@ class SessionManager: logger.debug(f"Closed session: {session_id}") def add_request_record( - self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model_id: str + self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str ) -> str: """Add a request record to a session. @@ -100,7 +100,7 @@ class SessionManager: entity_id: ID of the entity being executed executor_name: Name of the executor request_input: Input for the request - model_id: Model name + model: Model name Returns: Request ID @@ -115,7 +115,7 @@ class SessionManager: "entity_id": entity_id, "executor": executor_name, "input": request_input, - "model_id": model_id, + "model": model, "stream": True, } session["requests"].append(request_record) @@ -163,7 +163,7 @@ class SessionManager: "timestamp": req["timestamp"].isoformat(), "entity_id": req["entity_id"], "executor": req["executor"], - "model": req["model_id"], + "model": req["model"], "input_length": len(str(req["input"])) if req["input"] else 0, "execution_time": req.get("execution_time"), "status": req.get("status", "unknown"), diff --git a/python/packages/devui/agent_framework_devui/_utils.py b/python/packages/devui/agent_framework_devui/_utils.py index 889a690c87..9aa7efa9f6 100644 --- a/python/packages/devui/agent_framework_devui/_utils.py +++ b/python/packages/devui/agent_framework_devui/_utils.py @@ -59,13 +59,13 @@ def extract_agent_metadata(entity_object: Any) -> dict[str, Any]: chat_opts = entity_object.default_options chat_opts_dict = _string_key_dict(chat_opts) if chat_opts_dict is not None: - model_id = chat_opts_dict.get("model_id") - if model_id: - metadata["model"] = model_id - elif hasattr(chat_opts, "model_id") and chat_opts.model_id: - metadata["model"] = chat_opts.model_id - if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model_id"): - metadata["model"] = entity_object.client.model_id + model = chat_opts_dict.get("model") + if model: + metadata["model"] = model + elif hasattr(chat_opts, "model") and chat_opts.model: + metadata["model"] = chat_opts.model + if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model"): + metadata["model"] = entity_object.client.model # Try to get chat client type if hasattr(entity_object, "client"): diff --git a/python/packages/devui/agent_framework_devui/models/_discovery_models.py b/python/packages/devui/agent_framework_devui/models/_discovery_models.py index 1e1f19e04d..694f871a7d 100644 --- a/python/packages/devui/agent_framework_devui/models/_discovery_models.py +++ b/python/packages/devui/agent_framework_devui/models/_discovery_models.py @@ -44,7 +44,7 @@ class EntityInfo(BaseModel): # Agent-specific fields (optional, populated when available) instructions: str | None = None - model_id: str | None = None + model: str | None = None chat_client_type: str | None = None context_provider: list[str] | None = None middleware: list[str] | None = None diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 239c924fec..d4721e6db5 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -485,7 +485,7 @@ services: # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} - - AZURE_OPENAI_DEPLOYMENT_NAME=\${AZURE_OPENAI_DEPLOYMENT_NAME} + - AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL} # Optional: Enable instrumentation - ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false} ports: @@ -519,7 +519,7 @@ az acr build --registry myregistry \\ className: "border-l-2 border-primary pl-3", children: [o.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [o.jsx("div", { className: "w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold", children: "5" }), o.jsx("h5", { className: "font-medium text-sm", children: "Get Application URL" })] }), o.jsx("pre", { className: "bg-muted p-2 rounded text-xs overflow-x-auto border mt-2", children: `az containerapp show --name ${r.toLowerCase()}-app \\ --resource-group myResourceGroup \\ - --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"FOUNDRY_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} + --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"FOUNDRY_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_MODEL",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} mv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),a&&o.jsx(iu,{number:3,title:"Set up environment variables",description:"Create a .env file in the project folder with these required variables:",code:e.requiredEnvVars.map(c=>`${c.name}=${c.example||"your-value-here"} # ${c.description}`).join(` diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 899383e3b1..7c5e544c8e 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -37,7 +37,7 @@ OPENAI_CHAT_MODEL="gpt-4o-mini" # Or for Azure OpenAI AZURE_OPENAI_ENDPOINT="your-endpoint" -AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name" +AZURE_OPENAI_MODEL="your-deployment-name" ``` ## 4. Test DevUI diff --git a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx index f33351185f..ae1eddc235 100644 --- a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx +++ b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx @@ -247,7 +247,7 @@ services: # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} - - AZURE_OPENAI_DEPLOYMENT_NAME=\${AZURE_OPENAI_DEPLOYMENT_NAME} + - AZURE_OPENAI_MODEL=\${AZURE_OPENAI_MODEL} # Optional: Enable instrumentation - ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false} ports: diff --git a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts index d33e02ad7e..9509562dfc 100644 --- a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts +++ b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts @@ -78,7 +78,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ required: true, }, { - name: "AZURE_OPENAI_DEPLOYMENT_NAME", + name: "AZURE_OPENAI_MODEL", description: "Name of the deployed model in Azure OpenAI", required: true, example: "gpt-4o", diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 8fbc127946..3f1945be3a 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -151,7 +151,7 @@ async def test_credential_cleanup() -> None: # Create mock chat client with credential mock_client = Mock() mock_client.async_credential = mock_credential - mock_client.model_id = "test-model" + mock_client.model = "test-model" mock_client.function_invocation_configuration = None # Create agent with mock client @@ -184,7 +184,7 @@ async def test_credential_cleanup_error_handling() -> None: # Create mock chat client with credential mock_client = Mock() mock_client.async_credential = mock_credential - mock_client.model_id = "test-model" + mock_client.model = "test-model" mock_client.function_invocation_configuration = None # Create agent with mock client @@ -219,7 +219,7 @@ async def test_multiple_credential_attributes() -> None: mock_client = Mock() mock_client.credential = mock_cred1 mock_client.async_credential = mock_cred2 - mock_client.model_id = "test-model" + mock_client.model = "test-model" mock_client.function_invocation_configuration = None # Create agent with mock client diff --git a/python/packages/durabletask/tests/integration_tests/.env.example b/python/packages/durabletask/tests/integration_tests/.env.example index f86f2382db..1944e8999c 100644 --- a/python/packages/durabletask/tests/integration_tests/.env.example +++ b/python/packages/durabletask/tests/integration_tests/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_MODEL=your-deployment-name # Optional: Use Azure CLI authentication if not provided # AZURE_OPENAI_API_KEY=your-api-key diff --git a/python/packages/durabletask/tests/integration_tests/README.md b/python/packages/durabletask/tests/integration_tests/README.md index 5cce6712e4..704458a492 100644 --- a/python/packages/durabletask/tests/integration_tests/README.md +++ b/python/packages/durabletask/tests/integration_tests/README.md @@ -14,7 +14,7 @@ cp .env.example .env Required variables: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` - `AZURE_OPENAI_API_KEY` (optional if using Azure CLI authentication) - `ENDPOINT` (default: http://localhost:8080) - `TASKHUB` (default: default) @@ -97,7 +97,7 @@ If you see "DTS emulator is not available": If you see authentication or deployment errors: - Verify your `AZURE_OPENAI_ENDPOINT` is correct -- Confirm `AZURE_OPENAI_DEPLOYMENT_NAME` matches your deployment +- Confirm `AZURE_OPENAI_MODEL` matches your deployment - If using API key, check `AZURE_OPENAI_API_KEY` is valid - If using Azure CLI, ensure you're logged in: `az login` diff --git a/python/packages/durabletask/tests/integration_tests/conftest.py b/python/packages/durabletask/tests/integration_tests/conftest.py index 94fce45109..65202e2907 100644 --- a/python/packages/durabletask/tests/integration_tests/conftest.py +++ b/python/packages/durabletask/tests/integration_tests/conftest.py @@ -291,7 +291,7 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item """Skip tests based on markers and environment availability.""" foundry_vars = ["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"] foundry_available = all(os.getenv(var) for var in foundry_vars) - azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"] + azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"] azure_openai_available = all(os.getenv(var) for var in azure_openai_vars) skip_foundry = pytest.mark.skip(reason=f"Missing required environment variables: {', '.join(foundry_vars)}") skip_azure_openai = pytest.mark.skip( @@ -348,7 +348,7 @@ def check_sample_env(request: pytest.FixtureRequest) -> None: sample_name = cast(str, sample_marker.args[0]) # type: ignore[union-attr] if sample_name == "06_multi_agent_orchestration_conditionals": - required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"] + required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"] else: required_vars = ["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"] missing = [var for var in required_vars if not os.getenv(var)] diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 4634ec8524..b8ae5ce398 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -477,15 +477,14 @@ class FoundryChatClient( # type: ignore[misc] - ``FOUNDRY_MODEL`` to provide the Foundry model deployment name. Keyword Args: - project_endpoint: The Foundry project endpoint URL. - Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``. - project_client: An existing AIProjectClient to use. - model: The model deployment name. - Can also be set via environment variable ``FOUNDRY_MODEL``. - model_id: Deprecated alias for ``model``. - credential: Azure credential or token provider for authentication. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - env_file_path: Path to .env file for settings. + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``. + project_client: An existing AIProjectClient to use. + model: The model deployment name. + Can also be set via environment variable ``FOUNDRY_MODEL``. + credential: Azure credential or token provider for authentication. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. instruction_role: The role to use for 'instruction' messages. middleware: Optional sequence of middleware. diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 5957b51565..91f14ed230 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -13,7 +13,7 @@ Integration with Azure AI Foundry Local for local model inference. ```python from agent_framework.foundry import FoundryLocalClient -client = FoundryLocalClient(model_id="your-local-model") +client = FoundryLocalClient(model="your-local-model") response = await client.get_response("Hello") ``` diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 5b0e15f2a2..f51a77c054 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -23,7 +23,7 @@ from agent_framework._settings import load_settings from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient from foundry_local import FoundryLocalManager -from foundry_local.models import DeviceType +from foundry_local.models import DeviceType, FoundryModelInfo from openai import AsyncOpenAI from pydantic import BaseModel @@ -60,7 +60,7 @@ class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModel Keys: # Inherited from ChatOptions (supported via OpenAI-compatible API): - model_id: The model identifier or alias (e.g., 'phi-4-mini'). + model: The model identifier or alias (e.g., 'phi-4-mini'). temperature: Sampling temperature (0-2). top_p: Nucleus sampling parameter. max_tokens: Maximum tokens to generate. @@ -104,11 +104,6 @@ class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModel """Not applicable for local inference.""" -FOUNDRY_LOCAL_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", -} -"""Maps ChatOptions keys to OpenAI API parameter names (for compatibility).""" - FoundryLocalChatOptionsT = TypeVar( "FoundryLocalChatOptionsT", bound=TypedDict, # type: ignore[valid-type] @@ -295,8 +290,8 @@ class FoundryLocalClient( # will take a long time as the model is loaded then. # Alternatively, you could call the `download_model` and `load_model` methods # on the `manager` property manually. - client.manager.download_model(alias_or_model_id="phi-4-mini", device=DeviceType.CPU) - client.manager.load_model(alias_or_model_id="phi-4-mini", device=DeviceType.CPU) + client.manager.download_model("phi-4-mini", device=DeviceType.CPU) + client.manager.load_model("phi-4-mini", device=DeviceType.CPU) # You can also use the CLI: `foundry model load phi-4-mini --device Auto` @@ -328,8 +323,8 @@ class FoundryLocalClient( model_setting: str = settings["model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout) - model_info = manager.get_model_info( - alias_or_model_id=model_setting, + model_info: FoundryModelInfo | None = manager.get_model_info( + model_setting, device=device, ) if model_info is None: @@ -340,8 +335,8 @@ class FoundryLocalClient( ) raise ValueError(message) if prepare_model: - manager.download_model(alias_or_model_id=model_info.id, device=device) - manager.load_model(alias_or_model_id=model_info.id, device=device) + manager.download_model(model_info.id, device=device) + manager.load_model(model_info.id, device=device) super().__init__( model=model_info.id, diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index 02b42f22a6..43b2918ca0 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -34,7 +34,7 @@ def test_foundry_local_settings_init_with_explicit_values() -> None: @pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL"]], indirect=True) def test_foundry_local_settings_missing_model(foundry_local_unit_test_env: dict[str, str]) -> None: - """Test FoundryLocalSettings when model_id is missing raises error.""" + """Test FoundryLocalSettings when model is missing raises error.""" with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): load_settings( FoundryLocalSettings, @@ -157,15 +157,15 @@ def test_foundry_local_client_init_with_device(mock_foundry_local_manager: Magic FoundryLocalClient(model="test-model-id", device=DeviceType.CPU) mock_foundry_local_manager.get_model_info.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=DeviceType.CPU, ) mock_foundry_local_manager.download_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=DeviceType.CPU, ) mock_foundry_local_manager.load_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=DeviceType.CPU, ) @@ -207,10 +207,10 @@ def test_foundry_local_client_init_calls_download_and_load(mock_foundry_local_ma FoundryLocalClient(model="test-model-id") mock_foundry_local_manager.download_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=None, ) mock_foundry_local_manager.load_model.assert_called_once_with( - alias_or_model_id="test-model-id", + "test-model-id", device=None, ) diff --git a/python/packages/lab/lightning/README.md b/python/packages/lab/lightning/README.md index e9fd3ec91d..75c585bdaa 100644 --- a/python/packages/lab/lightning/README.md +++ b/python/packages/lab/lightning/README.md @@ -51,7 +51,7 @@ async def math_agent(task: TaskType, llm: LLM) -> float: MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server, Agent( client=OpenAIChatClient( - model_id=llm.model, + model=llm.model, api_key="your-api-key", base_url=llm.endpoint, ), diff --git a/python/packages/lab/lightning/samples/train_math_agent.py b/python/packages/lab/lightning/samples/train_math_agent.py index 98e79484bc..a58e7886d1 100644 --- a/python/packages/lab/lightning/samples/train_math_agent.py +++ b/python/packages/lab/lightning/samples/train_math_agent.py @@ -169,7 +169,7 @@ async def math_agent(task: MathProblem, llm: LLM) -> float: MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server, Agent( client=OpenAIChatClient( - model_id=llm.model, # This is the model being trained + model=llm.model, # This is the model being trained api_key=os.getenv("OPENAI_API_KEY") or "dummy", # Can be dummy when connecting to training LLM base_url=llm.endpoint, # vLLM server endpoint provided by agent-lightning ), diff --git a/python/packages/lab/lightning/samples/train_tau2_agent.py b/python/packages/lab/lightning/samples/train_tau2_agent.py index e9514b6f77..ea07ddc7e8 100644 --- a/python/packages/lab/lightning/samples/train_tau2_agent.py +++ b/python/packages/lab/lightning/samples/train_tau2_agent.py @@ -106,14 +106,14 @@ class Tau2Agent(LitAgent): assistant_chat_client = OpenAIChatClient( base_url=llm.endpoint, # vLLM endpoint for the model being trained api_key=openai_api_key, - model_id=llm.model, # Model ID being trained + model=llm.model, # Model ID being trained ) # User simulator: uses a fixed, capable model for consistent simulation user_simulator_chat_client = OpenAIChatClient( base_url=openai_base_url, # External API endpoint api_key=openai_api_key, - model_id="gpt-4.1", # Fixed model for user simulator + model="gpt-4.1", # Fixed model for user simulator ) try: diff --git a/python/packages/lab/tau2/README.md b/python/packages/lab/tau2/README.md index 083fd05a9d..0e6b5ea735 100644 --- a/python/packages/lab/tau2/README.md +++ b/python/packages/lab/tau2/README.md @@ -67,12 +67,12 @@ async def run_single_task(): assistant_client = OpenAIChatClient( base_url="https://api.openai.com/v1", api_key="your-api-key", - model_id="gpt-4o" + model="gpt-4o" ) user_client = OpenAIChatClient( base_url="https://api.openai.com/v1", api_key="your-api-key", - model_id="gpt-4o-mini" + model="gpt-4o-mini" ) # Get a task and run it diff --git a/python/packages/lab/tau2/samples/run_benchmark.py b/python/packages/lab/tau2/samples/run_benchmark.py index 5c2ed14d5e..5d11226bb1 100644 --- a/python/packages/lab/tau2/samples/run_benchmark.py +++ b/python/packages/lab/tau2/samples/run_benchmark.py @@ -96,14 +96,14 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st assistant_chat_client = OpenAIChatClient( base_url=openai_base_url, api_key=openai_api_key, - model_id=assistant_model, + model=assistant_model, ) # User simulator: simulates realistic customer behavior and requests user_simulator_chat_client = OpenAIChatClient( base_url=openai_base_url, api_key=openai_api_key, - model_id=user_model, + model=user_model, ) # STEP 4: Filter task set for debug mode @@ -133,8 +133,8 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st # Initialize result structure for this task result: dict[str, Any] = { "config": { - "assistant": assistant_chat_client.model_id, - "user": user_simulator_chat_client.model_id, + "assistant": assistant_chat_client.model, + "user": user_simulator_chat_client.model, }, "task": task, } @@ -183,8 +183,8 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st # Initialize result structure for this task result: dict[str, Any] = { "config": { - "assistant": assistant_chat_client.model_id, - "user": user_simulator_chat_client.model_id, + "assistant": assistant_chat_client.model, + "user": user_simulator_chat_client.model, }, "task": task, } diff --git a/python/packages/ollama/AGENTS.md b/python/packages/ollama/AGENTS.md index 3a4aa0f928..be0c7af951 100644 --- a/python/packages/ollama/AGENTS.md +++ b/python/packages/ollama/AGENTS.md @@ -13,7 +13,7 @@ Integration with Ollama for local LLM inference. ```python from agent_framework.ollama import OllamaChatClient -client = OllamaChatClient(model_id="llama3.2") +client = OllamaChatClient(model="llama3.2") response = await client.get_response("Hello") ``` diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 0c7f232797..cce4c14332 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -77,7 +77,7 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to Keys: # Inherited from ChatOptions (mapped to Ollama options): - model_id: The model name, translates to ``model`` in Ollama API. + model: The model name, translates to ``model`` in Ollama API. temperature: Sampling temperature, translates to ``options.temperature``. top_p: Nucleus sampling, translates to ``options.top_p``. max_tokens: Maximum tokens to generate, translates to ``options.num_predict``. @@ -229,7 +229,6 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to OLLAMA_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", "response_format": "format", } """Maps ChatOptions keys to Ollama API parameter names.""" @@ -278,7 +277,7 @@ class OllamaSettings(TypedDict, total=False): """Ollama settings.""" host: str | None - model_id: str | None + model: str | None logger = logging.getLogger("agent_framework.ollama") @@ -299,7 +298,7 @@ class OllamaChatClient( *, host: str | None = None, client: AsyncClient | None = None, - model_id: str | None = None, + model: str | None = None, additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, @@ -312,7 +311,7 @@ class OllamaChatClient( host: Ollama server URL, if none `http://localhost:11434` is used. Can be set via the OLLAMA_HOST env variable. client: An optional Ollama Client instance. If not provided, a new instance will be created. - model_id: The Ollama chat model ID to use. Can be set via the OLLAMA_MODEL_ID env variable. + model: The Ollama chat model to use. Can be set via the OLLAMA_MODEL env variable. additional_properties: Additional properties stored on the client instance. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. @@ -322,14 +321,14 @@ class OllamaChatClient( ollama_settings = load_settings( OllamaSettings, env_prefix="OLLAMA_", - required_fields=["model_id"], + required_fields=["model"], host=host, - model_id=model_id, + model=model, env_file_encoding=env_file_encoding, env_file_path=env_file_path, ) - self.model_id = ollama_settings["model_id"] # type: ignore[assignment, reportTypedDictNotRequiredAccess] + self.model = ollama_settings["model"] # type: ignore[assignment, reportTypedDictNotRequiredAccess] # we can just pass in None for the host, the default is set by the Ollama package. self.client = client or AsyncClient(host=ollama_settings.get("host")) # Save Host URL for serialization with to_dict() @@ -411,7 +410,7 @@ class OllamaChatClient( translated_key = OLLAMA_MODEL_OPTION_TRANSLATIONS.get(key, key) model_options[translated_key] = value else: - # Apply top-level translations (e.g., model_id -> model) + # Apply top-level translations (e.g., response_format -> format) translated_key = OLLAMA_OPTION_TRANSLATIONS.get(key, key) run_options[translated_key] = value @@ -425,11 +424,11 @@ class OllamaChatClient( if "messages" not in run_options: raise ChatClientInvalidRequestException("Messages are required for chat completions") - # model id + # model if not run_options.get("model"): - if not self.model_id: - raise ValueError("model_id must be a non-empty string") - run_options["model"] = self.model_id + if not self.model: + raise ValueError("model must be a non-empty string") + run_options["model"] = self.model # tools tools = options.get("tools") @@ -533,7 +532,7 @@ class OllamaChatClient( return ChatResponseUpdate( contents=contents, role="assistant", - model_id=response.model, + model=response.model, created_at=response.created_at, ) @@ -542,7 +541,7 @@ class OllamaChatClient( return ChatResponse( messages=[Message(role="assistant", contents=contents)], - model_id=response.model, + model=response.model, created_at=response.created_at, usage_details=UsageDetails( input_token_count=response.prompt_eval_count, diff --git a/python/packages/ollama/agent_framework_ollama/_embedding_client.py b/python/packages/ollama/agent_framework_ollama/_embedding_client.py index 8e0508c708..e921e6172a 100644 --- a/python/packages/ollama/agent_framework_ollama/_embedding_client.py +++ b/python/packages/ollama/agent_framework_ollama/_embedding_client.py @@ -38,7 +38,7 @@ class OllamaEmbeddingOptions(EmbeddingGenerationOptions, total=False): from agent_framework_ollama import OllamaEmbeddingOptions options: OllamaEmbeddingOptions = { - "model_id": "nomic-embed-text", + "model": "nomic-embed-text", "dimensions": 768, "truncate": True, } @@ -67,7 +67,7 @@ class OllamaEmbeddingSettings(TypedDict, total=False): """Ollama embedding settings.""" host: str | None - embedding_model_id: str | None + embedding_model: str | None class RawOllamaEmbeddingClient( @@ -77,8 +77,8 @@ class RawOllamaEmbeddingClient( """Raw Ollama embedding client without telemetry. Keyword Args: - model_id: The Ollama embedding model ID (e.g. "nomic-embed-text"). - Can also be set via environment variable OLLAMA_EMBEDDING_MODEL_ID. + model: The Ollama embedding model (e.g. "nomic-embed-text"). + Can also be set via environment variable OLLAMA_EMBEDDING_MODEL. host: Ollama server URL. Defaults to http://localhost:11434. Can also be set via environment variable OLLAMA_HOST. client: Optional pre-configured Ollama AsyncClient. @@ -89,7 +89,7 @@ class RawOllamaEmbeddingClient( def __init__( self, *, - model_id: str | None = None, + model: str | None = None, host: str | None = None, client: AsyncClient | None = None, additional_properties: dict[str, Any] | None = None, @@ -100,14 +100,14 @@ class RawOllamaEmbeddingClient( ollama_settings = load_settings( OllamaEmbeddingSettings, env_prefix="OLLAMA_", - required_fields=["embedding_model_id"], + required_fields=["embedding_model"], host=host, - embedding_model_id=model_id, + embedding_model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - self.model_id = ollama_settings["embedding_model_id"] # type: ignore[assignment,reportTypedDictNotRequiredAccess] + self.model = ollama_settings["embedding_model"] # type: ignore[assignment,reportTypedDictNotRequiredAccess] self.client = client or AsyncClient(host=ollama_settings.get("host")) self.host = str(self.client._client.base_url) # type: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] super().__init__(additional_properties=additional_properties) @@ -132,15 +132,15 @@ class RawOllamaEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or values is empty. + ValueError: If model is not provided or values is empty. """ if not values: return GeneratedEmbeddings([], options=options) opts: dict[str, Any] = options or {} # type: ignore - model = opts.get("model_id") or self.model_id + model = opts.get("model") or self.model if not model: - raise ValueError("model_id is required") + raise ValueError("model is required") kwargs: dict[str, Any] = {"model": model, "input": list(values)} if (truncate := opts.get("truncate")) is not None: @@ -156,7 +156,7 @@ class RawOllamaEmbeddingClient( Embedding( vector=list(emb), dimensions=len(emb), - model_id=response.get("model") or model, # type: ignore[assignment] + model=response.get("model") or model, # type: ignore[assignment] ) for emb in response.get("embeddings", []) ] @@ -177,8 +177,8 @@ class OllamaEmbeddingClient( """Ollama embedding client with telemetry support. Keyword Args: - model_id: The Ollama embedding model ID (e.g. "nomic-embed-text"). - Can also be set via environment variable OLLAMA_EMBEDDING_MODEL_ID. + model: The Ollama embedding model (e.g. "nomic-embed-text"). + Can also be set via environment variable OLLAMA_EMBEDDING_MODEL. host: Ollama server URL. Defaults to http://localhost:11434. Can also be set via environment variable OLLAMA_HOST. client: Optional pre-configured Ollama AsyncClient. @@ -191,12 +191,12 @@ class OllamaEmbeddingClient( from agent_framework_ollama import OllamaEmbeddingClient # Using environment variables - # Set OLLAMA_EMBEDDING_MODEL_ID=nomic-embed-text + # Set OLLAMA_EMBEDDING_MODEL=nomic-embed-text client = OllamaEmbeddingClient() # Or passing parameters directly client = OllamaEmbeddingClient( - model_id="nomic-embed-text", + model="nomic-embed-text", host="http://localhost:11434", ) @@ -210,7 +210,7 @@ class OllamaEmbeddingClient( def __init__( self, *, - model_id: str | None = None, + model: str | None = None, host: str | None = None, client: AsyncClient | None = None, otel_provider_name: str | None = None, @@ -220,7 +220,7 @@ class OllamaEmbeddingClient( ) -> None: """Initialize an Ollama embedding client.""" super().__init__( - model_id=model_id, + model=model, host=host, client=client, additional_properties=additional_properties, diff --git a/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py b/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py index c585f9c481..63dffeb7c1 100644 --- a/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py +++ b/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py @@ -13,11 +13,11 @@ from agent_framework_ollama import OllamaEmbeddingClient, OllamaEmbeddingOptions def test_ollama_embedding_construction(monkeypatch: pytest.MonkeyPatch) -> None: """Test construction with explicit parameters.""" - monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL_ID", "nomic-embed-text") + monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text") with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: mock_client_cls.return_value = MagicMock() client = OllamaEmbeddingClient() - assert client.model_id == "nomic-embed-text" + assert client.model == "nomic-embed-text" def test_ollama_embedding_construction_with_params() -> None: @@ -25,16 +25,16 @@ def test_ollama_embedding_construction_with_params() -> None: with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: mock_client_cls.return_value = MagicMock() client = OllamaEmbeddingClient( - model_id="nomic-embed-text", + model="nomic-embed-text", host="http://localhost:11434", ) - assert client.model_id == "nomic-embed-text" + assert client.model == "nomic-embed-text" def test_ollama_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that missing model_id raises an error.""" - monkeypatch.delenv("OLLAMA_EMBEDDING_MODEL_ID", raising=False) - monkeypatch.delenv("OLLAMA_MODEL_ID", raising=False) + """Test that missing model raises an error.""" + monkeypatch.delenv("OLLAMA_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("OLLAMA_MODEL", raising=False) from agent_framework.exceptions import SettingNotFoundError with pytest.raises(SettingNotFoundError): @@ -54,14 +54,14 @@ async def test_ollama_embedding_get_embeddings() -> None: mock_client.embed = AsyncMock(return_value=mock_response) mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client = OllamaEmbeddingClient(model="nomic-embed-text") result = await client.get_embeddings(["hello", "world"]) assert isinstance(result, GeneratedEmbeddings) assert len(result) == 2 assert result[0].vector == [0.1, 0.2, 0.3] assert result[1].vector == [0.4, 0.5, 0.6] - assert result[0].model_id == "nomic-embed-text" + assert result[0].model == "nomic-embed-text" assert result.usage == {"input_token_count": 10} mock_client.embed.assert_called_once_with( @@ -76,7 +76,7 @@ async def test_ollama_embedding_get_embeddings_empty_input() -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client = OllamaEmbeddingClient(model="nomic-embed-text") result = await client.get_embeddings([]) assert isinstance(result, GeneratedEmbeddings) @@ -96,7 +96,7 @@ async def test_ollama_embedding_get_embeddings_with_options() -> None: mock_client.embed = AsyncMock(return_value=mock_response) mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client = OllamaEmbeddingClient(model="nomic-embed-text") options: OllamaEmbeddingOptions = { "truncate": True, "dimensions": 512, @@ -113,22 +113,22 @@ async def test_ollama_embedding_get_embeddings_with_options() -> None: async def test_ollama_embedding_get_embeddings_no_model_raises() -> None: - """Test that missing model_id at call time raises ValueError.""" + """Test that missing model at call time raises ValueError.""" with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: mock_client = MagicMock() mock_client_cls.return_value = mock_client - client = OllamaEmbeddingClient(model_id="nomic-embed-text") - client.model_id = None # type: ignore[assignment] + client = OllamaEmbeddingClient(model="nomic-embed-text") + client.model = None # type: ignore[assignment] - with pytest.raises(ValueError, match="model_id is required"): + with pytest.raises(ValueError, match="model is required"): await client.get_embeddings(["hello"]) # region: Integration Tests skip_if_ollama_embedding_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OLLAMA_EMBEDDING_MODEL_ID", "") in ("", "test-model"), + os.getenv("OLLAMA_EMBEDDING_MODEL", "") in ("", "test-model"), reason="No real Ollama embedding model provided; skipping integration tests.", ) diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 490bbc0a15..c4c66077e4 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -26,7 +26,7 @@ from agent_framework_ollama import OllamaChatClient # region Service Setup skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OLLAMA_MODEL_ID", "") in ("", "test-model"), + os.getenv("OLLAMA_MODEL", "") in ("", "test-model"), reason="No real Ollama chat model provided; skipping integration tests.", ) @@ -55,7 +55,7 @@ def ollama_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # if override_env_param_dict is None: override_env_param_dict = {} - env_vars = {"OLLAMA_HOST": "http://localhost:12345", "OLLAMA_MODEL_ID": "test"} + env_vars = {"OLLAMA_HOST": "http://localhost:12345", "OLLAMA_MODEL": "test"} env_vars.update(override_env_param_dict) # type: ignore @@ -156,7 +156,7 @@ def test_init(ollama_unit_test_env: dict[str, str]) -> None: assert ollama_chat_client.client is not None assert isinstance(ollama_chat_client.client, AsyncClient) - assert ollama_chat_client.model_id == ollama_unit_test_env["OLLAMA_MODEL_ID"] + assert ollama_chat_client.model == ollama_unit_test_env["OLLAMA_MODEL"] assert isinstance(ollama_chat_client, BaseChatClient) @@ -165,27 +165,27 @@ def test_init_client(ollama_unit_test_env: dict[str, str]) -> None: test_client = MagicMock(spec=AsyncClient) # Mock underlying HTTP client's base_url test_client._client = MagicMock() - test_client._client.base_url = ollama_unit_test_env["OLLAMA_MODEL_ID"] + test_client._client.base_url = ollama_unit_test_env["OLLAMA_MODEL"] ollama_chat_client = OllamaChatClient(client=test_client) assert ollama_chat_client.client is test_client - assert ollama_chat_client.model_id == ollama_unit_test_env["OLLAMA_MODEL_ID"] + assert ollama_chat_client.model == ollama_unit_test_env["OLLAMA_MODEL"] assert isinstance(ollama_chat_client, BaseChatClient) -@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL_ID"]], indirect=True) +@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL"]], indirect=True) def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None: - with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"): + with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): OllamaChatClient( host="http://localhost:12345", - model_id=None, + model=None, ) def test_serialize(ollama_unit_test_env: dict[str, str]) -> None: settings = { "host": ollama_unit_test_env["OLLAMA_HOST"], - "model_id": ollama_unit_test_env["OLLAMA_MODEL_ID"], + "model": ollama_unit_test_env["OLLAMA_MODEL"], } ollama_chat_client = OllamaChatClient.from_dict(settings) @@ -193,7 +193,7 @@ def test_serialize(ollama_unit_test_env: dict[str, str]) -> None: assert isinstance(serialized, dict) assert serialized["host"] == ollama_unit_test_env["OLLAMA_HOST"] - assert serialized["model_id"] == ollama_unit_test_env["OLLAMA_MODEL_ID"] + assert serialized["model"] == ollama_unit_test_env["OLLAMA_MODEL"] def test_chat_middleware(ollama_unit_test_env: dict[str, str]) -> None: @@ -225,7 +225,7 @@ def test_additional_properties(ollama_unit_test_env: dict[str, str]) -> None: async def test_empty_messages() -> None: ollama_chat_client = OllamaChatClient( host="http://localhost:12345", - model_id="test-model", + model="test-model", ) with pytest.raises(ChatClientInvalidRequestException): await ollama_chat_client.get_response(messages=[]) diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md index a1847e4e44..17f1ee9597 100644 --- a/python/packages/openai/README.md +++ b/python/packages/openai/README.md @@ -56,16 +56,16 @@ These variables are used when the client is configured for Azure OpenAI: | `AZURE_OPENAI_BASE_URL` | Full Azure OpenAI base URL (`.../openai/v1`) | | `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI API version | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Generic fallback deployment | -| `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatClient` | -| `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatCompletionClient` | -| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIEmbeddingClient` | +| `AZURE_OPENAI_MODEL` | Generic fallback deployment | +| `AZURE_OPENAI_RESPONSES_MODEL` | Preferred deployment for `OpenAIChatClient` | +| `AZURE_OPENAI_CHAT_MODEL` | Preferred deployment for `OpenAIChatCompletionClient` | +| `AZURE_OPENAI_EMBEDDING_MODEL` | Preferred deployment for `OpenAIEmbeddingClient` | Deployment lookup order: -- `OpenAIChatClient`: `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` -- `OpenAIChatCompletionClient`: `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` -- `OpenAIEmbeddingClient`: `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` +- `OpenAIChatClient`: `AZURE_OPENAI_RESPONSES_MODEL` -> `AZURE_OPENAI_MODEL` +- `OpenAIChatCompletionClient`: `AZURE_OPENAI_CHAT_MODEL` -> `AZURE_OPENAI_MODEL` +- `OpenAIEmbeddingClient`: `AZURE_OPENAI_EMBEDDING_MODEL` -> `AZURE_OPENAI_MODEL` When both OpenAI and Azure environment variables are present, the generic clients prefer OpenAI when `OPENAI_API_KEY` is configured. To use Azure explicitly, pass `azure_endpoint` or diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 30c88b0848..5f79126711 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -331,8 +331,8 @@ class RawOpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_RESPONSES_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -361,7 +361,6 @@ class RawOpenAIChatClient( # type: ignore[misc] self, model: str | None = None, *, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, @@ -382,9 +381,7 @@ class RawOpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI, - or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure. - model_id: Deprecated alias for ``model``. + or ``AZURE_OPENAI_RESPONSES_MODEL`` and then ``AZURE_OPENAI_MODEL`` for Azure. api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -424,15 +421,9 @@ class RawOpenAIChatClient( # type: ignore[misc] OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - settings, client, use_azure_client = load_openai_service_settings( model=model, api_key=api_key, @@ -447,12 +438,12 @@ class RawOpenAIChatClient( # type: ignore[misc] env_file_path=env_file_path, env_file_encoding=env_file_encoding, openai_model_fields=("responses_model", "model"), - azure_deployment_fields=("responses_deployment_name", "deployment_name"), + azure_model_fields=("responses_model", "model"), responses_mode=True, ) self.client = client - self.model: str = settings.get("model") or settings.get("deployment_name") or "" + self.model: str = settings.get("model") or "" # Store configuration for serialization self.org_id = settings.get("org_id") @@ -1178,7 +1169,6 @@ class RawOpenAIChatClient( # type: ignore[misc] # translations between options and Responses API translations = { - "model_id": "model", # backward compat: accept model_id in options "allow_multiple_tool_calls": "parallel_tool_calls", "conversation_id": "previous_response_id", "max_tokens": "max_output_tokens", @@ -2559,8 +2549,8 @@ class OpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_RESPONSES_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -2613,8 +2603,8 @@ class OpenAIChatClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI - routing, or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + routing, or ``AZURE_OPENAI_RESPONSES_MODEL`` and then + ``AZURE_OPENAI_MODEL`` for Azure routing. api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -2656,8 +2646,8 @@ class OpenAIChatClient( # type: ignore[misc] OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. Examples: .. code-block:: python diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 4828014e5b..0c70ea83a0 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -114,7 +114,7 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM Extends ChatOptions with options specific to OpenAI's Chat Completions API. Keys: - model_id: The model to use for the request, + model: The model to use for the request, translates to ``model`` in OpenAI API. temperature: Sampling temperature between 0 and 2. top_p: Nucleus sampling parameter. @@ -155,7 +155,6 @@ OpenAIChatCompletionOptionsT = TypeVar( ) OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", # backward compat: accept model_id in options "allow_multiple_tool_calls": "parallel_tool_calls", "max_tokens": "max_completion_tokens", } @@ -246,8 +245,8 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_CHAT_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -276,7 +275,6 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] self, model: str | None = None, *, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, @@ -297,9 +295,7 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, - or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. - model_id: Deprecated alias for ``model``. + or ``AZURE_OPENAI_CHAT_MODEL`` and then ``AZURE_OPENAI_MODEL`` for Azure routing. api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -339,15 +335,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - settings, client, use_azure_client = load_openai_service_settings( model=model, api_key=api_key, @@ -362,11 +352,11 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] env_file_path=env_file_path, env_file_encoding=env_file_encoding, openai_model_fields=("chat_model", "model"), - azure_deployment_fields=("chat_deployment_name", "deployment_name"), + azure_model_fields=("chat_model", "model"), ) self.client = client - self.model: str = settings.get("model") or settings.get("deployment_name") or "" + self.model: str = settings.get("model") or "" # Store configuration for serialization self.org_id = settings.get("org_id") @@ -1098,8 +1088,8 @@ class OpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor - reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + reads ``AZURE_OPENAI_CHAT_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -1146,8 +1136,8 @@ class OpenAIChatCompletionClient( # type: ignore[misc] Keyword Args: model: Model identifier to use for the request. When not provided, the constructor reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, - or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + or ``AZURE_OPENAI_CHAT_MODEL`` and then + ``AZURE_OPENAI_MODEL`` for Azure routing. api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -1186,8 +1176,8 @@ class OpenAIChatCompletionClient( # type: ignore[misc] OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. Examples: .. code-block:: python diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index 6a637e29da..b11304dd27 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -123,8 +123,8 @@ class RawOpenAIEmbeddingClient( Keyword Args: model: Embedding deployment name. When not provided, the constructor reads - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + ``AZURE_OPENAI_EMBEDDING_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -150,7 +150,6 @@ class RawOpenAIEmbeddingClient( self, *, model: str | None = None, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, @@ -168,9 +167,8 @@ class RawOpenAIEmbeddingClient( Keyword Args: model: Embedding model or Azure OpenAI deployment name. When not provided, the constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` - for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` - and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. - model_id: Deprecated alias for ``model``. + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_MODEL`` + and then ``AZURE_OPENAI_MODEL``. api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -207,15 +205,9 @@ class RawOpenAIEmbeddingClient( OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - settings, client, use_azure_client = load_openai_service_settings( model=model, api_key=api_key, @@ -230,11 +222,11 @@ class RawOpenAIEmbeddingClient( env_file_path=env_file_path, env_file_encoding=env_file_encoding, openai_model_fields=("embedding_model", "model"), - azure_deployment_fields=("embedding_deployment_name", "deployment_name"), + azure_model_fields=("embedding_model", "model"), ) self.client = client - resolved_model = settings.get("model") or settings.get("deployment_name") + resolved_model = settings.get("model") self.model: str | None = resolved_model.strip() if isinstance(resolved_model, str) and resolved_model else None # Store configuration for serialization @@ -279,8 +271,7 @@ class RawOpenAIEmbeddingClient( return GeneratedEmbeddings([], options=options) # type: ignore opts: dict[str, Any] = options or {} # type: ignore - # backward compat: accept model_id in options - model = opts.get("model") or opts.get("model_id") or self.model + model = opts.get("model") or self.model if not model: raise ValueError("model is required") @@ -385,8 +376,8 @@ class OpenAIEmbeddingClient( Keyword Args: model: Embedding deployment name. When not provided, the constructor reads - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then - ``AZURE_OPENAI_DEPLOYMENT_NAME``. + ``AZURE_OPENAI_EMBEDDING_MODEL`` and then + ``AZURE_OPENAI_MODEL``. azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor reads ``AZURE_OPENAI_ENDPOINT``. credential: Azure credential or token provider for Entra auth. @@ -429,8 +420,8 @@ class OpenAIEmbeddingClient( Keyword Args: model: Embedding model or Azure OpenAI deployment name. When not provided, the constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` - for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` - and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_MODEL`` + and then ``AZURE_OPENAI_MODEL``. api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. A callable token provider is also accepted for backwards compatibility, @@ -467,8 +458,8 @@ class OpenAIEmbeddingClient( OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, - ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, - ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_MODEL``, + ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``. Examples: .. code-block:: python diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index 704583c30f..7afc4f2d25 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -104,17 +104,14 @@ class AzureOpenAISettings(TypedDict, total=False): endpoint: str | None base_url: str | None api_key: SecretString | None - deployment_name: str | None - embedding_deployment_name: str | None - chat_deployment_name: str | None - responses_deployment_name: str | None + model: str | None + embedding_model: str | None + chat_model: str | None + responses_model: str | None api_version: str | None OpenAIModelSettingName = Literal["model", "embedding_model", "chat_model", "responses_model"] -AzureDeploymentSettingName = Literal[ - "deployment_name", "embedding_deployment_name", "chat_deployment_name", "responses_deployment_name" -] OPENAI_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { "model": "OPENAI_MODEL", @@ -123,17 +120,17 @@ OPENAI_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { "responses_model": "OPENAI_RESPONSES_MODEL", } -AZURE_DEPLOYMENT_ENV_VARS: dict[AzureDeploymentSettingName, str] = { - "deployment_name": "AZURE_OPENAI_DEPLOYMENT_NAME", - "embedding_deployment_name": "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "chat_deployment_name": "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", - "responses_deployment_name": "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", +AZURE_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { + "model": "AZURE_OPENAI_MODEL", + "embedding_model": "AZURE_OPENAI_EMBEDDING_MODEL", + "chat_model": "AZURE_OPENAI_CHAT_MODEL", + "responses_model": "AZURE_OPENAI_RESPONSES_MODEL", } def _resolve_named_setting( settings: Mapping[str, Any], - fields: Sequence[OpenAIModelSettingName | AzureDeploymentSettingName], + fields: Sequence[OpenAIModelSettingName], ) -> str | None: """Return the first populated value from ``fields``.""" for field in fields: @@ -163,10 +160,10 @@ def load_openai_service_settings( env_file_path: str | None, env_file_encoding: str | None, openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",), - azure_deployment_fields: Sequence[AzureDeploymentSettingName] = ("deployment_name",), + azure_model_fields: Sequence[OpenAIModelSettingName] = ("model",), responses_mode: bool = False, ) -> tuple[dict[str, Any], AsyncOpenAI, bool]: - """Load OpenAI settings, including Azure OpenAI aliases. + """Load OpenAI settings, including Azure OpenAI model aliases. The generic OpenAI clients primarily read from ``OPENAI_*`` variables. Azure-specific environment variables are used only when an explicit Azure signal is present @@ -235,20 +232,18 @@ def load_openai_service_settings( env_file_encoding=env_file_encoding, ) if model is not None: - azure_settings[azure_deployment_fields[0]] = model + azure_settings[azure_model_fields[0]] = model client_args = {} - resolved_azure_deployment = _resolve_named_setting(azure_settings, azure_deployment_fields) - if resolved_azure_deployment is None and client: + resolved_azure_model = _resolve_named_setting(azure_settings, azure_model_fields) + if resolved_azure_model is None and client: azure_deployment = getattr(client, "_azure_deployment", None) if isinstance(azure_deployment, str) and azure_deployment: - resolved_azure_deployment = azure_deployment - if resolved_azure_deployment: - azure_settings["deployment_name"] = resolved_azure_deployment - client_args["azure_deployment"] = resolved_azure_deployment + resolved_azure_model = azure_deployment + if resolved_azure_model: + azure_settings["model"] = resolved_azure_model + client_args["azure_deployment"] = resolved_azure_model else: - deployment_env_guidance = _join_env_names([ - AZURE_DEPLOYMENT_ENV_VARS[field] for field in azure_deployment_fields - ]) + deployment_env_guidance = _join_env_names([AZURE_MODEL_ENV_VARS[field] for field in azure_model_fields]) has_azure_configuration = ( client is not None or azure_settings.get("endpoint") is not None @@ -261,7 +256,7 @@ def load_openai_service_settings( "'AZURE_OPENAI_BASE_URL'." ) raise SettingNotFoundError( - "Azure OpenAI client requires a deployment name, which can be provided via the 'model' parameter, " + "Azure OpenAI client requires a model, which can be provided via the 'model' parameter, " f"or the {deployment_env_guidance} environment variable." ) if client: diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index 34ea878a19..9e11067213 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -45,20 +45,15 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_EMBEDDING_MODEL", "OPENAI_CHAT_MODEL", "OPENAI_RESPONSES_MODEL", - "OPENAI_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID", - "OPENAI_REALTIME_MODEL_ID", "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "AZURE_OPENAI_DEPLOYMENT_NAME", + "AZURE_OPENAI_CHAT_MODEL", + "AZURE_OPENAI_RESPONSES_MODEL", + "AZURE_OPENAI_EMBEDDING_MODEL", + "AZURE_OPENAI_MODEL", "AZURE_OPENAI_API_VERSION", ], ) @@ -66,13 +61,8 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # env_vars = { "OPENAI_API_KEY": "test-dummy-key", "OPENAI_ORG_ID": "test_org_id", - "OPENAI_MODEL": "test_model_id", - "OPENAI_EMBEDDING_MODEL": "test_embedding_model_id", - "OPENAI_TEXT_MODEL_ID": "test_text_model_id", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID": "test_text_to_image_model_id", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID": "test_audio_to_text_model_id", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID": "test_text_to_audio_model_id", - "OPENAI_REALTIME_MODEL_ID": "test_realtime_model_id", + "OPENAI_MODEL": "test_model", + "OPENAI_EMBEDDING_MODEL": "test_embedding_model", } env_vars.update(override_env_param_dict) # type: ignore @@ -104,30 +94,25 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_EMBEDDING_MODEL", "OPENAI_CHAT_MODEL", "OPENAI_RESPONSES_MODEL", - "OPENAI_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID", - "OPENAI_REALTIME_MODEL_ID", "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "AZURE_OPENAI_DEPLOYMENT_NAME", + "AZURE_OPENAI_CHAT_MODEL", + "AZURE_OPENAI_RESPONSES_MODEL", + "AZURE_OPENAI_EMBEDDING_MODEL", + "AZURE_OPENAI_MODEL", "AZURE_OPENAI_API_VERSION", ], ) env_vars = { "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.openai.azure.com", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_responses_deployment", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", - "AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment", + "AZURE_OPENAI_CHAT_MODEL": "test_chat_deployment", + "AZURE_OPENAI_RESPONSES_MODEL": "test_responses_deployment", + "AZURE_OPENAI_EMBEDDING_MODEL": "test_embedding_deployment", + "AZURE_OPENAI_MODEL": "test_deployment", "AZURE_OPENAI_API_KEY": "test_api_key", "AZURE_OPENAI_API_VERSION": "2024-12-01-preview", } diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 902e1d7c7a..dc7a119f2d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -151,11 +151,11 @@ def test_openai_chat_client_tool_methods_return_dict() -> None: def test_init_prefers_openai_responses_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model_id") + monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model") openai_responses_client = OpenAIChatClient() - assert openai_responses_client.model == "test_responses_model_id" + assert openai_responses_client.model == "test_responses_model" def test_init_validation_fail() -> None: @@ -164,12 +164,12 @@ def test_init_validation_fail() -> None: OpenAIChatClient(api_key="34523", model={"test": "dict"}) # type: ignore -def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None: +def test_init_model_constructor(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - model_id = "test_model_id" - openai_responses_client = OpenAIChatClient(model=model_id) + model = "test_model" + openai_responses_client = OpenAIChatClient(model=model) - assert openai_responses_client.model == model_id + assert openai_responses_client.model == model assert isinstance(openai_responses_client, SupportsChatGetResponse) @@ -191,18 +191,18 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) -def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: +def test_init_with_empty_model(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(SettingNotFoundError): OpenAIChatClient() @pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: - model_id = "test_model_id" + model = "test_model" with pytest.raises(SettingNotFoundError): OpenAIChatClient( - model=model_id, + model=model, ) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index bda022e94a..756de8580d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -24,10 +24,7 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or ( - os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "") == "" - and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), + or (os.getenv("AZURE_OPENAI_RESPONSES_MODEL", "") == "" and os.getenv("AZURE_OPENAI_MODEL", "") == ""), reason="No real Azure OpenAI endpoint or responses deployment provided; skipping integration tests.", ) @@ -39,9 +36,7 @@ def _with_azure_openai_debug() -> Any: try: return await func(*args, **kwargs) except Exception as exc: - model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) + model = os.getenv("AZURE_OPENAI_RESPONSES_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "") api_version = os.getenv("AZURE_OPENAI_API_VERSION") or "preview" endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" @@ -102,7 +97,7 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_MODEL"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -112,7 +107,7 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -147,7 +142,7 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ client = OpenAIChatClient(credential=lambda: "token") - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -155,34 +150,34 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ def test_init_falls_back_to_generic_azure_deployment_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_MODEL", raising=False) client = OpenAIChatClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) def test_init_does_not_fall_back_to_openai_responses_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatClient() def test_init_does_not_fall_back_to_openai_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.delenv("OPENAI_RESPONSES_MODEL", raising=False) monkeypatch.setenv("OPENAI_MODEL", "gpt-5") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatClient() @@ -209,7 +204,7 @@ def test_init_with_credential_wraps_async_token_credential( def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_MODEL"] assert client.api_version is not None diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 8318d164bb..0b2bb75e2f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -79,11 +79,11 @@ def test_supports_web_search_only() -> None: def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model_id") + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model") open_ai_chat_completion = OpenAIChatCompletionClient() - assert open_ai_chat_completion.model == "test_chat_model_id" + assert open_ai_chat_completion.model == "test_chat_model" def test_init_validation_fail() -> None: @@ -92,12 +92,12 @@ def test_init_validation_fail() -> None: OpenAIChatCompletionClient(api_key="34523", model={"test": "dict"}) # type: ignore -def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None: +def test_init_model_constructor(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - model_id = "test_model_id" - open_ai_chat_completion = OpenAIChatCompletionClient(model=model_id) + model = "test_model" + open_ai_chat_completion = OpenAIChatCompletionClient(model=model) - assert open_ai_chat_completion.model == model_id + assert open_ai_chat_completion.model == model assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) @@ -141,18 +141,18 @@ def test_init_base_url_from_settings_env() -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) -def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: +def test_init_with_empty_model(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient() @pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: - model_id = "test_model_id" + model = "test_model" with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient( - model=model_id, + model=model, ) @@ -1178,10 +1178,10 @@ def test_parse_text_with_refusal(openai_unit_test_env: dict[str, str]) -> None: assert message.contents[0].text == "I cannot provide that information." -def test_prepare_options_without_model_id(openai_unit_test_env: dict[str, str]) -> None: - """Test that prepare_options raises error when model_id is not set.""" +def test_prepare_options_without_model(openai_unit_test_env: dict[str, str]) -> None: + """Test that prepare_options raises error when model is not set.""" client = OpenAIChatCompletionClient() - client.model = None # Remove model_id + client.model = None # Remove model messages = [Message(role="user", text="test")] diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py index f9edab227a..3a5e930d1f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -29,9 +29,7 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or ( - os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "") == "" and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), + or (os.getenv("AZURE_OPENAI_CHAT_MODEL", "") == "" and os.getenv("AZURE_OPENAI_MODEL", "") == ""), reason="No real Azure OpenAI endpoint or chat deployment provided; skipping integration tests.", ) @@ -43,9 +41,7 @@ def _with_azure_openai_debug() -> Any: try: return await func(*args, **kwargs) except Exception as exc: - model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) + model = os.getenv("AZURE_OPENAI_CHAT_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "") api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" @@ -82,7 +78,7 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatCompletionClient(azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT")) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -93,7 +89,7 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatCompletionClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -115,7 +111,7 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ client = OpenAIChatCompletionClient(credential=lambda: "token") - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -123,34 +119,34 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ def test_init_falls_back_to_generic_azure_deployment_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False) client = OpenAIChatCompletionClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) def test_init_does_not_fall_back_to_openai_chat_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatCompletionClient() def test_init_does_not_fall_back_to_openai_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.delenv("OPENAI_CHAT_MODEL", raising=False) monkeypatch.setenv("OPENAI_MODEL", "gpt-5") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIChatCompletionClient() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index 3244f9620b..1690c2b70e 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -185,7 +185,7 @@ async def test_openai_base64_decoding(openai_unit_test_env: dict[str, str]) -> N assert abs(expected - actual) < 1e-6 -async def test_openai_error_when_no_model_id() -> None: +async def test_openai_error_when_no_model() -> None: client = OpenAIEmbeddingClient.__new__(OpenAIEmbeddingClient) client.model = None client.client = MagicMock() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py index 3cf62a064d..2d3c457bf6 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py @@ -19,10 +19,7 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or ( - os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" - and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), + or (os.getenv("AZURE_OPENAI_EMBEDDING_MODEL", "") == "" and os.getenv("AZURE_OPENAI_MODEL", "") == ""), reason="No real Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", ) @@ -34,9 +31,7 @@ def _with_azure_openai_debug() -> Any: try: return await func(*args, **kwargs) except Exception as exc: - model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) + model = os.getenv("AZURE_OPENAI_EMBEDDING_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "") api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" @@ -54,7 +49,7 @@ def _with_azure_openai_debug() -> Any: def _get_azure_embedding_deployment_name() -> str: - return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + return os.getenv("AZURE_OPENAI_EMBEDDING_MODEL") or os.environ["AZURE_OPENAI_MODEL"] def _create_azure_embedding_client( @@ -77,7 +72,7 @@ def _create_azure_embedding_client( def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: client = _create_azure_embedding_client() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -87,7 +82,7 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -95,34 +90,34 @@ def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[ def test_init_falls_back_to_generic_azure_deployment_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False) client = OpenAIEmbeddingClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) def test_init_does_not_fall_back_to_openai_embedding_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIEmbeddingClient() def test_init_does_not_fall_back_to_openai_model_for_azure_env( monkeypatch, azure_openai_unit_test_env: dict[str, str] ) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) - monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False) monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) monkeypatch.setenv("OPENAI_MODEL", "gpt-5") - with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"): OpenAIEmbeddingClient() @@ -156,7 +151,7 @@ def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_ client = OpenAIEmbeddingClient(credential=lambda: "token") - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] @@ -177,7 +172,7 @@ def test_init_with_credential_wraps_async_token_credential( client = OpenAIEmbeddingClient(credential=credential) assert isinstance(client.client, AsyncAzureOpenAI) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") @@ -185,7 +180,7 @@ def test_init_with_credential_wraps_async_token_credential( def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: client = _create_azure_embedding_client() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"] assert client.api_version == "2024-10-21" diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index cbe2999040..a802cd9615 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -245,7 +245,7 @@ from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=credential, middleware=[ diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index 80b1e0ea1a..0a67230d51 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -51,7 +51,7 @@ Depending on the selected client, set the appropriate environment variables: **For Azure OpenAI clients (`azure_openai_responses` and `azure_openai_chat_completion`):** - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_DEPLOYMENT_NAME`: The Azure OpenAI deployment used by the sample +- `AZURE_OPENAI_MODEL`: The Azure OpenAI deployment used by the sample - `AZURE_OPENAI_API_VERSION` (optional): Azure OpenAI API version override - `AZURE_OPENAI_API_KEY` (optional): Azure OpenAI API key if you are not using `AzureCliCredential` @@ -66,13 +66,13 @@ Depending on the selected client, set the appropriate environment variables: **For Anthropic client (`anthropic`):** - `ANTHROPIC_API_KEY`: Your Anthropic API key -- `ANTHROPIC_CHAT_MODEL_ID`: The Anthropic model ID (for example, `claude-sonnet-4-5`) +- `ANTHROPIC_CHAT_MODEL`: The Anthropic model to use (for example, `claude-sonnet-4-5`) **For Ollama client (`ollama`):** - `OLLAMA_HOST`: Ollama server URL (defaults to `http://localhost:11434` if unset) -- `OLLAMA_MODEL_ID`: Ollama model name (for example, `mistral`, `qwen2.5:8b`) +- `OLLAMA_MODEL`: Ollama model name (for example, `mistral`, `qwen2.5:8b`) **For Bedrock client (`bedrock`):** -- `BEDROCK_CHAT_MODEL_ID`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) +- `BEDROCK_CHAT_MODEL`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) - `BEDROCK_REGION`: AWS region (defaults to `us-east-1` if unset) - AWS credentials via standard environment variables (for example, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) diff --git a/python/samples/02-agents/chat_client/chat_response_cancellation.py b/python/samples/02-agents/chat_client/chat_response_cancellation.py index 6c7bc39894..1455026d64 100644 --- a/python/samples/02-agents/chat_client/chat_response_cancellation.py +++ b/python/samples/02-agents/chat_client/chat_response_cancellation.py @@ -24,7 +24,7 @@ async def main() -> None: Creates a task for the chat request, waits briefly, then cancels it to show proper cleanup. Configuration: - - OpenAI model ID: Use "model_id" parameter or "OPENAI_MODEL" environment variable + - OpenAI model ID: Use "model" parameter or "OPENAI_MODEL" environment variable - OpenAI API key: Use "api_key" parameter or "OPENAI_API_KEY" environment variable """ client = FoundryChatClient(credential=AzureCliCredential()) diff --git a/python/samples/02-agents/chat_client/custom_chat_client.py b/python/samples/02-agents/chat_client/custom_chat_client.py index c677c09d8b..7a7406f23e 100644 --- a/python/samples/02-agents/chat_client/custom_chat_client.py +++ b/python/samples/02-agents/chat_client/custom_chat_client.py @@ -102,7 +102,7 @@ class EchoingChatClient(BaseChatClient[OptionsT]): response = ChatResponse( messages=[response_message], - model_id="echo-model-v1", + model="echo-model-v1", response_id=f"echo-resp-{random.randint(1000, 9999)}", ) @@ -120,7 +120,7 @@ class EchoingChatClient(BaseChatClient[OptionsT]): contents=[Content.from_text(char)], role="assistant", response_id=f"echo-stream-resp-{random.randint(1000, 9999)}", - model_id="echo-model-v1", + model="echo-model-v1", ) await asyncio.sleep(stream_delay_seconds) diff --git a/python/samples/02-agents/compaction/tiktoken_tokenizer.py b/python/samples/02-agents/compaction/tiktoken_tokenizer.py index c0ba93c806..ef109eaaa3 100644 --- a/python/samples/02-agents/compaction/tiktoken_tokenizer.py +++ b/python/samples/02-agents/compaction/tiktoken_tokenizer.py @@ -33,9 +33,9 @@ Key components: class TiktokenTokenizer(TokenizerProtocol): """TokenizerProtocol implementation backed by tiktoken's o200k_base (gpt-4.1 and up default) encoding.""" - def __init__(self, *, encoding_name: str = "o200k_base", model_name: str | None = None) -> None: - if model_name is not None: - self._encoding = tiktoken.encoding_for_model(model_name) + def __init__(self, *, encoding_name: str = "o200k_base", model: str | None = None) -> None: + if model is not None: + self._encoding = tiktoken.encoding_for_model(model) else: self._encoding: Any = tiktoken.get_encoding(encoding_name) diff --git a/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py b/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py index 9c27e5857f..fbbeb20b14 100644 --- a/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py +++ b/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py @@ -33,7 +33,7 @@ rather than chat history. The memory store is deleted at the end of the run. Prerequisites: 1. Set FOUNDRY_PROJECT_ENDPOINT environment variable 2. Set FOUNDRY_MODEL for the chat/responses model -3. Set AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME for the embedding model +3. Set AZURE_OPENAI_EMBEDDING_MODEL for the embedding model 4. Deploy both a chat model (e.g. gpt-4) and an embedding model (e.g. text-embedding-3-small) """ load_dotenv() @@ -55,7 +55,7 @@ async def main() -> None: ) memory_store_definition = MemoryStoreDefaultDefinition( chat_model=os.environ["FOUNDRY_MODEL"], - embedding_model=os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + embedding_model=os.environ["AZURE_OPENAI_EMBEDDING_MODEL"], options=options, ) print(f"Creating memory store '{memory_store_name}'...") diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index d12b7df849..5f2a57f511 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -32,7 +32,7 @@ Prerequisites: - AZURE_SEARCH_INDEX_NAME: Your search index name - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - FOUNDRY_MODEL: Your model deployment name (e.g., "gpt-4o") - - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: (Optional) Your Azure OpenAI embedding deployment for hybrid search + - AZURE_OPENAI_EMBEDDING_MODEL: (Optional) Your Azure OpenAI embedding deployment for hybrid search - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using Azure OpenAI embeddings """ @@ -56,7 +56,7 @@ async def main() -> None: project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") - embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") + embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL") embedding_client = None if openai_endpoint and embedding_deployment: diff --git a/python/samples/02-agents/declarative/README.md b/python/samples/02-agents/declarative/README.md index 045280218e..4bc4489dc2 100644 --- a/python/samples/02-agents/declarative/README.md +++ b/python/samples/02-agents/declarative/README.md @@ -159,7 +159,7 @@ agent_factory = AgentFactory( "MyProvider": { "package": "my_custom_module", "name": "MyCustomChatClient", - "model_id_field": "model_id", + "model_field": "model", } } ) @@ -176,7 +176,7 @@ agent = agent_factory.create_agent_from_yaml_path(Path("custom_provider.yaml")) This allows you to extend the declarative framework with custom chat client implementations. The mapping requires: - **package**: The Python package/module to import from - **name**: The class name of your SupportsChatGetResponse implementation -- **model_id_field**: The constructor parameter name that accepts the value of the `model.id` field from the YAML +- **model_field**: The constructor parameter name that accepts the value of the `model.id` field from the YAML You can reference your custom provider using either `Provider.ApiType` format or just `Provider` in your YAML configuration, as long as it matches the registered mapping. diff --git a/python/samples/02-agents/devui/.env.example b/python/samples/02-agents/devui/.env.example index 394dc7465d..712a1f7a30 100644 --- a/python/samples/02-agents/devui/.env.example +++ b/python/samples/02-agents/devui/.env.example @@ -8,8 +8,8 @@ FOUNDRY_MODEL=gpt-4o # Azure OpenAI workflow sample AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_RESPONSES_MODEL=gpt-4o # Optional fallback env name also supported by workflow_with_agents/workflow.py: -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_MODEL=gpt-4o # Optional if you need to override the default API version: AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index 8a0ed60b6f..8498a0cab5 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -94,7 +94,7 @@ workflow_name/ | Sample | What it demonstrates | Required keys / auth | | ------ | -------------------- | -------------------- | | [**workflow_declarative/**](workflow_declarative/) | A YAML-defined workflow loaded through `WorkflowFactory`, with nested age-based branching and no model client code. | None | -| [**workflow_with_agents/**](workflow_with_agents/) | A content review workflow that uses agents as executors and routes based on structured review output (`Writer -> Reviewer -> Editor/Publisher -> Summarizer`). | `AZURE_OPENAI_ENDPOINT`, plus `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` or `AZURE_OPENAI_DEPLOYMENT_NAME`; Azure CLI auth via `az login`; `AZURE_OPENAI_API_VERSION` is optional | +| [**workflow_with_agents/**](workflow_with_agents/) | A content review workflow that uses agents as executors and routes based on structured review output (`Writer -> Reviewer -> Editor/Publisher -> Summarizer`). | `AZURE_OPENAI_ENDPOINT`, plus `AZURE_OPENAI_RESPONSES_MODEL` or `AZURE_OPENAI_MODEL`; Azure CLI auth via `az login`; `AZURE_OPENAI_API_VERSION` is optional | | [**workflow_spam/**](workflow_spam/) | A multi-step spam detection workflow with human-in-the-loop approval, branching for spam vs. legitimate messages, and a final reporting step. | None | | [**workflow_fanout/**](workflow_fanout/) | A larger fan-out/fan-in data processing workflow with parallel validation, multiple transformations, QA, aggregation, and demo failure toggles. | None | @@ -130,8 +130,8 @@ export FOUNDRY_MODEL="gpt-4o" # Azure OpenAI workflow_with_agents sample export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" -export AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="gpt-4o" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" +export AZURE_OPENAI_RESPONSES_MODEL="gpt-4o" +export AZURE_OPENAI_MODEL="gpt-4o" az login ``` diff --git a/python/samples/02-agents/devui/workflow_with_agents/.env.example b/python/samples/02-agents/devui/workflow_with_agents/.env.example index 520cd181cc..ebbf5ccc9e 100644 --- a/python/samples/02-agents/devui/workflow_with_agents/.env.example +++ b/python/samples/02-agents/devui/workflow_with_agents/.env.example @@ -2,8 +2,8 @@ # This sample uses Azure CLI auth, so run `az login` before starting DevUI. AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_RESPONSES_MODEL=gpt-4o # Optional fallback env name also supported by the client: -# AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +# AZURE_OPENAI_MODEL=gpt-4o # Optional if you need to override the default API version: AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/devui/workflow_with_agents/workflow.py b/python/samples/02-agents/devui/workflow_with_agents/workflow.py index cda57a67f7..598e7201f7 100644 --- a/python/samples/02-agents/devui/workflow_with_agents/workflow.py +++ b/python/samples/02-agents/devui/workflow_with_agents/workflow.py @@ -65,7 +65,7 @@ def is_approved(message: Any) -> bool: # Create Azure OpenAI Responses chat client client = OpenAIChatClient( - model=os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.environ.get("AZURE_OPENAI_RESPONSES_MODEL") or os.environ.get("AZURE_OPENAI_MODEL"), azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), api_version=os.environ.get("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), diff --git a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py b/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py index e806166f16..931d8a8759 100644 --- a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py +++ b/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py @@ -31,9 +31,9 @@ Prerequisites: - AZURE_AI_INFERENCE_ENDPOINT: Your Azure AI model inference endpoint URL, for instance: https://.azure-api.net//models - AZURE_AI_INFERENCE_API_KEY: Your API key - - AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID: The text embedding model name + - AZURE_AI_INFERENCE_EMBEDDING_MODEL: The text embedding model name (e.g. "text-embedding-3-small") - - AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID: The image embedding model name + - AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL: The image embedding model name (e.g. "Cohere-embed-v3-english") """ @@ -49,7 +49,7 @@ async def main() -> None: result = await client.get_embeddings([image_content]) print(f"Image embedding dimensions: {result[0].dimensions}") print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") + print(f"Model: {result[0].model}") print(f"Usage: {result.usage}") print() diff --git a/python/samples/02-agents/embeddings/azure_openai_embeddings.py b/python/samples/02-agents/embeddings/azure_openai_embeddings.py index 460bbcf7de..30f3cf4569 100644 --- a/python/samples/02-agents/embeddings/azure_openai_embeddings.py +++ b/python/samples/02-agents/embeddings/azure_openai_embeddings.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv Prerequisites: Set the following environment variables or add them to a local ``.env`` file: - ``AZURE_OPENAI_ENDPOINT``: Your Azure OpenAI endpoint URL - - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``: The embedding deployment name + - ``AZURE_OPENAI_EMBEDDING_MODEL``: The embedding deployment name - ``AZURE_OPENAI_API_VERSION``: Optional API version override Sign in with ``az login`` before running the sample. @@ -27,7 +27,7 @@ async def main() -> None: """Generate embeddings with Azure OpenAI.""" async with AzureCliCredential() as credential: client = OpenAIEmbeddingClient( - model=os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_EMBEDDING_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=credential, diff --git a/python/samples/02-agents/evaluation/evaluate_with_expected.py b/python/samples/02-agents/evaluation/evaluate_with_expected.py index de44c4e7e9..a165593c18 100644 --- a/python/samples/02-agents/evaluation/evaluate_with_expected.py +++ b/python/samples/02-agents/evaluation/evaluate_with_expected.py @@ -41,7 +41,7 @@ def response_matches_expected(response: str, expected_output: str) -> float: async def main() -> None: client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/02-agents/multimodal_input/README.md b/python/samples/02-agents/multimodal_input/README.md index 6feda8e62c..da99e71057 100644 --- a/python/samples/02-agents/multimodal_input/README.md +++ b/python/samples/02-agents/multimodal_input/README.md @@ -32,8 +32,8 @@ Set the following environment variables before running the examples: **For Azure OpenAI:** - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat model deployment -- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses model deployment +- `AZURE_OPENAI_MODEL`: The name of your Azure OpenAI chat model deployment +- `AZURE_OPENAI_MODEL`: The name of your Azure OpenAI responses model deployment Optionally for Azure OpenAI: - `AZURE_OPENAI_API_VERSION`: The API version to use (default is `2024-10-21`) diff --git a/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py index 8a141fa9ce..2d7c5c9a20 100644 --- a/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py @@ -23,7 +23,7 @@ async def test_image() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. Requires AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL # environment variables to be set. - # Alternatively, you can pass deployment_name explicitly: + # Alternatively, you can pass model explicitly: # client = FoundryChatClient(credential=AzureCliCredential(), model="your-deployment-name") client = FoundryChatClient(credential=AzureCliCredential()) image_uri = create_sample_image() diff --git a/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py index 61f14749e7..ec0a684c47 100644 --- a/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py @@ -32,7 +32,7 @@ async def test_image() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. Requires AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL # environment variables to be set. - # Alternatively, you can pass deployment_name explicitly: + # Alternatively, you can pass model explicitly: # client = FoundryChatClient(credential=AzureCliCredential(), model="your-deployment-name") client = FoundryChatClient(credential=AzureCliCredential()) diff --git a/python/samples/02-agents/providers/amazon/README.md b/python/samples/02-agents/providers/amazon/README.md index ab10aeecfd..5dfd3e2c82 100644 --- a/python/samples/02-agents/providers/amazon/README.md +++ b/python/samples/02-agents/providers/amazon/README.md @@ -1,7 +1,7 @@ # Bedrock Examples This folder contains examples demonstrating how to use AWS Bedrock models with the Agent Framework. The sample -uses `BEDROCK_CHAT_MODEL_ID`, `BEDROCK_REGION`, and AWS credentials (`AWS_ACCESS_KEY_ID`, +uses `BEDROCK_CHAT_MODEL`, `BEDROCK_REGION`, and AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`). ## Examples @@ -12,6 +12,6 @@ uses `BEDROCK_CHAT_MODEL_ID`, `BEDROCK_REGION`, and AWS credentials (`AWS_ACCESS ## Environment Variables -- `BEDROCK_CHAT_MODEL_ID`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) +- `BEDROCK_CHAT_MODEL`: Bedrock model ID (for example, `anthropic.claude-3-5-sonnet-20240620-v1:0`) - `BEDROCK_REGION`: AWS region (defaults to `us-east-1` if unset) - AWS credentials via standard variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`) diff --git a/python/samples/02-agents/providers/amazon/bedrock_chat_client.py b/python/samples/02-agents/providers/amazon/bedrock_chat_client.py index a61944bcbd..2c1f0b8845 100644 --- a/python/samples/02-agents/providers/amazon/bedrock_chat_client.py +++ b/python/samples/02-agents/providers/amazon/bedrock_chat_client.py @@ -17,7 +17,7 @@ Bedrock Chat Client Example This sample demonstrates using `BedrockChatClient` with an agent and a simple tool. Environment variables used: -- `BEDROCK_CHAT_MODEL_ID` +- `BEDROCK_CHAT_MODEL` - `BEDROCK_REGION` (defaults to `us-east-1` if unset) - AWS credentials via standard variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN`) diff --git a/python/samples/02-agents/providers/anthropic/README.md b/python/samples/02-agents/providers/anthropic/README.md index 5151ade855..0db183490a 100644 --- a/python/samples/02-agents/providers/anthropic/README.md +++ b/python/samples/02-agents/providers/anthropic/README.md @@ -28,14 +28,14 @@ This folder contains examples demonstrating how to use Anthropic's Claude models ### Anthropic Client - `ANTHROPIC_API_KEY`: Your Anthropic API key (get one from [Anthropic Console](https://console.anthropic.com/)) -- `ANTHROPIC_CHAT_MODEL_ID`: The Claude model to use (e.g., `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`) +- `ANTHROPIC_CHAT_MODEL`: The Claude model to use (e.g., `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`) ### Foundry - `ANTHROPIC_FOUNDRY_API_KEY`: Your Foundry Anthropic API key - `ANTHROPIC_FOUNDRY_RESOURCE`: Your Foundry resource name (for example `my-foundry-resource`) - `ANTHROPIC_FOUNDRY_BASE_URL`: Optional full Foundry Anthropic base URL alternative to `ANTHROPIC_FOUNDRY_RESOURCE` -- `ANTHROPIC_CHAT_MODEL_ID`: The Claude model to use in Foundry (e.g., `claude-haiku-4-5`) +- `ANTHROPIC_CHAT_MODEL`: The Claude model to use in Foundry (e.g., `claude-haiku-4-5`) ### Claude Agent diff --git a/python/samples/02-agents/providers/anthropic/anthropic_basic.py b/python/samples/02-agents/providers/anthropic/anthropic_basic.py index 85f0485d66..3ff9f81504 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_basic.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_basic.py @@ -35,7 +35,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming Response Example ===") agent = Agent( - client=AnthropicClient(model_id="claude-sonnet-4-5-20250929"), + client=AnthropicClient(model="claude-sonnet-4-5-20250929"), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, @@ -52,7 +52,7 @@ async def streaming_example() -> None: print("=== Streaming Response Example ===") agent = Agent( - client=AnthropicClient(model_id="claude-sonnet-4-5-20250929"), + client=AnthropicClient(model="claude-sonnet-4-5-20250929"), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, diff --git a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py index dd66262131..8807059362 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py @@ -2,8 +2,7 @@ import asyncio from agent_framework import Agent -from agent_framework.anthropic import AnthropicClient -from anthropic import AsyncAnthropicFoundry +from agent_framework.foundry import AnthropicFoundryClient from dotenv import load_dotenv # Load environment variables from .env file @@ -27,14 +26,14 @@ To use the Foundry integration ensure you have the following environment variabl - ANTHROPIC_FOUNDRY_BASE_URL Optional alternative to ANTHROPIC_FOUNDRY_RESOURCE. Should be something like https://.services.ai.azure.com/anthropic/ -- ANTHROPIC_CHAT_MODEL_ID +- ANTHROPIC_CHAT_MODEL Should be something like claude-haiku-4-5 """ async def main() -> None: """Example of streaming response (get results as they are generated).""" - client = AnthropicClient(anthropic_client=AsyncAnthropicFoundry()) + client = AnthropicFoundryClient() # Create MCP tool configuration using instance method mcp_tool = client.get_mcp_tool( diff --git a/python/samples/02-agents/providers/anthropic/anthropic_skills.py b/python/samples/02-agents/providers/anthropic/anthropic_skills.py index a93874c269..9d8b2df92b 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_skills.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_skills.py @@ -29,7 +29,7 @@ async def main() -> None: client = AnthropicClient[AnthropicChatOptions](additional_beta_flags=["skills-2025-10-02"]) # List Anthropic-managed Skills - skills = await client.anthropic_client.beta.skills.list(source="anthropic", betas=["skills-2025-10-02"]) + skills = await client.anthropic_client.beta.skills.list(source="anthropic", betas=["skills-2025-10-02"]) # type: ignore for skill in skills.data: print(f"{skill.source}: {skill.id} (version: {skill.latest_version})") @@ -81,7 +81,7 @@ async def main() -> None: # Since I'm using the pptx skill, the files will be PowerPoint presentations print("Generated files:") for idx, file in enumerate(files): - file_content = await client.anthropic_client.beta.files.download( + file_content = await client.anthropic_client.beta.files.download( # type: ignore file_id=file.file_id, betas=["files-api-2025-04-14"] ) with open(Path(__file__).parent / f"python_programming-{idx}.pptx", "wb") as f: diff --git a/python/samples/02-agents/providers/azure/README.md b/python/samples/02-agents/providers/azure/README.md index cd34fe2717..ab33281a37 100644 --- a/python/samples/02-agents/providers/azure/README.md +++ b/python/samples/02-agents/providers/azure/README.md @@ -26,7 +26,7 @@ This folder contains Azure-backed samples for the generic OpenAI clients in Set these before running the Azure provider samples: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` Optionally, you can also set: diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py index 030828da89..9381d7f80c 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: agent = Agent( client=OpenAIChatCompletionClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), @@ -60,7 +60,7 @@ async def streaming_example() -> None: agent = Agent( client=OpenAIChatCompletionClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py index 613aa03000..afe73b428d 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py @@ -41,7 +41,7 @@ async def main() -> None: # authentication option. agent = Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=AzureCliCredential(), ), diff --git a/python/samples/02-agents/providers/azure/openai_client_basic.py b/python/samples/02-agents/providers/azure/openai_client_basic.py index 3029c03cda..0bb061ef92 100644 --- a/python/samples/02-agents/providers/azure/openai_client_basic.py +++ b/python/samples/02-agents/providers/azure/openai_client_basic.py @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: agent = Agent( client=OpenAIChatClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), @@ -60,7 +60,7 @@ async def streaming_example() -> None: agent = Agent( client=OpenAIChatClient( - model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + model=os.getenv("AZURE_OPENAI_MODEL"), azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version=os.getenv("AZURE_OPENAI_API_VERSION"), credential=AzureCliCredential(), diff --git a/python/samples/02-agents/providers/foundry/README.md b/python/samples/02-agents/providers/foundry/README.md index 80ddab2dcd..164032b752 100644 --- a/python/samples/02-agents/providers/foundry/README.md +++ b/python/samples/02-agents/providers/foundry/README.md @@ -45,4 +45,4 @@ This folder contains Azure AI Foundry and Foundry Local samples for Agent Framew ### Environment Variables -- `FOUNDRY_LOCAL_MODEL_ID`: Optional model alias/ID to use by default when `model_id` is not passed to `FoundryLocalClient`. +- `FOUNDRY_LOCAL_MODEL`: Optional model alias/ID to use by default when `model` is not passed to `FoundryLocalClient`. diff --git a/python/samples/02-agents/providers/ollama/README.md b/python/samples/02-agents/providers/ollama/README.md index 2a10ae2f57..2dca35a8fd 100644 --- a/python/samples/02-agents/providers/ollama/README.md +++ b/python/samples/02-agents/providers/ollama/README.md @@ -40,8 +40,8 @@ Set the following environment variables: - `OLLAMA_HOST`: The base URL for your Ollama server (optional, defaults to `http://localhost:11434`) - Example: `export OLLAMA_HOST="http://localhost:11434"` -- `OLLAMA_MODEL_ID`: The model name to use - - Example: `export OLLAMA_MODEL_ID="qwen2.5:8b"` +- `OLLAMA_MODEL`: The model name to use + - Example: `export OLLAMA_MODEL="qwen2.5:8b"` - Must be a model you have pulled with Ollama ### For OpenAI Client with Ollama (`ollama_with_openai_chat_client.py`) diff --git a/python/samples/02-agents/providers/ollama/ollama_agent_basic.py b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py index 3652b069c4..a987c05398 100644 --- a/python/samples/02-agents/providers/ollama/ollama_agent_basic.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py @@ -17,7 +17,7 @@ This sample demonstrates implementing a Ollama agent with basic tool usage. Ensure to install Ollama and have a model running locally before running the sample Not all Models support function calling, to test function calling try llama3.2 or qwen3:4b -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py index b23727311b..24defc5a8f 100644 --- a/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py @@ -16,7 +16,7 @@ This sample demonstrates implementing a Ollama agent with reasoning. Ensure to install Ollama and have a model running locally before running the sample Not all Models support reasoning, to test reasoning try qwen3:8b -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/providers/ollama/ollama_chat_client.py b/python/samples/02-agents/providers/ollama/ollama_chat_client.py index c92eaa0f55..15dbdf48aa 100644 --- a/python/samples/02-agents/providers/ollama/ollama_chat_client.py +++ b/python/samples/02-agents/providers/ollama/ollama_chat_client.py @@ -17,7 +17,7 @@ This sample demonstrates using the native Ollama Chat Client directly. Ensure to install Ollama and have a model running locally before running the sample. Not all Models support function calling, to test function calling try llama3.2 -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py b/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py index bfeb7824fa..41e7c3aedd 100644 --- a/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py +++ b/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py @@ -16,7 +16,7 @@ This sample demonstrates implementing a Ollama agent with multimodal input capab Ensure to install Ollama and have a model running locally before running the sample Not all Models support multimodal input, to test multimodal input try gemma3:4b -Set the model to use via the OLLAMA_MODEL_ID environment variable or modify the code below. +Set the model to use via the OLLAMA_MODEL environment variable or modify the code below. https://ollama.com/ """ diff --git a/python/samples/02-agents/skills/code_defined_skill/README.md b/python/samples/02-agents/skills/code_defined_skill/README.md index 281580bcc7..c19a3c17d3 100644 --- a/python/samples/02-agents/skills/code_defined_skill/README.md +++ b/python/samples/02-agents/skills/code_defined_skill/README.md @@ -28,7 +28,7 @@ code_defined_skill/ Set the required environment variables in a `.env` file (see `python/.env.example`): - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/file_based_skill/README.md b/python/samples/02-agents/skills/file_based_skill/README.md index 23a27d1c24..fa07b7e973 100644 --- a/python/samples/02-agents/skills/file_based_skill/README.md +++ b/python/samples/02-agents/skills/file_based_skill/README.md @@ -48,7 +48,7 @@ file_based_skill/ Set the required environment variables in a `.env` file (see `python/.env.example`): - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md index 0703ce5d20..d41f7dbaa9 100644 --- a/python/samples/02-agents/skills/mixed_skills/README.md +++ b/python/samples/02-agents/skills/mixed_skills/README.md @@ -61,7 +61,7 @@ Set environment variables (or create a `.env` file): ``` FOUNDRY_PROJECT_ENDPOINT=https://your-project.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o-mini +AZURE_OPENAI_MODEL=gpt-4o-mini ``` Authenticate with Azure CLI: diff --git a/python/samples/02-agents/skills/script_approval/README.md b/python/samples/02-agents/skills/script_approval/README.md index 81ef08c416..07dd37cb65 100644 --- a/python/samples/02-agents/skills/script_approval/README.md +++ b/python/samples/02-agents/skills/script_approval/README.md @@ -29,7 +29,7 @@ When `require_script_approval=True` is set, the agent pauses before executing an Set the required environment variables in a `.env` file (see `python/.env.example`): - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/typed_options.py b/python/samples/02-agents/typed_options.py index e6d5605ee8..65c2332892 100644 --- a/python/samples/02-agents/typed_options.py +++ b/python/samples/02-agents/typed_options.py @@ -38,7 +38,7 @@ async def demo_anthropic_chat_client() -> None: print("\n=== Anthropic ChatClient with TypedDict Options ===\n") # Create Anthropic client - client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client = AnthropicClient(model="claude-sonnet-4-5-20250929") # Standard options work great: response = await client.get_response( @@ -53,14 +53,14 @@ async def demo_anthropic_chat_client() -> None: ) print(f"Anthropic Response: {response.text}") - print(f"Model used: {response.model_id}") + print(f"Model used: {response.model}") async def demo_anthropic_agent() -> None: """Demonstrate Agent with Anthropic client and typed options.""" print("\n=== Agent with Anthropic and Typed Options ===\n") - client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client = AnthropicClient(model="claude-sonnet-4-5-20250929") # Create a typed agent for Anthropic - IDE knows Anthropic-specific options! agent = Agent( @@ -129,7 +129,7 @@ async def demo_openai_chat_client_reasoning_models() -> None: ) print(f"OpenAI Response: {response.text}") - print(f"Model used: {response.model_id}") + print(f"Model used: {response.model}") async def demo_openai_agent() -> None: diff --git a/python/samples/04-hosting/a2a/a2a_server.py b/python/samples/04-hosting/a2a/a2a_server.py index de3f361457..0bd0b29dc5 100644 --- a/python/samples/04-hosting/a2a/a2a_server.py +++ b/python/samples/04-hosting/a2a/a2a_server.py @@ -67,12 +67,12 @@ def main() -> None: # Validate environment project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") - deployment_name = os.getenv("FOUNDRY_MODEL") + model = os.getenv("FOUNDRY_MODEL") if not project_endpoint: print("Error: FOUNDRY_PROJECT_ENDPOINT environment variable is not set.") sys.exit(1) - if not deployment_name: + if not model: print("Error: FOUNDRY_MODEL environment variable is not set.") sys.exit(1) @@ -80,7 +80,7 @@ def main() -> None: credential = AzureCliCredential() client = FoundryChatClient( project_endpoint=project_endpoint, - model=deployment_name, + model=model, credential=credential, ) diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py index 9771a27f70..e9e2cb05f3 100644 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py @@ -7,7 +7,7 @@ Components used in this sample: - AgentFunctionApp to register multiple agents and expose dedicated HTTP endpoints. - Custom tool functions to demonstrate tool invocation from different agents. -Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, and sign in with Azure CLI before starting the Functions host.""" +Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_MODEL`, and sign in with Azure CLI before starting the Functions host.""" import logging from typing import Any diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template index bfea3f5279..f39a3a2933 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template @@ -10,4 +10,4 @@ TASKHUB_NAME=default # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_MODEL=your-deployment-name diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md b/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md index 99afd35edb..e12cec0046 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md @@ -99,7 +99,7 @@ The sample can run locally without Azure Functions infrastructure using DevUI: ``` 2. Configure `.env` with your Azure OpenAI credentials (`AZURE_OPENAI_ENDPOINT` and - `AZURE_OPENAI_DEPLOYMENT_NAME`) + `AZURE_OPENAI_MODEL`) 3. Install dependencies: ```bash diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py b/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py index 2ea10e8ce7..7deea4211c 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py @@ -21,7 +21,7 @@ Key architectural points: - Mixed agent/executor fan-outs execute concurrently Prerequisites: -- Configure `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME` +- Configure `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_MODEL` - Sign in with Azure CLI (`az login`) for `AzureCliCredential` - Ensure Azurite and the Durable Task Scheduler emulator are running """ @@ -362,7 +362,7 @@ def _create_workflow() -> Workflow: credential = AzureCliCredential() chat_client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"), ) diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample b/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample index 23140d3eec..5b65dd278f 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample @@ -6,6 +6,6 @@ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_DEPLOYMENT_NAME": "" + "AZURE_OPENAI_MODEL": "" } } diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py index 81933de8ee..693e6dcfe0 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/client.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/client.py @@ -8,7 +8,7 @@ each with their own specialized capabilities and tools. Prerequisites: - The worker must be running with both agents registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME when running the worker +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL when running the worker - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py index 4ef01fe400..e847abbd64 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py @@ -5,7 +5,7 @@ This sample demonstrates running both the worker and client in a single process for multiple agents with different tools. The worker registers two agents (WeatherAgent and MathAgent), each with their own specialized capabilities. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) To run this sample: diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py index 9183e9ee61..36bb96a555 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py @@ -7,7 +7,7 @@ with their own specialized tools. This demonstrates how to host multiple agents with different capabilities in a single worker process. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL - Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md index caa3ca03f5..cc6879fc76 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md @@ -17,7 +17,7 @@ See the [README.md](../README.md) file in the parent directory for more informat This sample uses Azure OpenAI credentials: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_DEPLOYMENT_NAME` +- `AZURE_OPENAI_MODEL` ## Running the Sample diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py index 2eac694ed6..da42ad6d45 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py @@ -7,7 +7,7 @@ that uses conditional logic to either handle spam emails or draft professional r Prerequisites: - The worker must be running with both agents, orchestration, and activities registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py index a00e075d4a..f243b2ccb7 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py @@ -10,7 +10,7 @@ The orchestration branches based on spam detection results, calling different activity functions to handle spam or send legitimate email responses. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py index e9cda191f4..0b5f014873 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py @@ -7,7 +7,7 @@ orchestration function that routes execution based on spam detection results. Ac handle side effects (spam handling and email sending). Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL - Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -69,7 +69,7 @@ def create_spam_agent() -> "Agent": """ return Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], api_key=get_async_bearer_token_provider( AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" ), @@ -87,7 +87,7 @@ def create_email_agent() -> "Agent": """ return Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_MODEL"], api_key=get_async_bearer_token_provider( AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" ), diff --git a/python/samples/05-end-to-end/chatkit-integration/README.md b/python/samples/05-end-to-end/chatkit-integration/README.md index 365677eaad..5a8639fa51 100644 --- a/python/samples/05-end-to-end/chatkit-integration/README.md +++ b/python/samples/05-end-to-end/chatkit-integration/README.md @@ -177,7 +177,7 @@ pip install agent-framework-chatkit fastapi uvicorn azure-identity ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" export AZURE_OPENAI_API_VERSION="2024-06-01" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" +export AZURE_OPENAI_MODEL="gpt-4o" ``` 3. **Authenticate with Azure:** diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py index 1135077f5c..7c8b306c11 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py @@ -10,7 +10,7 @@ See ``evaluate_tool_calls_sample.py`` for tool-call accuracy evaluation. Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py index b15781e2bd..2f26eb2e3e 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_mixed_sample.py @@ -48,7 +48,7 @@ async def main() -> None: # 1. Set up the chat client chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py index b28bba22c0..863011241f 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_multiturn_sample.py @@ -11,7 +11,7 @@ a different aspect of agent behavior: Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -94,7 +94,7 @@ def print_split(item: EvalItem, split: ConversationSplit = ConversationSplit.LAS async def main() -> None: chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py index 50b084606b..ef51afc0c6 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py @@ -7,7 +7,7 @@ by using ``FoundryEvals.evaluate()`` with ``TOOL_CALL_ACCURACY``. Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -39,7 +39,7 @@ def get_flight_price(origin: str, destination: str) -> str: async def main() -> None: chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py index e0d4d07950..5a1ebeeb57 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py @@ -13,7 +13,7 @@ Prerequisites: - An Azure AI Foundry project with a deployed model - Response IDs from prior agent runs (for Pattern 1) - OTel traces exported to App Insights (for Pattern 2) -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -30,7 +30,7 @@ async def main() -> None: # 1. Set up the chat client chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py index 5f0a7315dc..e2861324f6 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py @@ -11,7 +11,7 @@ breakdown in sub_results so you can identify which agent is underperforming. Prerequisites: - An Azure AI Foundry project with a deployed model -- Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL in .env """ import asyncio @@ -46,7 +46,7 @@ async def main() -> None: # 1. Set up the chat client client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), + model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"), credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/.env.example b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example index d74003f39b..584c630542 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/.env.example +++ b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration (for the agent being tested) AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_MODEL=gpt-4o # AZURE_OPENAI_API_KEY=your-api-key-here # Azure AI Project Configuration (for red teaming) diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/README.md b/python/samples/05-end-to-end/evaluation/red_teaming/README.md index bdd3dfcb72..c72713356b 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/README.md +++ b/python/samples/05-end-to-end/evaluation/red_teaming/README.md @@ -43,7 +43,7 @@ Create a `.env` file in this directory or set these environment variables: ```bash # Azure OpenAI (for the agent being tested) AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_MODEL=gpt-4o # AZURE_OPENAI_API_KEY is optional if using Azure CLI authentication # Azure AI Project (for red teaming) diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py index d0edf632d8..758a462dcb 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py +++ b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py @@ -51,7 +51,7 @@ async def main() -> None: credential = AzureCliCredential() # Create the agent # Constructor automatically reads from environment variables: - # AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT_NAME, AZURE_OPENAI_API_KEY + # AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_MODEL, AZURE_OPENAI_API_KEY agent = Agent( client=FoundryChatClient(credential=credential), name="FinancialAdvisor", diff --git a/python/samples/05-end-to-end/hosted_agents/README.md b/python/samples/05-end-to-end/hosted_agents/README.md index 5ee8a7b1b2..d45b2f630c 100644 --- a/python/samples/05-end-to-end/hosted_agents/README.md +++ b/python/samples/05-end-to-end/hosted_agents/README.md @@ -76,7 +76,7 @@ Example `.env` for Azure OpenAI samples: ```dotenv AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ -AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1 +AZURE_OPENAI_MODEL=gpt-4.1 ``` Example `.env` for Foundry project samples: diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml index ce59158393..6e46adcaed 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml @@ -22,7 +22,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME + - name: AZURE_OPENAI_MODEL value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample index 7a7d4d5ec3..67bcea72f3 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample @@ -1,3 +1,3 @@ # IMPORTANT: Never commit .env to version control - add it to .gitignore -PROJECT_ENDPOINT= -MODEL_DEPLOYMENT_NAME= \ No newline at end of file +FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_MODEL= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md index 41fa3660fa..50efdcb7a4 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md @@ -59,24 +59,24 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): -- `PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) +- `FOUNDRY_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `FOUNDRY_MODEL` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) This sample loads environment variables from a local `.env` file if present. Create a `.env` file in this directory with the following content: ``` -PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4.1-mini ``` Or set them via PowerShell: ```powershell # Replace with your actual values -$env:PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:FOUNDRY_MODEL="gpt-4.1-mini" ``` ### Running the Sample diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml index 159b31996d..d18fafc374 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml @@ -21,7 +21,7 @@ protocols: - protocol: responses version: v1 environment_variables: - - name: PROJECT_ENDPOINT - value: ${PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file + - name: FOUNDRY_PROJECT_ENDPOINT + value: ${FOUNDRY_PROJECT_ENDPOINT} + - name: FOUNDRY_MODEL + value: ${FOUNDRY_MODEL} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py index 880162586b..3bdecb5100 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py @@ -18,10 +18,8 @@ from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential # Configure these for your Foundry project # Read the explicit variables present in the .env file -PROJECT_ENDPOINT = os.getenv("PROJECT_ENDPOINT") # e.g., "https://.services.ai.azure.com" -MODEL_DEPLOYMENT_NAME = os.getenv( - "MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini" -) # Your model deployment name e.g., "gpt-4.1-mini" +FOUNDRY_PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT") # e.g., "https://.services.ai.azure.com" +FOUNDRY_MODEL = os.getenv("FOUNDRY_MODEL", "gpt-4.1-mini") # Your model deployment name e.g., "gpt-4.1-mini" # Simulated hotel data for Seattle @@ -116,8 +114,8 @@ async def main(): """Main function to run the agent as a web server.""" async with get_credential() as credential: client = FoundryChatClient( - project_endpoint=PROJECT_ENDPOINT, - model=MODEL_DEPLOYMENT_NAME, + project_endpoint=FOUNDRY_PROJECT_ENDPOINT, + model=FOUNDRY_MODEL, credential=credential, ) agent = Agent( diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml index a54917bba5..13938f6a51 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml @@ -25,7 +25,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME + - name: AZURE_OPENAI_MODEL value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml index bd2824276a..82f4d9e887 100644 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml @@ -20,7 +20,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME + - name: AZURE_OPENAI_MODEL value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample index 7a7d4d5ec3..67bcea72f3 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample @@ -1,3 +1,3 @@ # IMPORTANT: Never commit .env to version control - add it to .gitignore -PROJECT_ENDPOINT= -MODEL_DEPLOYMENT_NAME= \ No newline at end of file +FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_MODEL= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md index f4181f6e6b..8a9464f7b5 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md @@ -56,24 +56,24 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): -- `PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) +- `FOUNDRY_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `FOUNDRY_MODEL` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) This sample loads environment variables from a local `.env` file if present. Create a `.env` file in this directory with the following content: ``` -PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4.1-mini ``` Or set them via PowerShell: ```powershell # Replace with your actual values -$env:PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:FOUNDRY_MODEL="gpt-4.1-mini" ``` ### Running the Sample diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml index 9a5e63b83f..36e08b7e77 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml @@ -18,7 +18,7 @@ protocols: - protocol: responses version: v1 environment_variables: - - name: PROJECT_ENDPOINT - value: ${PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file + - name: FOUNDRY_PROJECT_ENDPOINT + value: ${FOUNDRY_PROJECT_ENDPOINT} + - name: FOUNDRY_MODEL + value: ${FOUNDRY_MODEL} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py index 757bad4f26..ff42f7d6dc 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py @@ -14,12 +14,10 @@ load_dotenv(override=True) # Configure these for your Foundry project # Read the explicit variables present in the .env file -PROJECT_ENDPOINT = os.getenv( - "PROJECT_ENDPOINT" +FOUNDRY_PROJECT_ENDPOINT = os.getenv( + "FOUNDRY_PROJECT_ENDPOINT" ) # e.g., "https://.services.ai.azure.com/api/projects/" -MODEL_DEPLOYMENT_NAME = os.getenv( - "MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini" -) # Your model deployment name e.g., "gpt-4.1-mini" +FOUNDRY_MODEL = os.getenv("FOUNDRY_MODEL", "gpt-4.1-mini") # Your model deployment name e.g., "gpt-4.1-mini" def get_credential(): @@ -31,8 +29,8 @@ def get_credential(): async def create_agents(): async with get_credential() as credential: client = FoundryChatClient( - project_endpoint=PROJECT_ENDPOINT, - model=MODEL_DEPLOYMENT_NAME, + project_endpoint=FOUNDRY_PROJECT_ENDPOINT, + model=FOUNDRY_MODEL, credential=credential, ) writer = Agent( @@ -60,8 +58,8 @@ async def main() -> None: The writer and reviewer multi-agent workflow. Environment variables required: - - PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint - - MODEL_DEPLOYMENT_NAME: Your Microsoft Foundry model deployment name + - FOUNDRY_PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint + - FOUNDRY_MODEL: Your Microsoft Foundry model deployment name """ async with create_agents() as (writer, reviewer): diff --git a/python/samples/05-end-to-end/purview_agent/README.md b/python/samples/05-end-to-end/purview_agent/README.md index 3d13478616..1cdb7e3ef4 100644 --- a/python/samples/05-end-to-end/purview_agent/README.md +++ b/python/samples/05-end-to-end/purview_agent/README.md @@ -18,7 +18,7 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat | Variable | Required | Purpose | |----------|----------|---------| | `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://.openai.azure.com) | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Optional | Model deployment name (defaults inside SDK if omitted) | +| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) | | `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication | | `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth | | `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication | diff --git a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py index 369e39655b..689d4e6e46 100644 --- a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py +++ b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py @@ -12,7 +12,7 @@ Note: Caching is automatic and enabled by default. Environment variables: - AZURE_OPENAI_ENDPOINT (required) -- AZURE_OPENAI_DEPLOYMENT_NAME (optional, defaults to gpt-4o-mini) +- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini) - PURVIEW_CLIENT_APP_ID (required) - PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth) - PURVIEW_TENANT_ID (required if certificate auth) @@ -143,7 +143,7 @@ async def run_with_agent_middleware() -> None: print("Skipping run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) @@ -179,7 +179,7 @@ async def run_with_chat_middleware() -> None: print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", default="gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient( @@ -229,7 +229,7 @@ async def run_with_custom_cache_provider() -> None: print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) @@ -269,7 +269,7 @@ async def run_with_custom_cache_provider() -> None: print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") + deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) diff --git a/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py index f0dbb504cc..053011f8ea 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py +++ b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py @@ -145,14 +145,14 @@ class ResearchLead(Executor): async def run_workflow_with_response_tracking( - query: str, client: FoundryChatClient | None = None, deployment_name: str | None = None + query: str, client: FoundryChatClient | None = None, model: str | None = None ) -> dict: """Run multi-agent workflow and track conversation IDs, response IDs, and interaction sequence. Args: query: The user query to process through the multi-agent workflow client: Optional FoundryChatClient instance - deployment_name: Optional model deployment name for the workflow agents + model: Optional model for the workflow agents Returns: Dictionary containing interaction sequence, conversation/response IDs, and conversation analysis @@ -166,7 +166,7 @@ async def run_workflow_with_response_tracking( ) async with project_client: - client = FoundryChatClient(project_client=project_client, model=deployment_name) + client = FoundryChatClient(project_client=project_client, model=model) return await _run_workflow_with_client(query, client) except Exception as e: print(f"Error during workflow execution: {e}") @@ -347,11 +347,11 @@ def _track_agent_ids(event, agent, response_ids, conversation_ids): conversation_ids[agent].append(raw.conversation_id) -async def create_and_run_workflow(deployment_name: str | None = None): +async def create_and_run_workflow(model: str | None = None): """Run the workflow evaluation and display results. Args: - deployment_name: Optional model deployment name for the workflow agents + model: Optional model for the workflow agents Returns: Dictionary containing agents data with conversation IDs, response IDs, and query information @@ -365,7 +365,7 @@ async def create_and_run_workflow(deployment_name: str | None = None): query = example_queries[0] print(f"Query: {query}\n") - result = await run_workflow_with_response_tracking(query, model=deployment_name) + result = await run_workflow_with_response_tracking(query, model=model) # Create output data structure output_data = {"agents": {}, "query": result["query"], "output": result.get("output", "")} diff --git a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py index f4bc8ebb85..e246710b41 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py +++ b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py @@ -46,11 +46,11 @@ def print_section(title: str): print(f"{'=' * 80}") -async def run_workflow(deployment_name: str | None = None) -> dict[str, Any]: +async def run_workflow(model: str | None = None) -> dict[str, Any]: """Execute the multi-agent travel planning workflow. Args: - deployment_name: Optional model deployment name for the workflow agents + model: Optional model for the workflow agents Returns: Dictionary containing workflow data with agent response IDs @@ -58,7 +58,7 @@ async def run_workflow(deployment_name: str | None = None) -> dict[str, Any]: print("Executing multi-agent travel planning workflow...") print("This may take a few minutes...") - workflow_data = await create_and_run_workflow(model=deployment_name) + workflow_data = await create_and_run_workflow(model=model) print("Workflow execution completed") return workflow_data @@ -97,9 +97,9 @@ def fetch_agent_responses(openai_client: OpenAI, workflow_data: dict[str, Any], print(f" Error: {e}") -def create_evaluation(openai_client: OpenAI, deployment_name: str | None = "gpt-5.2") -> EvalCreateResponse: +def create_evaluation(openai_client: OpenAI, model: str | None = "gpt-5.2") -> EvalCreateResponse: """Create evaluation with multiple evaluators.""" - deployment_name = os.environ.get("FOUNDRY_MODEL", deployment_name) + model = os.environ.get("FOUNDRY_MODEL", model) data_source_config = {"type": "azure_ai_source", "scenario": "responses"} testing_criteria = [ @@ -107,25 +107,25 @@ def create_evaluation(openai_client: OpenAI, deployment_name: str | None = "gpt- "type": "azure_ai_evaluator", "name": "relevance", "evaluator_name": "builtin.relevance", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, { "type": "azure_ai_evaluator", "name": "groundedness", "evaluator_name": "builtin.groundedness", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, { "type": "azure_ai_evaluator", "name": "tool_call_accuracy", "evaluator_name": "builtin.tool_call_accuracy", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, { "type": "azure_ai_evaluator", "name": "tool_output_utilization", "evaluator_name": "builtin.tool_output_utilization", - "initialization_parameters": {"deployment_name": deployment_name}, + "initialization_parameters": {"deployment_name": model}, }, ] diff --git a/python/samples/README.md b/python/samples/README.md index 4e178c2a78..fc55a4157e 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -91,11 +91,11 @@ variable. | package | class | env var | example value | | --- | --- | --- | --- | | `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_API_KEY` | `sk-ant-api03-...` | -| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL_ID` | `claude-sonnet-4-5-20250929` | +| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-5-20250929` | | `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_ENDPOINT` | `https://my-endpoint.inference.ai.azure.com` | | `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_API_KEY` | `env-key` | -| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID` | `text-embedding-3-small` | -| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID` | `Cohere-embed-v3-english` | +| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_EMBEDDING_MODEL` | `text-embedding-3-small` | +| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL` | `Cohere-embed-v3-english` | | `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_ENDPOINT` | `https://my-search.search.windows.net` | | `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_API_KEY` | `search-key` | | `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_INDEX_NAME` | `hotels-index` | @@ -105,9 +105,9 @@ variable. | `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_CONTAINER_NAME` | `messages` | | `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_KEY` | `C2F...==` | | `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_REGION` | `us-east-1` | -| `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_CHAT_MODEL_ID` | `anthropic.claude-3-5-sonnet-20241022-v2:0` | +| `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_CHAT_MODEL` | `anthropic.claude-3-5-sonnet-20241022-v2:0` | | `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_REGION` | `us-east-1` | -| `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_EMBEDDING_MODEL_ID` | `amazon.titan-embed-text-v2:0` | +| `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_EMBEDDING_MODEL` | `amazon.titan-embed-text-v2:0` | | `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_ACCESS_KEY_ID` | `AKIAIOSFODNN7EXAMPLE` | | `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_SECRET_ACCESS_KEY` | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | | `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_SESSION_TOKEN` | `IQoJb3JpZ2luX2VjEO7//////////wEaCXVzLXdlc3QtMiJHMEUCIQD...` | @@ -141,7 +141,7 @@ variable. | `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_LOG_LEVEL` | `info` | | `agent-framework-mem0` | `agent_framework_mem0 package import` | `MEM0_TELEMETRY` | `false` | | `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_HOST` | `http://localhost:11434` | -| `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_MODEL_ID` | `llama3.1:8b` | +| `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_MODEL` | `llama3.1:8b` | | `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_API_KEY` | `sk-proj-...` | | `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_MODEL` | `gpt-4o-mini` | | `agent-framework-openai` | `OpenAIChatClient` | `OPENAI_RESPONSES_MODEL` | `gpt-4.1-mini` | @@ -153,10 +153,10 @@ variable. | `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_API_KEY` | `sk-azure-...` | | `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_API_VERSION` | `2024-10-21` | | `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_BASE_URL` | `https://my-resource.openai.azure.com/openai/v1/` | -| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_DEPLOYMENT_NAME` | `gpt-4o` | -| `agent-framework-openai` | `OpenAIChatClient` | `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` | `gpt-4.1` | -| `agent-framework-openai` | `OpenAIChatCompletionClient` | `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` | `gpt-4o-mini` | -| `agent-framework-openai` | `OpenAIEmbeddingClient` | `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | `text-embedding-3-large` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_MODEL` | `gpt-4o` | +| `agent-framework-openai` | `OpenAIChatClient` | `AZURE_OPENAI_RESPONSES_MODEL` | `gpt-4.1` | +| `agent-framework-openai` | `OpenAIChatCompletionClient` | `AZURE_OPENAI_CHAT_MODEL` | `gpt-4o-mini` | +| `agent-framework-openai` | `OpenAIEmbeddingClient` | `AZURE_OPENAI_EMBEDDING_MODEL` | `text-embedding-3-large` | | `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_RESOURCE_URL` | `https://cognitiveservices.azure.com/` | `agent-framework-openai` supports the Azure OpenAI client-specific deployment aliases listed above; keep diff --git a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py index d74487d1e8..37f002ef22 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py +++ b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py @@ -23,12 +23,12 @@ async def run_semantic_kernel() -> None: from semantic_kernel.connectors.ai.open_ai import OpenAISettings openai_settings = OpenAISettings() - assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + assert openai_settings.responses_model is not None, "Responses model ID must be set in OpenAISettings" client = OpenAIResponsesAgent.create_client() # SK response agents wrap OpenAI's hosted Responses API. agent = OpenAIResponsesAgent( - ai_model_id=openai_settings.responses_model_id, + ai_model=openai_settings.responses_model, client=client, instructions="Answer in one concise sentence.", name="Expert", diff --git a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py index 01b783aff9..306aa68a23 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py +++ b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py @@ -29,12 +29,12 @@ async def run_semantic_kernel() -> None: return a + b openai_settings = OpenAISettings() - assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + assert openai_settings.responses_model is not None, "Responses model ID must be set in OpenAISettings" client = OpenAIResponsesAgent.create_client() # Plugins advertise callable tools to the Responses agent. agent = OpenAIResponsesAgent( - ai_model_id=openai_settings.responses_model_id, + ai_model=openai_settings.responses_model, client=client, instructions="Use the add tool when math is required.", name="MathExpert", diff --git a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py index cbfbf470a0..133d3b079c 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py +++ b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py @@ -30,12 +30,12 @@ async def run_semantic_kernel() -> None: from semantic_kernel.connectors.ai.open_ai import OpenAISettings openai_settings = OpenAISettings() - assert openai_settings.responses_model_id is not None, "Responses model ID must be set in OpenAISettings" + assert openai_settings.responses_model is not None, "Responses model ID must be set in OpenAISettings" client = OpenAIResponsesAgent.create_client() # response_format requests schema-constrained output from the model. agent = OpenAIResponsesAgent( - ai_model_id=openai_settings.responses_model_id, + ai_model=openai_settings.responses_model, client=client, instructions="Return launch briefs as structured JSON.", name="ProductMarketer", diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py index b5360e2130..dcf7b1af33 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py @@ -54,15 +54,15 @@ async def build_semantic_kernel_agents() -> list[ChatCompletionAgent | OpenAIAss instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - service=OpenAIChatCompletion(ai_model_id="gpt-4o-mini-search-preview"), + service=OpenAIChatCompletion(ai_model="gpt-4o-mini-search-preview"), ) client = OpenAIAssistantAgent.create_client() code_interpreter_tool, code_interpreter_tool_resources = OpenAIAssistantAgent.configure_code_interpreter_tool() openai_settings = OpenAISettings() - model_id = openai_settings.chat_model_id if openai_settings.chat_model_id else "gpt-5" + model = openai_settings.chat_model if openai_settings.chat_model else "gpt-5" definition = await client.beta.assistants.create( # pyright: ignore[reportDeprecated] - model=model_id, + model=model, name="CoderAgent", description="A helpful assistant that writes and executes code to process and analyze data.", instructions="You solve questions using code. Please provide detailed analysis and computation process.", From 519bb0cb2b6fe610151598ce3d8e6e30b7e45134 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 1 Apr 2026 21:16:00 +0200 Subject: [PATCH 17/20] Python: updated declarative samples and handling of non-pydantic response formats (#5022) * updated declarative samples and handling of non-pydantic response formats * fixed from comments * update docstring --- .../foundry/MicrosoftLearnAgent.yaml | 4 +- .../anthropic/tests/test_anthropic_client.py | 21 ++++ .../packages/core/agent_framework/_agents.py | 12 +- .../packages/core/agent_framework/_clients.py | 3 +- .../packages/core/agent_framework/_tools.py | 3 +- .../packages/core/agent_framework/_types.py | 108 +++++++++++++----- python/packages/core/tests/core/conftest.py | 8 +- .../packages/core/tests/core/test_agents.py | 50 ++++++++ .../core/tests/core/test_observability.py | 4 +- python/packages/core/tests/core/test_types.py | 29 +++++ .../agent_framework_declarative/_loader.py | 6 +- .../tests/foundry/test_foundry_chat_client.py | 50 +++++++- .../agent_framework_ollama/_chat_client.py | 13 ++- .../ollama/tests/test_ollama_chat_client.py | 27 +++++ .../agent_framework_openai/_chat_client.py | 4 +- .../tests/openai/test_openai_chat_client.py | 50 +++++++- .../openai/test_openai_chat_client_azure.py | 10 +- .../test_openai_chat_completion_client.py | 35 +++++- .../02-agents/declarative/inline_yaml.py | 4 +- .../declarative/microsoft_learn_agent.py | 14 +++ ...nai_responses_agent.py => openai_agent.py} | 5 +- 21 files changed, 370 insertions(+), 90 deletions(-) rename python/samples/02-agents/declarative/{openai_responses_agent.py => openai_agent.py} (89%) diff --git a/agent-samples/foundry/MicrosoftLearnAgent.yaml b/agent-samples/foundry/MicrosoftLearnAgent.yaml index 8e15340351..af20bbf18b 100644 --- a/agent-samples/foundry/MicrosoftLearnAgent.yaml +++ b/agent-samples/foundry/MicrosoftLearnAgent.yaml @@ -3,13 +3,13 @@ name: MicrosoftLearnAgent description: Microsoft Learn Agent instructions: You answer questions by searching the Microsoft Learn content only. model: - id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID + id: =Env.FOUNDRY_MODEL options: temperature: 0.9 topP: 0.95 connection: kind: remote - endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT + endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT tools: - kind: mcp name: microsoft_learn diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index b9e3689e85..d1102d56ad 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -992,6 +992,27 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: assert response.usage_details["output_token_count"] == 5 +def test_process_message_with_dict_response_format(mock_anthropic_client: MagicMock) -> None: + """_process_message should preserve dict response_format values for response.value parsing.""" + client = create_test_anthropic_client(mock_anthropic_client) + + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_123" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [BetaTextBlock(type="text", text='{"greeting": "Hello"}')] + mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) + mock_message.stop_reason = "end_turn" + + response = client._process_message( + mock_message, + options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["greeting"] == "Hello" + + def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None: """Test _process_message with tool use.""" client = create_test_anthropic_client(mock_anthropic_client) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 6f1be6c323..585898ae52 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1026,20 +1026,13 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] session_context=context["session_context"], suppress_response_id=context["suppress_response_id"], ) - - response_format = context["chat_options"].get("response_format") - if not ( - response_format is not None and isinstance(response_format, type) and issubclass(response_format, BaseModel) - ): - response_format = None - return AgentResponse( messages=response.messages, response_id=None if context["suppress_response_id"] else response.response_id, created_at=response.created_at, usage_details=response.usage_details, value=response.value, - response_format=response_format, + response_format=context["chat_options"].get("response_format"), continuation_token=response.continuation_token, raw_representation=response, additional_properties=response.additional_properties, @@ -1125,10 +1118,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] response_format: Any | None = None, ) -> AgentResponse[Any]: """Finalize response updates into a single AgentResponse.""" - output_format_type = response_format if isinstance(response_format, type) else None return AgentResponse.from_updates( # pyright: ignore[reportUnknownVariableType] updates, - output_format_type=output_format_type, + output_format_type=response_format, ) @staticmethod diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index cd810f8675..e7a3e41dff 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -345,10 +345,9 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): response_format: Any | None = None, ) -> ChatResponse[Any]: """Finalize response updates into a single ChatResponse.""" - output_format_type = response_format if isinstance(response_format, type) else None return ChatResponse.from_updates( # pyright: ignore[reportUnknownVariableType] updates, - output_format_type=output_format_type, + output_format_type=response_format, ) def _build_response_stream( diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 043187caa4..6cdc74b313 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -2327,7 +2327,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): return _get_response() response_format = mutable_options.get("response_format") if mutable_options else None - output_format_type: type[BaseModel] | None = response_format if isinstance(response_format, type) else None stream_result_hooks: list[Callable[[ChatResponse], Any]] = [] async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -2485,6 +2484,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]: # Note: stream_result_hooks are already run via inner stream's get_final_response() # We don't need to run them again here - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=response_format) return ResponseStream(_stream(), finalizer=_finalize) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index ccad977f21..41979cb5f2 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -299,6 +299,7 @@ ToolModeT = TypeVar("ToolModeT", bound="ToolMode") AgentResponseT = TypeVar("AgentResponseT", bound="AgentResponse") ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True) ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) +StructuredResponseFormat = type[BaseModel] | Mapping[str, Any] | None CreatedAtT = str # Use a datetimeoffset type? Or a more specific type like datetime.datetime? @@ -1949,6 +1950,24 @@ class ContinuationToken(TypedDict): # endregion +def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None: + if response_format is None: + return None + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return response_format.model_validate_json(text) + if isinstance(response_format, Mapping): + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Response text is not valid JSON: {exc}") from exc + logger.warning( + "Unable to parse structured response value, use either a Pydantic model or a dict defining the schema, " + "received response_format type: %s", + type(response_format), # type: ignore[reportUnknownArgumentType] + ) + return None + + class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to a chat request. @@ -2014,7 +2033,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): finish_reason: FinishReasonLiteral | FinishReason | None = None, usage_details: UsageDetails | None = None, value: ResponseModelT | None = None, - response_format: type[BaseModel] | None = None, + response_format: StructuredResponseFormat = None, continuation_token: ContinuationToken | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, @@ -2058,7 +2077,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): self.finish_reason = finish_reason self.usage_details = usage_details self._value: ResponseModelT | None = value - self._response_format: type[BaseModel] | None = response_format + self._response_format: StructuredResponseFormat = response_format self._value_parsed: bool = value is not None self.additional_properties = ( _restore_compaction_annotation_in_additional_properties(additional_properties) or {} @@ -2087,6 +2106,15 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): output_format_type: type[ResponseModelBoundT], ) -> ChatResponse[ResponseModelBoundT]: ... + @overload + @classmethod + def from_updates( + cls: type[ChatResponse[Any]], + updates: Sequence[ChatResponseUpdate], + *, + output_format_type: Mapping[str, Any], + ) -> ChatResponse[Any]: ... + @overload @classmethod def from_updates( @@ -2101,7 +2129,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[ChatResponseT], updates: Sequence[ChatResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. @@ -2124,10 +2152,10 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): updates: A sequence of ChatResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data. + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. """ - response_format = output_format_type if isinstance(output_format_type, type) else None - msg = cls(messages=[], response_format=response_format) + msg = cls(messages=[], response_format=output_format_type) for update in updates: _process_update(msg, update) _finalize_response(msg) @@ -2142,6 +2170,15 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): output_format_type: type[ResponseModelBoundT], ) -> ChatResponse[ResponseModelBoundT]: ... + @overload + @classmethod + async def from_update_generator( + cls: type[ChatResponse[Any]], + updates: AsyncIterable[ChatResponseUpdate], + *, + output_format_type: Mapping[str, Any], + ) -> ChatResponse[Any]: ... + @overload @classmethod async def from_update_generator( @@ -2156,7 +2193,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[ChatResponseT], updates: AsyncIterable[ChatResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. @@ -2175,10 +2212,10 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): updates: An async iterable of ChatResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data. + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. """ - response_format = output_format_type if isinstance(output_format_type, type) else None - msg = cls(messages=[], response_format=response_format) + msg = cls(messages=[], response_format=output_format_type) async for update in updates: _process_update(msg, update) _finalize_response(msg) @@ -2198,15 +2235,12 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): Raises: ValidationError: If the response text doesn't match the expected schema. + ValueError: If the response text is not valid JSON for a non-Pydantic structured format. """ if self._value_parsed: return self._value - if ( - self._response_format is not None - and isinstance(self._response_format, type) - and issubclass(self._response_format, BaseModel) - ): - self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) + if self._response_format is not None: + self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format)) self._value_parsed = True return self._value @@ -2397,7 +2431,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): created_at: CreatedAtT | None = None, usage_details: UsageDetails | None = None, value: ResponseModelT | None = None, - response_format: type[BaseModel] | None = None, + response_format: StructuredResponseFormat = None, continuation_token: ContinuationToken | None = None, raw_representation: Any | None = None, additional_properties: dict[str, Any] | None = None, @@ -2438,7 +2472,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): self.created_at = created_at self.usage_details = usage_details self._value: ResponseModelT | None = value - self._response_format: type[BaseModel] | None = response_format + self._response_format: type[BaseModel] | Mapping[str, Any] | None = response_format self._value_parsed: bool = value is not None self.additional_properties = ( _restore_compaction_annotation_in_additional_properties(additional_properties) or {} @@ -2460,15 +2494,12 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): Raises: ValidationError: If the response text doesn't match the expected schema. + ValueError: If the response text is not valid JSON for a non-Pydantic structured format. """ if self._value_parsed: return self._value - if ( - self._response_format is not None - and isinstance(self._response_format, type) - and issubclass(self._response_format, BaseModel) - ): - self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) + if self._response_format is not None: + self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format)) self._value_parsed = True return self._value @@ -2492,6 +2523,16 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): value: Any | None = None, ) -> AgentResponse[ResponseModelBoundT]: ... + @overload + @classmethod + def from_updates( + cls: type[AgentResponse[Any]], + updates: Sequence[AgentResponseUpdate], + *, + output_format_type: Mapping[str, Any], + value: Any | None = None, + ) -> AgentResponse[Any]: ... + @overload @classmethod def from_updates( @@ -2507,7 +2548,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[AgentResponseT], updates: Sequence[AgentResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, value: Any | None = None, ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. @@ -2516,7 +2557,8 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): updates: A sequence of AgentResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data. + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. value: Optional pre-parsed structured output value to set directly on the response. """ msg = cls(messages=[], response_format=output_format_type, value=value) @@ -2534,6 +2576,15 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): output_format_type: type[ResponseModelBoundT], ) -> AgentResponse[ResponseModelBoundT]: ... + @overload + @classmethod + async def from_update_generator( + cls: type[AgentResponse[Any]], + updates: AsyncIterable[AgentResponseUpdate], + *, + output_format_type: Mapping[str, Any], + ) -> AgentResponse[Any]: ... + @overload @classmethod async def from_update_generator( @@ -2548,7 +2599,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): cls: type[AgentResponseT], updates: AsyncIterable[AgentResponseUpdate], *, - output_format_type: type[BaseModel] | None = None, + output_format_type: StructuredResponseFormat = None, ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. @@ -2556,7 +2607,8 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]): updates: An async iterable of AgentResponseUpdate objects to combine. Keyword Args: - output_format_type: Optional Pydantic model type to parse the response text into structured data + output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the + response text into structured data. """ msg = cls(messages=[], response_format=output_format_type) async for update in updates: diff --git a/python/packages/core/tests/core/conftest.py b/python/packages/core/tests/core/conftest.py index a331dca20d..37c554c1d8 100644 --- a/python/packages/core/tests/core/conftest.py +++ b/python/packages/core/tests/core/conftest.py @@ -127,9 +127,7 @@ class MockChatClient: yield ChatResponseUpdate(contents=[Content.from_text("another update")], role="assistant") def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=options.get("response_format")) return ResponseStream(_stream(), finalizer=_finalize) @@ -233,9 +231,7 @@ class MockBaseChatClient( await asyncio.sleep(0) def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=options.get("response_format")) return ResponseStream(_stream(), finalizer=_finalize) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index f61221b526..3832a5c441 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -301,6 +301,56 @@ async def test_chat_client_agent_streaming_response_format_from_run_options( assert result.value.greeting == "Hi" +async def test_chat_client_agent_response_format_dict_from_default_options( + client: SupportsChatGetResponse, +) -> None: + """AgentResponse.value should parse JSON dicts from default_options response_format.""" + json_text = json.dumps({"greeting": "Hello"}) + client.responses.append(ChatResponse(messages=Message(role="assistant", text=json_text))) # type: ignore[attr-defined] + + agent = Agent( + client=client, + default_options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}}, + ) + result = await agent.run("Hello") + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, dict) + assert result.value["greeting"] == "Hello" + + +async def test_chat_client_agent_streaming_response_format_dict_from_run_options( + client: SupportsChatGetResponse, +) -> None: + """Agent streaming should preserve mapping response_format and parse the final value as a dict.""" + json_text = json.dumps({"greeting": "Hi"}) + client.streaming_responses.append( # type: ignore[attr-defined] + [ + ChatResponseUpdate( + contents=[Content.from_text(json_text)], + role="assistant", + finish_reason="stop", + ) + ] + ) + + agent = Agent(client=client) + stream = agent.run( + "Hello", + stream=True, + options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}}, + ) + async for _ in stream: + pass + result = await stream.get_final_response() + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, dict) + assert result.value["greeting"] == "Hi" + + async def test_chat_client_agent_create_session( client: SupportsChatGetResponse, ) -> None: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index ec99c73af1..33a844c16e 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -191,9 +191,7 @@ def mock_chat_client(): yield ChatResponseUpdate(contents=[Content.from_text(" world")], role="assistant", finish_reason="stop") def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) + return ChatResponse.from_updates(updates, output_format_type=options.get("response_format")) return ResponseStream(_stream(), finalizer=_finalize) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 101379b8f9..be3c82e424 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -800,6 +800,19 @@ def test_chat_response_with_format_init(): assert response.value.response == "Hello" +def test_chat_response_with_mapping_response_format() -> None: + """ChatResponse.value should parse JSON when response_format is a mapping.""" + message = Message(role="assistant", text='{"response": "Hello"}') + response = ChatResponse( + messages=message, + response_format={"type": "object", "properties": {"response": {"type": "string"}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["response"] == "Hello" + + def test_chat_response_value_raises_on_invalid_schema(): """Test that value property raises ValidationError with field constraint details.""" @@ -1004,6 +1017,22 @@ async def test_chat_response_from_async_generator_output_format_in_method(): assert resp.value.response == "Hello" +async def test_chat_response_from_async_generator_mapping_response_format() -> None: + async def gen() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[Content.from_text('{ "respon')], message_id="1") + yield ChatResponseUpdate(contents=[Content.from_text('se": "Hello" }')], message_id="1") + + resp = await ChatResponse.from_update_generator( + gen(), + output_format_type={"type": "object", "properties": {"response": {"type": "string"}}}, + ) + + assert resp.text == '{ "response": "Hello" }' + assert resp.value is not None + assert isinstance(resp.value, dict) + assert resp.value["response"] == "Hello" + + # region ToolMode diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 635d825cdc..a9c534ee2d 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -82,7 +82,7 @@ PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = { }, "OpenAI.Chat": { "package": "agent_framework.openai", - "name": "OpenAIChatClient", + "name": "OpenAIChatCompletionClient", "model_field": "model", "endpoint_field": "base_url", "api_key_field": "api_key", @@ -186,7 +186,7 @@ class AgentFactory: connections: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, additional_mappings: Mapping[str, ProviderTypeMapping] | None = None, - default_provider: str = "OpenAI", + default_provider: str = "Foundry", safe_mode: bool = True, env_file_path: str | None = None, env_file_encoding: str | None = None, @@ -223,7 +223,7 @@ class AgentFactory: SupportsChatGetResponse implementation, and model_field is the name of the field in the constructor that accepts the model.id value. default_provider: The default provider used when model.provider is not specified, - default is "OpenAI". + default is "Foundry", which uses the FoundryChatClient. safe_mode: Whether to run in safe mode, default is True. When safe_mode is True, environment variables are not accessible in the powerfx expressions. You can still use environment variables, but through the constructors of the classes. diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 5691de70e1..24e5677e19 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -3,7 +3,6 @@ from __future__ import annotations import inspect -import json import os import sys from functools import wraps @@ -532,6 +531,48 @@ async def test_response_format_parse_path_with_conversation_id() -> None: assert response.model == "test-model" +async def test_response_format_dict_parse_path() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + response_format = {"type": "object", "properties": {"answer": {"type": "string"}}} + + mock_response = MagicMock() + mock_response.id = "response_123" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.metadata = {} + mock_response.output_parsed = None + mock_response.output = [] + mock_response.usage = None + mock_response.finish_reason = None + mock_response.conversation = None + mock_response.status = "completed" + + mock_message_content = MagicMock() + mock_message_content.type = "output_text" + mock_message_content.text = '{"answer": "Parsed"}' + mock_message_content.annotations = [] + mock_message_content.logprobs = None + + mock_message_item = MagicMock() + mock_message_item.type = "message" + mock_message_item.content = [mock_message_content] + mock_response.output = [mock_message_item] + client.client.responses.create = AsyncMock(return_value=mock_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": response_format}, + ) + + assert response.response_id == "response_123" + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["answer"] == "Parsed" + + async def test_bad_request_error_non_content_filter() -> None: mock_openai_client = _make_mock_openai_client() project_client = MagicMock() @@ -642,10 +683,9 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - assert response.value is None - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value @pytest.mark.flaky diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index cce4c14332..ee517fe53f 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -382,7 +382,10 @@ class OllamaChatClient( except Exception as ex: raise ChatClientException(f"Ollama chat request failed : {ex}", ex) from ex - return self._parse_response_from_ollama(response) + return self._parse_response_from_ollama( + response, + response_format=validated_options.get("response_format"), + ) return _get_response() @@ -536,7 +539,12 @@ class OllamaChatClient( created_at=response.created_at, ) - def _parse_response_from_ollama(self, response: OllamaChatResponse) -> ChatResponse: + def _parse_response_from_ollama( + self, + response: OllamaChatResponse, + *, + response_format: Any | None = None, + ) -> ChatResponse: contents = self._parse_contents_from_ollama(response) return ChatResponse( @@ -547,6 +555,7 @@ class OllamaChatClient( input_token_count=response.prompt_eval_count, output_token_count=response.eval_count, ), + response_format=response_format, ) def _parse_tool_calls_from_ollama(self, tool_calls: Sequence[OllamaMessage.ToolCall]) -> list[Content]: diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index c4c66077e4..34bb3efdfb 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -248,6 +248,33 @@ async def test_cmc( assert result.text == "test" +@patch.object(AsyncClient, "chat", new_callable=AsyncMock) +async def test_cmc_response_format_dict( + mock_chat: AsyncMock, + ollama_unit_test_env: dict[str, str], + chat_history: list[Message], +) -> None: + mock_chat.return_value = OllamaChatResponse( + message=OllamaMessage(content='{"answer": "test"}', role="assistant"), + model="test", + eval_count=1, + prompt_eval_count=1, + created_at="2024-01-01T00:00:00Z", + ) + chat_history.append(Message(text="hello world", role="system")) + chat_history.append(Message(text="hello world", role="user")) + + ollama_client = OllamaChatClient() + result = await ollama_client.get_response( + messages=chat_history, + options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}}, + ) + + assert result.value is not None + assert isinstance(result.value, dict) + assert result.value["answer"] == "test" + + @patch.object(AsyncClient, "chat", new_callable=AsyncMock) async def test_cmc_reasoning( mock_chat: AsyncMock, diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 5f79126711..ec5bfa09b2 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1912,9 +1912,7 @@ class RawOpenAIChatClient( # type: ignore[misc] args["usage_details"] = usage_details if structured_response: args["value"] = structured_response - elif (response_format := options.get("response_format")) and isinstance(response_format, type): - # Only pass response_format to ChatResponse if it's a Pydantic model type, - # not a runtime JSON schema dict + elif response_format := options.get("response_format"): args["response_format"] = response_format # Set continuation_token when background operation is still in progress if response.status and response.status in ("in_progress", "queued"): diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index dc7a119f2d..dd1da5851a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -485,6 +485,46 @@ async def test_response_format_parse_path_with_conversation_id() -> None: assert response.model == "test-model" +async def test_response_format_dict_parse_path() -> None: + """Test get_response response_format parsing path for runtime JSON schema mappings.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + response_format = {"type": "object", "properties": {"answer": {"type": "string"}}} + + mock_response = MagicMock() + mock_response.id = "response_123" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.metadata = {} + mock_response.output_parsed = None + mock_response.output = [] + mock_response.usage = None + mock_response.finish_reason = None + mock_response.conversation = None + mock_response.status = "completed" + + mock_message_content = MagicMock() + mock_message_content.type = "output_text" + mock_message_content.text = '{"answer": "Parsed"}' + mock_message_content.annotations = [] + mock_message_content.logprobs = None + + mock_message_item = MagicMock() + mock_message_item.type = "message" + mock_message_item.content = [mock_message_content] + mock_response.output = [mock_message_item] + + with patch.object(client.client.responses, "create", return_value=mock_response): + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": response_format}, + ) + + assert response.response_id == "response_123" + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["answer"] == "Parsed" + + async def test_bad_request_error_non_content_filter() -> None: """Test get_response BadRequestError without content_filter.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -3297,12 +3337,10 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value + assert "seattle" in response.value["location"].lower() @pytest.mark.timeout(300) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index 756de8580d..044477bc72 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import os from functools import wraps from pathlib import Path @@ -322,11 +321,10 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - assert response.value is None - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value + assert "seattle" in response.value["location"].lower() @pytest.mark.flaky diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 0b2bb75e2f..9c1c103987 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1421,6 +1421,31 @@ def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) assert prepared_options["response_format"] == custom_format +def test_parse_response_with_dict_response_format(openai_unit_test_env: dict[str, str]) -> None: + """Chat completions should parse dict response_format values into response.value.""" + client = OpenAIChatCompletionClient() + response = client._parse_response_from_openai( + ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="gpt-4o-mini", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content='{"answer": "Hello"}'), + finish_reason="stop", + ) + ], + ), + options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["answer"] == "Hello" + + def test_multiple_function_calls_in_single_message( openai_unit_test_env: dict[str, str], ) -> None: @@ -1635,12 +1660,10 @@ async def test_integration_options( assert isinstance(response.value, OutputStruct) assert "seattle" in response.value.location.lower() else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + assert response.value is not None + assert isinstance(response.value, dict) + assert "location" in response.value + assert "seattle" in response.value["location"].lower() @pytest.mark.flaky diff --git a/python/samples/02-agents/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py index 016f75947f..de6ad8fb4f 100644 --- a/python/samples/02-agents/declarative/inline_yaml.py +++ b/python/samples/02-agents/declarative/inline_yaml.py @@ -18,7 +18,7 @@ Prerequisites: - `pip install agent-framework-foundry agent-framework-declarative --pre` - Set the following environment variables in a .env file or your environment: - FOUNDRY_PROJECT_ENDPOINT - - AZURE_OPENAI_MODEL + - FOUNDRY_MODEL """ @@ -31,7 +31,7 @@ instructions: Specialized diagnostic and issue detection agent for systems with description: A agent that performs diagnostics on systems and can escalate issues when critical errors are detected. model: - id: =Env.AZURE_OPENAI_MODEL + id: =Env.FOUNDRY_MODEL """ # create the agent from the yaml async with ( diff --git a/python/samples/02-agents/declarative/microsoft_learn_agent.py b/python/samples/02-agents/declarative/microsoft_learn_agent.py index fc5994da21..53415ed55d 100644 --- a/python/samples/02-agents/declarative/microsoft_learn_agent.py +++ b/python/samples/02-agents/declarative/microsoft_learn_agent.py @@ -9,6 +9,20 @@ from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() +""" +This sample demonstrates creating an agent from a declarative YAML file specification. + +It uses a MCP server to connect to the Microsoft Learn content and a FoundryChatClient. + +The yaml also has some chat options set, such as temperature and topP. +These options do not work with newer OpenAI models, so ensure to use a compatible model such as gpt-4o-mini. + +Environment variables: +- FOUNDRY_PROJECT_ENDPOINT: The endpoint URL for the Foundry project. +- FOUNDRY_MODEL: The model ID to use for the agent, make sure it is compatible with the chat options specified in + the yaml, or remove the options. +""" + async def main(): """Create an agent from a declarative yaml specification and run it.""" diff --git a/python/samples/02-agents/declarative/openai_responses_agent.py b/python/samples/02-agents/declarative/openai_agent.py similarity index 89% rename from python/samples/02-agents/declarative/openai_responses_agent.py rename to python/samples/02-agents/declarative/openai_agent.py index 1a78c8ab7a..741e886a09 100644 --- a/python/samples/02-agents/declarative/openai_responses_agent.py +++ b/python/samples/02-agents/declarative/openai_agent.py @@ -14,11 +14,8 @@ async def main(): # get the path current_path = Path(__file__).parent yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "openai" / "OpenAIResponses.yaml" - # load the yaml from the path - with yaml_path.open("r") as f: - yaml_str = f.read() # create the agent from the yaml - agent = AgentFactory(safe_mode=False).create_agent_from_yaml(yaml_str) + agent = AgentFactory(safe_mode=False).create_agent_from_yaml_path(yaml_path) # use the agent response = await agent.run("Why is the sky blue, answer in Dutch?") # Use response.value with try/except for safe parsing From 15e435b47237b2d5f246df1cae28701e2d3780fa Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:30:45 +0100 Subject: [PATCH 18/20] Fix telemetry sample bug (#5037) --- dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 8e1f4245b6..83445dc1fc 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -23,7 +23,7 @@ const string SourceName = "OpenTelemetryAspire.ConsoleApp"; const string ServiceName = "AgentOpenTelemetry"; // Configure OpenTelemetry for Aspire dashboard -var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4318"; +var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4317"; var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); From 6f6ee61834b820cd85026ad3a0ada94235b9cfaa Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:35:16 -0700 Subject: [PATCH 19/20] Python: Fix broken samples and add missing READMEs (#5038) * Python: Fix broken samples and add missing READMEs - simple_context_provider: move instructions kwarg into options dict - suspend_resume_session: use OpenAIChatCompletionClient for in-memory demo - foundry_chat_client_with_hosted_mcp: move store kwarg into options dict - Add README.md for context_providers and conversations sample folders Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix additional sample issues in context_providers - mem0_basic: send preferences query before sleep so Mem0 can learn them, print result from new session recall - mem0_sessions: add session for multi-turn conversation in agent-scoped example, remove user_id from agent-scoped provider (Mem0 API stores memories without user_id when agent_id is provided), use single message for storing preferences - redis_basics: print retrieved context messages instead of raw object - redis_sessions: add missing load_dotenv() call - redis_basics/redis_sessions: fix docstrings referencing wrong client type - azure_redis_conversation: replace duplicate copyright with load_dotenv() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix broken link in declarative README openai_responses_agent.py was renamed to openai_agent.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../02-agents/context_providers/README.md | 28 +++++++++++++++++++ .../context_providers/mem0/mem0_basic.py | 9 +++++- .../context_providers/mem0/mem0_sessions.py | 27 +++++------------- .../redis/azure_redis_conversation.py | 3 +- .../context_providers/redis/redis_basics.py | 2 +- .../context_providers/redis/redis_sessions.py | 6 ++-- .../simple_context_provider.py | 8 ++++-- .../samples/02-agents/conversations/README.md | 28 +++++++++++++++++++ .../conversations/suspend_resume_session.py | 3 +- .../samples/02-agents/declarative/README.md | 2 +- .../foundry_chat_client_with_hosted_mcp.py | 4 +-- 11 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 python/samples/02-agents/context_providers/README.md create mode 100644 python/samples/02-agents/conversations/README.md diff --git a/python/samples/02-agents/context_providers/README.md b/python/samples/02-agents/context_providers/README.md new file mode 100644 index 0000000000..86137b344b --- /dev/null +++ b/python/samples/02-agents/context_providers/README.md @@ -0,0 +1,28 @@ +# Context Provider Samples + +These samples demonstrate how to use context providers to enrich agent conversations with external knowledge — from custom logic to Azure AI Search (RAG) and memory services. + +## Samples + +| File / Folder | Description | +|---------------|-------------| +| [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `BaseContextProvider` to extract and inject structured user information across turns. | +| [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Azure AI Foundry. | +| [`azure_ai_search/`](azure_ai_search/) | Retrieval Augmented Generation (RAG) with Azure AI Search in semantic and agentic modes. See its own [README](azure_ai_search/README.md). | +| [`mem0/`](mem0/) | Memory-powered context using the Mem0 integration (open-source and managed). See its own [README](mem0/README.md). | +| [`redis/`](redis/) | Redis-backed context providers for conversation memory and sessions. See its own [README](redis/README.md). | + +## Prerequisites + +**For `simple_context_provider.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: Model deployment name +- Azure CLI authentication (`az login`) + +**For `azure_ai_foundry_memory.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: Chat/responses model deployment name +- `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`: Embedding model deployment name (e.g., `text-embedding-ada-002`) +- Azure CLI authentication (`az login`) + +See each subfolder's README for provider-specific prerequisites. diff --git a/python/samples/02-agents/context_providers/mem0/mem0_basic.py b/python/samples/02-agents/context_providers/mem0/mem0_basic.py index 489e5bfcae..712d3b0491 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_basic.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_basic.py @@ -57,12 +57,16 @@ async def main() -> None: # Now tell the agent the company code and the report format that you want to use # and it should be able to invoke the tool and return the report. query = "I always work with CNTS and I always want a detailed report format. Please remember and retrieve it." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + # Mem0 processes and indexes memories asynchronously. # Wait for memories to be indexed before querying in a new thread. # In production, consider implementing retry logic or using Mem0's # eventual consistency handling instead of a fixed delay. print("Waiting for memories to be processed...") - await asyncio.sleep(12) # Empirically determined delay for Mem0 indexing + await asyncio.sleep(15) # Empirically determined delay for Mem0 indexing print("\nRequest within a new session:") # Create a new session for the agent. # The new session has no context of the previous conversation. @@ -70,7 +74,10 @@ async def main() -> None: # Since we have the mem0 component in the session, the agent should be able to # retrieve the company report without asking for clarification, as it will # be able to remember the user preferences from Mem0 component. + query = "Please retrieve my company report" + print(f"User: {query}") result = await agent.run(query, session=session) + print(f"Agent: {result}") if __name__ == "__main__": diff --git a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py index d402b34baf..2ba6113d15 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py @@ -71,8 +71,6 @@ async def example_agent_scoped_memory() -> None: print("2. Agent-Scoped Memory Example:") print("-" * 40) - user_id = "user123" - async with ( AzureCliCredential() as credential, Agent( @@ -83,34 +81,23 @@ async def example_agent_scoped_memory() -> None: context_providers=[ Mem0ContextProvider( source_id="mem0", - user_id=user_id, agent_id="scoped_assistant", ) ], ) as scoped_agent, ): - # Store some information - query = "Remember that for this conversation, I'm working on a Python project about data analysis." + query = ( + "Remember that I'm working on a Python project about data analysis " + "and I prefer using pandas and matplotlib." + ) print(f"User: {query}") result = await scoped_agent.run(query) print(f"Agent: {result}\n") - # Test memory retrieval - query = "What project am I working on?" - print(f"User: {query}") - result = await scoped_agent.run(query) - print(f"Agent: {result}\n") - - # Store more information - query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User: {query}") - result = await scoped_agent.run(query) - print(f"Agent: {result}\n") - - # Test comprehensive memory retrieval + new_session = scoped_agent.create_session() query = "What do you know about my current project and preferences?" - print(f"User: {query}") - result = await scoped_agent.run(query) + print(f"User (new session): {query}") + result = await scoped_agent.run(query, session=new_session) print(f"Agent: {result}\n") diff --git a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index ee0757d907..a9e72d83d7 100644 --- a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -30,9 +30,10 @@ from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisHistoryProvider from azure.identity import AzureCliCredential from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential +from dotenv import load_dotenv from redis.credentials import CredentialProvider -# Copyright (c) Microsoft. All rights reserved. +load_dotenv() class AzureCredentialProvider(CredentialProvider): diff --git a/python/samples/02-agents/context_providers/redis/redis_basics.py b/python/samples/02-agents/context_providers/redis/redis_basics.py index efe433e5d4..bf9e163a49 100644 --- a/python/samples/02-agents/context_providers/redis/redis_basics.py +++ b/python/samples/02-agents/context_providers/redis/redis_basics.py @@ -100,7 +100,7 @@ def search_flights(origin_airport_code: str, destination_airport_code: str, deta def create_chat_client() -> FoundryChatClient: - """Create an Azure OpenAI Responses client using a Foundry project endpoint.""" + """Create a FoundryChatClient using a Foundry project endpoint.""" return FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], diff --git a/python/samples/02-agents/context_providers/redis/redis_sessions.py b/python/samples/02-agents/context_providers/redis/redis_sessions.py index 7819894f7b..de1ef8e095 100644 --- a/python/samples/02-agents/context_providers/redis/redis_sessions.py +++ b/python/samples/02-agents/context_providers/redis/redis_sessions.py @@ -34,10 +34,12 @@ from agent_framework import Agent from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisContextProvider from azure.identity import AzureCliCredential +from dotenv import load_dotenv from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer -# Copyright (c) Microsoft. All rights reserved. +# Load environment variables from .env file +load_dotenv() # Default Redis URL for local Redis Stack. @@ -48,7 +50,7 @@ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") # Please set OPENAI_API_KEY to use the OpenAI vectorizer. # For chat responses, also set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL. def create_chat_client() -> FoundryChatClient: - """Create an Azure OpenAI Responses client using a Foundry project endpoint.""" + """Create a FoundryChatClient using a Foundry project endpoint.""" return FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], diff --git a/python/samples/02-agents/context_providers/simple_context_provider.py b/python/samples/02-agents/context_providers/simple_context_provider.py index 5f2a0f409a..dd8da8cbe6 100644 --- a/python/samples/02-agents/context_providers/simple_context_provider.py +++ b/python/samples/02-agents/context_providers/simple_context_provider.py @@ -50,9 +50,11 @@ class UserInfoMemory(ContextProvider): # Use the chat client to extract structured information result = await self._chat_client.get_response( messages=request_messages, # type: ignore - instructions="Extract the user's name and age from the message if present. " - "If not present return nulls.", - options={"response_format": UserInfo}, + options={ + "instructions": "Extract the user's name and age from the message if present. " + "If not present return nulls.", + "response_format": UserInfo, + }, ) # Update user info with extracted data diff --git a/python/samples/02-agents/conversations/README.md b/python/samples/02-agents/conversations/README.md new file mode 100644 index 0000000000..ab527890fa --- /dev/null +++ b/python/samples/02-agents/conversations/README.md @@ -0,0 +1,28 @@ +# Conversation & Session Management Samples + +These samples demonstrate different approaches to managing conversation history and session state in Agent Framework. + +## Samples + +| File | Description | +|------|-------------| +| [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume conversation sessions, comparing service-managed sessions (Azure AI Foundry) with in-memory sessions (OpenAI). | +| [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom history provider by extending `BaseHistoryProvider`, enabling conversation persistence in your preferred storage backend. | +| [`redis_history_provider.py`](redis_history_provider.py) | Use Redis as a history provider for persistent conversation history storage across sessions. | + +## Prerequisites + +**For `suspend_resume_session.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint (service-managed session) +- `FOUNDRY_MODEL`: The Foundry model deployment name +- `OPENAI_API_KEY`: Your OpenAI API key (in-memory session) +- Azure CLI authentication (`az login`) + +**For `custom_history_provider.py`:** +- `OPENAI_API_KEY`: Your OpenAI API key + +**For `redis_history_provider.py`:** +- `OPENAI_API_KEY`: Your OpenAI API key +- A running Redis server — default URL is `redis://localhost:6379` + - Override via the `REDIS_URL` environment variable for remote or authenticated instances + - Quickstart with Docker: `docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest` diff --git a/python/samples/02-agents/conversations/suspend_resume_session.py b/python/samples/02-agents/conversations/suspend_resume_session.py index a5e9c44248..457ffd9a6d 100644 --- a/python/samples/02-agents/conversations/suspend_resume_session.py +++ b/python/samples/02-agents/conversations/suspend_resume_session.py @@ -4,6 +4,7 @@ import asyncio from agent_framework import Agent, AgentSession from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -62,7 +63,7 @@ async def suspend_resume_in_memory_session() -> None: # OpenAI Chat Client is used as an example here, # other chat clients can be used as well. agent = Agent( - client=FoundryChatClient(), + client=OpenAIChatCompletionClient(), name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation.", ) diff --git a/python/samples/02-agents/declarative/README.md b/python/samples/02-agents/declarative/README.md index 4bc4489dc2..8cb6eadcd8 100644 --- a/python/samples/02-agents/declarative/README.md +++ b/python/samples/02-agents/declarative/README.md @@ -68,7 +68,7 @@ Illustrates a basic agent using Azure OpenAI with structured responses. **Key concepts**: Azure OpenAI integration, credential management, structured outputs -### 5. **OpenAI Responses Agent** ([`openai_responses_agent.py`](./openai_responses_agent.py)) +### 5. **OpenAI Responses Agent** ([`openai_agent.py`](./openai_agent.py)) Demonstrates the simplest possible agent using OpenAI directly. diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py index 214ff005e9..3089fd1856 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py @@ -51,7 +51,7 @@ async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", s """Here we let the session deal with the previous responses, and we just rerun with the approval.""" from agent_framework import Message - result = await agent.run(query, session=session, store=True) + result = await agent.run(query, session=session, options={"store": True}) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -66,7 +66,7 @@ async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", s contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, session=session, store=True) + result = await agent.run(new_input, session=session, options={"store": True}) return result From 628bb1af48ea264d17147f30190c1e745da309f7 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:25:24 +0100 Subject: [PATCH 20/20] .NET: Rename Microsoft.Agents.AI.AzureAI to Microsoft.Agents.AI.Foundry and consolidate FoundryMemory (#5042) * Update Foundry Responses as ChatClientAgent * Migrate obsolete AzureAI integration tests to versioned agent pattern Replace obsolete CreateAIAgentAsync/GetAIAgentAsync calls with Agents.CreateAgentVersionAsync() + AsAIAgent(AgentVersion) in all AzureAI integration tests. - Rename AIProjectClient* test files to FoundryVersionedAgent* - Register AIFunction tools in PromptAgentDefinition.Tools for server-side visibility via AsOpenAIResponseTool() - Skip structured output tests (AzureAIProjectChatClient clears ResponseFormat for versioned agents) - Remove all [Obsolete] attributes and #pragma warning disable CS0618 * Merge FoundryMemory package into AzureAI under Memory/ folder Move all FoundryMemory source, unit tests, and integration tests into the Microsoft.Agents.AI.AzureAI package. Change namespace from Microsoft.Agents.AI.FoundryMemory to Microsoft.Agents.AI.AzureAI. - Add [Experimental] to FoundryMemoryProviderOptions and Scope - Rename internal AIProjectClientExtensions to MemoryStoreExtensions - Update AzureAI .csproj with Compliance.Abstractions, Redaction - Remove FoundryMemory from solution and release filter - Update sample to reference AzureAI instead of FoundryMemory - Delete old Microsoft.Agents.AI.FoundryMemory project and tests * Add EnsureMemoryStoreCreatedAsync and memory existence checks to integration tests - Ensure memory store is created before testing memory operations - Add AZURE_AI_EMBEDDING_DEPLOYMENT_NAME config setting - Assert memories exist in store via SearchMemoriesAsync before cleanup - Verify scope isolation with direct memory store queries * Fix and rename AzureAI unit tests for RAPI vs Versioned clarity - Rename AsAIAgentAsync_* to AsAIAgent_* (drop Async from method group) - Add _Rapi_ prefix to non-versioned (Responses API) tests - Add _Versioned_ prefix to versioned agent tests where needed - Fix RAPI tests: assert GetService() is null - Fix Versioned tests: assert IsType and GetService() returns the client instance - Fix UserAgent header tests: proper HTTP handler routing - Fix ChatClient_UsesDefaultConversationIdAsync test setup - All 153 unit tests pass with 0 failures * Rename Microsoft.Agents.AI.AzureAI to Microsoft.Agents.AI.Foundry Rename the project, namespace, folder, and all references from Microsoft.Agents.AI.AzureAI to Microsoft.Agents.AI.Foundry. Also rename Workflows.Declarative.AzureAI to .Foundry. - Rename src, unit test, integration test, and workflow folders - Update namespaces in all source and test .cs files - Update ProjectReferences in ~47 sample and test .csproj files - Update solution files (.slnx, .slnf) - Update sample using statements - Update READMEs, SKILL.md, ADRs in docs/ - Disable package validation baseline for renamed packages - Fix UTF-8 BOM encoding on all affected .cs files - AzureAI.Persistent left completely unchanged * Fix format: remove ImplicitUsings, add explicit usings, fix BOM encoding - Remove ImplicitUsings=enable from Foundry csproj to resolve IDE0005 on shared ReplacingRedactor.cs - Add explicit System usings to all source files that relied on them - Sort usings alphabetically per editorconfig rules - Fix UTF-8 BOM on 12 sample Program.cs files - Rename Azure AI Foundry Agents to Microsoft Foundry Agents in docs --- .../.github/skills/project-structure/SKILL.md | 4 +- dotnet/agent-framework-dotnet.slnx | 14 +- dotnet/agent-framework-release.slnf | 6 +- .../Agent_With_AzureAIProject.csproj | 2 +- .../Agent_With_AzureAIProject/Program.cs | 2 +- ...ithMemory_Step04_MemoryUsingFoundry.csproj | 5 +- .../Program.cs | 5 +- ...entWithRAG_Step04_FoundryServiceRAG.csproj | 2 +- .../Program.cs | 2 +- .../Agent_Step07_AsMcpTool.csproj | 2 +- .../Agent_Step00_FoundryAgentLifecycle.csproj | 2 +- .../Program.cs | 2 +- .../Agent_Step01_Basics.csproj | 4 +- ...gent_Step02.1_MultiturnConversation.csproj | 2 +- ....2_MultiturnWithServerConversations.csproj | 4 +- .../Program.cs | 14 +- .../Agent_Step03_UsingFunctionTools.csproj | 4 +- ...p04_UsingFunctionToolsWithApprovals.csproj | 4 +- .../Agent_Step05_StructuredOutput.csproj | 4 +- ...Agent_Step06_PersistedConversations.csproj | 4 +- .../Agent_Step07_Observability.csproj | 4 +- .../Agent_Step08_DependencyInjection.csproj | 4 +- .../Agent_Step09_UsingMcpClientAsTools.csproj | 4 +- .../Agent_Step10_UsingImages.csproj | 4 +- .../Agent_Step11_AsFunctionTool.csproj | 4 +- .../Agent_Step12_Middleware.csproj | 4 +- .../Agent_Step13_Plugins.csproj | 4 +- .../Agent_Step14_CodeInterpreter.csproj | 4 +- .../Agent_Step15_ComputerUse.csproj | 4 +- .../Agent_Step15_ComputerUse/Program.cs | 2 +- .../Agent_Step16_FileSearch.csproj | 4 +- .../Agent_Step17_OpenAPITools.csproj | 4 +- .../Agent_Step17_OpenAPITools/Program.cs | 2 +- .../Agent_Step18_BingCustomSearch.csproj | 4 +- .../Agent_Step18_BingCustomSearch/Program.cs | 2 +- .../Agent_Step19_SharePoint.csproj | 4 +- .../Agent_Step19_SharePoint/Program.cs | 2 +- .../Agent_Step20_MicrosoftFabric.csproj | 4 +- .../Agent_Step20_MicrosoftFabric/Program.cs | 2 +- .../Agent_Step21_WebSearch.csproj | 4 +- .../Agent_Step22_MemorySearch.csproj | 4 +- .../Agent_Step22_MemorySearch/Program.cs | 2 +- .../Agent_Step23_LocalMCP.csproj | 4 +- .../FoundryAgent_Hosted_MCP.csproj | 2 +- .../Agents/FoundryAgent/FoundryAgent.csproj | 2 +- .../Agents/FoundryAgent/Program.cs | 2 +- .../ConfirmInput/ConfirmInput.csproj | 2 +- .../CustomerSupport/CustomerSupport.csproj | 2 +- .../DeepResearch/DeepResearch.csproj | 2 +- .../ExecuteCode/ExecuteCode.csproj | 2 +- .../ExecuteWorkflow/ExecuteWorkflow.csproj | 2 +- .../FunctionTools/FunctionTools.csproj | 2 +- .../HostedWorkflow/HostedWorkflow.csproj | 2 +- .../Declarative/HostedWorkflow/Program.cs | 2 +- .../InputArguments/InputArguments.csproj | 2 +- .../InvokeFunctionTool.csproj | 2 +- .../InvokeMcpTool/InvokeMcpTool.csproj | 2 +- .../Declarative/Marketing/Marketing.csproj | 2 +- .../StudentTeacher/StudentTeacher.csproj | 2 +- .../ToolApproval/ToolApproval.csproj | 2 +- .../A2AServer/A2AServer.csproj | 4 +- .../FoundrySingleAgent/Program.cs | 16 +- .../AzureAIProjectChatClientExtensions.cs | 935 ----- .../AzureAIProjectChatClient.cs | 6 +- .../AzureAIProjectChatClientExtensions.cs | 436 +++ .../AzureAIProjectResponsesChatClient.cs | 3 +- .../CompatibilitySuppressions.xml | 0 .../FoundryAITool.cs | 4 +- .../FoundryAgent.cs | 30 +- .../Memory}/FoundryMemoryJsonUtilities.cs | 6 +- .../Memory}/FoundryMemoryProvider.cs | 4 +- .../Memory}/FoundryMemoryProviderOptions.cs | 5 +- .../Memory}/FoundryMemoryProviderScope.cs | 5 +- .../Memory/MemoryStoreExtensions.cs} | 4 +- .../Microsoft.Agents.AI.Foundry.csproj} | 15 +- .../ProjectResponsesClientExtensions.cs | 1 + .../RequestOptionsExtensions.cs | 3 + .../Microsoft.Agents.AI.FoundryMemory.csproj | 39 - .../AzureAgentProvider.cs | 0 .../CompatibilitySuppressions.xml | 22 +- ...s.AI.Workflows.Declarative.Foundry.csproj} | 12 +- .../Shared/IntegrationTests/TestSettings.cs | 1 + ...tClientChatClientAgentRunStreamingTests.cs | 18 - .../AIProjectClientChatClientAgentRunTests.cs | 18 - .../Foundry.IntegrationTests.csproj} | 2 +- ...rsionedAgentChatClientRunStreamingTests.cs | 15 + ...FoundryVersionedAgentChatClientRunTests.cs | 15 + .../FoundryVersionedAgentCreateTests.cs} | 138 +- .../FoundryVersionedAgentFixture.cs} | 78 +- ...FoundryVersionedAgentRunStreamingTests.cs} | 9 +- .../FoundryVersionedAgentRunTests.cs} | 9 +- ...VersionedAgentStructuredOutputRunTests.cs} | 23 +- .../Memory}/FoundryMemoryProviderTests.cs | 83 +- ...sponsesAgentChatClientRunStreamingTests.cs | 2 +- .../ResponsesAgentChatClientRunTests.cs | 2 +- .../ResponsesAgentExtensionCreateTests.cs | 36 +- .../ResponsesAgentFixture.cs | 5 +- .../ResponsesAgentRunStreamingTests.cs | 2 +- .../ResponsesAgentRunTests.cs | 2 +- .../ResponsesAgentStructuredOutputRunTests.cs | 2 +- ...AzureAIProjectChatClientExtensionsTests.cs | 3472 ----------------- ...AzureAIProjectChatClientExtensionsTests.cs | 1814 +++++++++ .../AzureAIProjectChatClientTests.cs | 52 +- .../FakeAuthenticationTokenProvider.cs | 2 +- .../FoundryAgentTests.cs | 2 +- .../HttpHandlerAssert.cs | 2 +- .../Memory}/FoundryMemoryProviderTests.cs | 2 +- .../Memory}/TestableAIProjectClient.cs | 2 +- ...rosoft.Agents.AI.Foundry.UnitTests.csproj} | 7 +- .../ProjectResponsesClientExtensionsTests.cs | 2 +- .../TestData/AgentResponse.json | 0 .../TestData/AgentVersionResponse.json | 0 .../TestData/OpenAIDefaultResponse.json | 0 .../TestDataUtil.cs | 2 +- ...s.AI.FoundryMemory.IntegrationTests.csproj | 21 - ...t.Agents.AI.FoundryMemory.UnitTests.csproj | 16 - ...kflows.Declarative.IntegrationTests.csproj | 2 +- 117 files changed, 2770 insertions(+), 4840 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/AzureAIProjectChatClient.cs (98%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/AzureAIProjectResponsesChatClient.cs (95%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/CompatibilitySuppressions.xml (100%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/FoundryAITool.cs (99%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/FoundryAgent.cs (89%) rename dotnet/src/{Microsoft.Agents.AI.FoundryMemory => Microsoft.Agents.AI.Foundry/Memory}/FoundryMemoryJsonUtilities.cs (84%) rename dotnet/src/{Microsoft.Agents.AI.FoundryMemory => Microsoft.Agents.AI.Foundry/Memory}/FoundryMemoryProvider.cs (99%) rename dotnet/src/{Microsoft.Agents.AI.FoundryMemory => Microsoft.Agents.AI.Foundry/Memory}/FoundryMemoryProviderOptions.cs (95%) rename dotnet/src/{Microsoft.Agents.AI.FoundryMemory => Microsoft.Agents.AI.Foundry/Memory}/FoundryMemoryProviderScope.cs (89%) rename dotnet/src/{Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs => Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs} (93%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj => Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj} (63%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/ProjectResponsesClientExtensions.cs (99%) rename dotnet/src/{Microsoft.Agents.AI.AzureAI => Microsoft.Agents.AI.Foundry}/RequestOptionsExtensions.cs (96%) delete mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj rename dotnet/src/{Microsoft.Agents.AI.Workflows.Declarative.AzureAI => Microsoft.Agents.AI.Workflows.Declarative.Foundry}/AzureAgentProvider.cs (100%) rename dotnet/src/{Microsoft.Agents.AI.Workflows.Declarative.AzureAI => Microsoft.Agents.AI.Workflows.Declarative.Foundry}/CompatibilitySuppressions.xml (80%) rename dotnet/src/{Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj => Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj} (70%) delete mode 100644 dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs delete mode 100644 dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs rename dotnet/tests/{AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj => Foundry.IntegrationTests/Foundry.IntegrationTests.csproj} (83%) create mode 100644 dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs create mode 100644 dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs rename dotnet/tests/{AzureAI.IntegrationTests/AIProjectClientCreateTests.cs => Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs} (70%) rename dotnet/tests/{AzureAI.IntegrationTests/AIProjectClientFixture.cs => Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs} (70%) rename dotnet/tests/{AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs => Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs} (57%) rename dotnet/tests/{AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs => Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs} (56%) rename dotnet/tests/{AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs => Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs} (71%) rename dotnet/tests/{Microsoft.Agents.AI.FoundryMemory.IntegrationTests => Foundry.IntegrationTests/Memory}/FoundryMemoryProviderTests.cs (57%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentChatClientRunStreamingTests.cs (93%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentChatClientRunTests.cs (92%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentExtensionCreateTests.cs (79%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentFixture.cs (98%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentRunStreamingTests.cs (96%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentRunTests.cs (96%) rename dotnet/tests/{AzureAI.IntegrationTests => Foundry.IntegrationTests}/ResponsesAgentStructuredOutputRunTests.cs (99%) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/AzureAIProjectChatClientTests.cs (82%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/FakeAuthenticationTokenProvider.cs (95%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/FoundryAgentTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/HttpHandlerAssert.cs (96%) rename dotnet/tests/{Microsoft.Agents.AI.FoundryMemory.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Memory}/FoundryMemoryProviderTests.cs (98%) rename dotnet/tests/{Microsoft.Agents.AI.FoundryMemory.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests/Memory}/TestableAIProjectClient.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj => Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj} (65%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/ProjectResponsesClientExtensionsTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/TestData/AgentResponse.json (100%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/TestData/AgentVersionResponse.json (100%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/TestData/OpenAIDefaultResponse.json (100%) rename dotnet/tests/{Microsoft.Agents.AI.AzureAI.UnitTests => Microsoft.Agents.AI.Foundry.UnitTests}/TestDataUtil.cs (99%) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj delete mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj diff --git a/dotnet/.github/skills/project-structure/SKILL.md b/dotnet/.github/skills/project-structure/SKILL.md index 01dcafabf8..6ec9476039 100644 --- a/dotnet/.github/skills/project-structure/SKILL.md +++ b/dotnet/.github/skills/project-structure/SKILL.md @@ -12,8 +12,8 @@ dotnet/ │ ├── Microsoft.Agents.AI.Abstractions/ # Core AI agent abstractions │ ├── Microsoft.Agents.AI.A2A/ # Agent-to-Agent (A2A) provider │ ├── Microsoft.Agents.AI.OpenAI/ # OpenAI provider -│ ├── Microsoft.Agents.AI.AzureAI/ # Azure AI Foundry Agents (v2) provider -│ ├── Microsoft.Agents.AI.AzureAI.Persistent/ # Legacy Azure AI Foundry Agents (v1) provider +│ ├── Microsoft.Agents.AI.Foundry/ # Microsoft Foundry Agents (v2) provider +│ ├── Microsoft.Agents.AI.AzureAI.Persistent/ # Legacy Microsoft Foundry Agents (v1) provider │ ├── Microsoft.Agents.AI.Anthropic/ # Anthropic provider │ ├── Microsoft.Agents.AI.Workflows/ # Workflow orchestration │ └── ... # Other packages diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f16a580519..748bed8017 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -478,13 +478,13 @@ - + - + @@ -495,7 +495,7 @@ - + @@ -506,11 +506,11 @@ - + - + @@ -526,12 +526,12 @@ - + - + diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index 1c8f477b16..98f9dcf887 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -8,13 +8,13 @@ "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj", "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj", "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj", - "src\\Microsoft.Agents.AI.AzureAI\\Microsoft.Agents.AI.AzureAI.csproj", + "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj", "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj", "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj", "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj", "src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj", "src\\Microsoft.Agents.AI.DurableTask\\Microsoft.Agents.AI.DurableTask.csproj", - "src\\Microsoft.Agents.AI.FoundryMemory\\Microsoft.Agents.AI.FoundryMemory.csproj", + "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj", "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj", "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj", @@ -24,7 +24,7 @@ "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj", "src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj", "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj", - "src\\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj", + "src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj", "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj", "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj index a8deaa57b5..562ce0c37e 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index 8265623904..355f4e1380 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj index 0b6c06a5a8..4c83380f90 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,8 +14,7 @@ - - + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 9a8c643bb9..1132e9ef1d 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -11,8 +11,7 @@ using System.Text.Json; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; -using Microsoft.Agents.AI.FoundryMemory; +using Microsoft.Agents.AI.Foundry; string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "memory-store-sample"; @@ -37,7 +36,7 @@ FoundryMemoryProvider memoryProvider = new( memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123"))); -FoundryAgent agent = projectClient.AsAIAgent( +ChatClientAgent agent = projectClient.AsAIAgent( new ChatClientAgentOptions() { Name = "TravelAssistantWithFoundryMemory", diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj index d90e1c394b..6a2bd7618c 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs index e049618a72..7eb5bd5a39 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -7,7 +7,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using OpenAI; using OpenAI.Files; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj index 5239225499..a7df53251d 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj @@ -17,7 +17,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj index d861331d9f..4c83380f90 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs index 691af1fc16..16c219e144 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -7,7 +7,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj index 5e73fd236a..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs index 7a66555c18..6057d71895 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs @@ -4,10 +4,10 @@ // Server-side conversations persist on the Foundry service and are visible in the Foundry Project UI. // Use this when you need conversation history to be stored and accessible server-side. +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -15,12 +15,20 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLO // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -FoundryAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +ChatClientAgent agent = aiProjectClient .AsAIAgent(deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); +ProjectConversationsClient conversationsClient = aiProjectClient + .GetProjectOpenAIClient() + .GetProjectConversationsClient(); + +ProjectConversation conversation = (await conversationsClient.CreateProjectConversationAsync().ConfigureAwait(false)).Value; + // CreateConversationSessionAsync creates a server-side ProjectConversation // that persists on the Foundry service and is visible in the Foundry Project UI. -AgentSession session = await agent.CreateConversationSessionAsync(); +AgentSession session = await agent.CreateSessionAsync(conversation.Id); Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); Console.WriteLine(await agent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj index 1189939bc0..60190545a1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj index 72af634725..fd54882035 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj index 96cdf948fe..1f596a94a1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj index 6064cf9334..c2bb03bffd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj index 7367c1d2f8..6b4cb8f43e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj index b30baccd54..d1cfe0da4a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj index 1f5e37c1a3..5dfa730c8a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj index f739f56123..7bd94bd716 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index 22f03e27b3..d79c1122b7 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -5,7 +5,7 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj index 4602e9c9e0..8671b3027c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs index 5cc2720dd9..1968b89ca1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs index 4ab548403d..e244bb3cb0 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string connectionId = Environment.GetEnvironmentVariable("AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID") ?? throw new InvalidOperationException("AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID is not set."); string instanceName = Environment.GetEnvironmentVariable("AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs index aafca6b8bd..a5f8bf845a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string sharepointConnectionId = Environment.GetEnvironmentVariable("SHAREPOINT_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("SHAREPOINT_PROJECT_CONNECTION_ID is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs index 7c49afa7ee..69954b9483 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs @@ -6,7 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj index e11688b6ba..7d91cedca5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj index 4602e9c9e0..8671b3027c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs index 9e1a902f29..f2b447e52e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs @@ -9,7 +9,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj index e51f57c439..53b8a3af34 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj index d861331d9f..4c83380f90 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj b/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj index a7648b7a10..dd6854fbfe 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index e4fb1378b1..1ecbbae08e 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -4,7 +4,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; diff --git a/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj b/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj index dac2f49921..e7a4c3a774 100644 --- a/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj +++ b/dotnet/samples/03-workflows/Declarative/ConfirmInput/ConfirmInput.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj b/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj index 0bc83997d0..ec6ca6093c 100644 --- a/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj +++ b/dotnet/samples/03-workflows/Declarative/CustomerSupport/CustomerSupport.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj b/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj index cd533a0707..60928aaa52 100644 --- a/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj +++ b/dotnet/samples/03-workflows/Declarative/DeepResearch/DeepResearch.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj b/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj index 6a9c4957c2..c92a9ffbf1 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj +++ b/dotnet/samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj @@ -27,7 +27,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj index fce40b64d4..243d6d3d52 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj +++ b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj b/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj index f890fb30a8..5ed2187e2f 100644 --- a/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj +++ b/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj index f9379f38a3..31e88d54e7 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj @@ -27,7 +27,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs index 7852a8730f..1d688531df 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs @@ -8,7 +8,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Shared.Foundry; diff --git a/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj b/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj index 45bc44eaf3..ae6a0046ef 100644 --- a/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj +++ b/dotnet/samples/03-workflows/Declarative/InputArguments/InputArguments.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj index 67229da4b8..f2d23835a1 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj +++ b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj index 317d93c4e9..ea122bd971 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj b/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj index 20e5843554..23d1224a5e 100644 --- a/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj +++ b/dotnet/samples/03-workflows/Declarative/Marketing/Marketing.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj b/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj index 8136706b8d..2785c5930b 100644 --- a/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj +++ b/dotnet/samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj b/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj index a44e140f1f..e5cf939e15 100644 --- a/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj +++ b/dotnet/samples/03-workflows/Declarative/ToolApproval/ToolApproval.csproj @@ -26,7 +26,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj index 5a7ef20208..98b7b293c8 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj @@ -1,4 +1,4 @@ - + Exe @@ -23,7 +23,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs index 5173578ee8..185afd186f 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs @@ -70,17 +70,19 @@ string GetAvailableHotels( // Build response var result = new StringBuilder(); - result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):"); - result.AppendLine(); + result + .AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):") + .AppendLine(); foreach (var hotel in availableHotels) { var totalCost = hotel.PricePerNight * nights; - result.AppendLine($"**{hotel.Name}**"); - result.AppendLine($" Location: {hotel.Location}"); - result.AppendLine($" Rating: {hotel.Rating}/5"); - result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})"); - result.AppendLine(); + result + .AppendLine($"**{hotel.Name}**") + .AppendLine($" Location: {hotel.Location}") + .AppendLine($" Rating: {hotel.Rating}/5") + .AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})") + .AppendLine(); } return result.ToString(); diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs deleted file mode 100644 index 479ab894ae..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ /dev/null @@ -1,935 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects.Agents; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; -using OpenAI; -using OpenAI.Responses; - -namespace Azure.AI.Projects; - -/// -/// Provides extension methods for . -/// -[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] -public static partial class AzureAIProjectChatClientExtensions -{ - /// - /// Uses an existing server side agent, wrapped as a using the provided and . - /// - /// The to create the with. Cannot be . - /// The representing the name and version of the server side agent to create a for. Cannot be . - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. - /// Thrown when or is . - /// The agent with the specified name was not found. - /// - /// When instantiating a by using an , minimal information will be available about the agent in the instance level, and any logic that relies - /// on to retrieve information about the agent like will receive as the result. - /// - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - AgentReference agentReference, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentReference); - ThrowIfInvalidAgentName(agentReference.Name); - - var innerAgent = AsChatClientAgent( - aiProjectClient, - agentReference, - new ChatClientAgentOptions() - { - Id = $"{agentReference.Name}:{agentReference.Version}", - Name = agentReference.Name, - ChatOptions = new() { Tools = tools }, - }, - clientFactory, - services); - - return new FoundryAgent(aiProjectClient, innerAgent); - } - - /// - /// Asynchronously retrieves an existing server side agent, wrapped as a using the provided . - /// - /// The to create the with. Cannot be . - /// The name of the server side agent to create a for. Cannot be or whitespace. - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// The to monitor for cancellation requests. The default is . - /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. - /// Thrown when or is . - /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. - /// The agent with the specified name was not found. - [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] - public static async Task GetAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - ThrowIfInvalidAgentName(name); - - AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, name, cancellationToken).ConfigureAwait(false); - - return AsAIAgent( - aiProjectClient, - agentRecord, - tools, - clientFactory, - services); - } - - /// - /// Uses an existing server side agent, wrapped as a using the provided and . - /// - /// The client used to interact with Azure AI Agents. Cannot be . - /// The agent record to be converted. The latest version will be used. Cannot be . - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. - /// Thrown when or is . - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - AgentRecord agentRecord, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentRecord); - - var allowDeclarativeMode = tools is not { Count: > 0 }; - - var innerAgent = AsChatClientAgent( - aiProjectClient, - agentRecord, - tools, - clientFactory, - !allowDeclarativeMode, - services); - - return new FoundryAgent(aiProjectClient, innerAgent); - } - - /// - /// Uses an existing server side agent, wrapped as a using the provided and . - /// - /// The client used to interact with Azure AI Agents. Cannot be . - /// The agent version to be converted. Cannot be . - /// In-process invocable tools to be provided. If no tools are provided manual handling will be necessary to invoke in-process tools. - /// Provides a way to customize the creation of the underlying used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. - /// Thrown when or is . - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - AgentVersion agentVersion, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentVersion); - - var allowDeclarativeMode = tools is not { Count: > 0 }; - - var innerAgent = AsChatClientAgent( - aiProjectClient, - agentVersion, - tools, - clientFactory, - !allowDeclarativeMode, - services); - - return new FoundryAgent(aiProjectClient, innerAgent); - } - - /// - /// Asynchronously retrieves an existing server side agent, wrapped as a using the provided . - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The options for creating the agent. Cannot be . - /// A factory function to customize the creation of the chat client used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A to cancel the operation if needed. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . - [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] - public static async Task GetAIAgentAsync( - this AIProjectClient aiProjectClient, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - - if (string.IsNullOrWhiteSpace(options.Name)) - { - throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); - } - - ThrowIfInvalidAgentName(options.Name); - - AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, options.Name, cancellationToken).ConfigureAwait(false); - var agentVersion = agentRecord.GetLatestVersion(); - - var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: !options.UseProvidedChatClientAsIs); - - return new FoundryAgent( - aiProjectClient, - AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); - } - - /// - /// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a . - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The name for the agent. - /// The name of the model to use for the agent. Cannot be or whitespace. - /// The instructions that guide the agent's behavior. Cannot be or whitespace. - /// The description for the agent. - /// The tools to use when interacting with the agent, this is required when using prompt agent definitions with tools. - /// A factory function to customize the creation of the chat client used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A token to monitor for cancellation requests. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when , , or is . - /// Thrown when or is empty or whitespace. - /// When using prompt agent definitions with tools the parameter needs to be provided. - [Obsolete("Use native AIProjectClient.Agents APIs instead.")] - public static Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - string model, - string instructions, - string? description = null, - IList? tools = null, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - ThrowIfInvalidAgentName(name); - Throw.IfNullOrWhitespace(model); - Throw.IfNullOrWhitespace(instructions); - - return CreateAIAgentAsync( - aiProjectClient, - name, - tools, - new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, - clientFactory, - services, - cancellationToken); - } - - /// - /// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a . - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The name of the model to use for the agent. Cannot be or whitespace. - /// The options for creating the agent. Cannot be . - /// A factory function to customize the creation of the chat client used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A to cancel the operation if needed. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . - /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. - [Obsolete("Use native AIProjectClient.Agents APIs instead.")] - public static async Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string model, - ChatClientAgentOptions options, - Func? clientFactory = null, - IServiceProvider? services = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - Throw.IfNullOrWhitespace(model); - - if (string.IsNullOrWhiteSpace(options.Name)) - { - throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); - } - - ThrowIfInvalidAgentName(options.Name); - - AgentVersion agentVersion = await CreateAgentVersionFromOptionsAsync(aiProjectClient, model, options, cancellationToken).ConfigureAwait(false); - - var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); - - return new FoundryAgent( - aiProjectClient, - AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); - } - - /// - /// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a . - /// parameters. - /// - /// The client used to manage and interact with AI agents. Cannot be . - /// The name for the agent. - /// Settings that control the creation of the agent. - /// A factory function to customize the creation of the chat client used by the agent. - /// A token to monitor for cancellation requests. - /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . - /// - /// When using this extension method with a the tools are only declarative and not invocable. - /// Invocation of any in-process tools will need to be handled manually. - /// - [Obsolete("Use native AIProjectClient.Agents APIs instead.")] - public static Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - AgentVersionCreationOptions creationOptions, - Func? clientFactory = null, - CancellationToken cancellationToken = default) - { - Throw.IfNull(aiProjectClient); - ThrowIfInvalidAgentName(name); - Throw.IfNull(creationOptions); - - return CreateAIAgentAsync( - aiProjectClient, - name, - tools: null, - creationOptions, - clientFactory, - services: null, - cancellationToken); - } - - /// - /// Creates a non-versioned backed by the project's Responses API using the specified model and instructions. - /// - /// The to use for Responses API calls. Cannot be . - /// The model deployment name to use for the agent. Cannot be or whitespace. - /// The instructions that guide the agent's behavior. Cannot be or whitespace. - /// Optional name for the agent. - /// Optional human-readable description for the agent. - /// Optional collection of tools that the agent can invoke during conversations. - /// Provides a way to customize the creation of the underlying used by the agent. - /// Optional logger factory for creating loggers used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A backed by the project's Responses API. - /// Thrown when is . - /// Thrown when or is empty or whitespace. - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - string model, - string instructions, - string? name = null, - string? description = null, - IList? tools = null, - Func? clientFactory = null, - ILoggerFactory? loggerFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNullOrWhitespace(model); - Throw.IfNullOrWhitespace(instructions); - - ChatClientAgentOptions options = new() - { - Name = name, - Description = description, - ChatOptions = new ChatOptions - { - ModelId = model, - Instructions = instructions, - Tools = tools, - }, - }; - - return new FoundryAgent(aiProjectClient, CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services)); - } - - /// - /// Creates a non-versioned backed by the project's Responses API using the specified options. - /// - /// The to use for Responses API calls. Cannot be . - /// Configuration options that control the agent's behavior. is required. - /// Provides a way to customize the creation of the underlying used by the agent. - /// Optional logger factory for creating loggers used by the agent. - /// An optional to use for resolving services required by the instances being invoked. - /// A backed by the project's Responses API. - /// Thrown when or is . - /// Thrown when does not specify . - public static FoundryAgent AsAIAgent( - this AIProjectClient aiProjectClient, - ChatClientAgentOptions options, - Func? clientFactory = null, - ILoggerFactory? loggerFactory = null, - IServiceProvider? services = null) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - - return new FoundryAgent(aiProjectClient, CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services)); - } - - #region Private - - private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W"); - - /// - /// Asynchronously retrieves an agent record by name using the protocol method to inject user-agent headers. - /// - internal static async Task GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) - { - ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); - var rawResponse = protocolResponse.GetRawResponse(); - AgentRecord? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); - return result ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); - } - - /// - /// Asynchronously creates an agent version using the protocol method to inject user-agent headers. - /// - internal static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) - { - BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); - BinaryContent content = BinaryContent.Create(serializedOptions); - ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, content, foundryFeatures: null, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); - var rawResponse = protocolResponse.GetRawResponse(); - AgentVersion? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); - return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'."); - } - - private static async Task CreateAIAgentAsync( - this AIProjectClient aiProjectClient, - string name, - IList? tools, - AgentVersionCreationOptions creationOptions, - Func? clientFactory, - IServiceProvider? services, - CancellationToken cancellationToken) - { - var allowDeclarativeMode = tools is not { Count: > 0 }; - - if (!allowDeclarativeMode) - { - ApplyToolsToAgentDefinition(creationOptions.Definition, tools); - } - - AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false); - - return new FoundryAgent(aiProjectClient, AsChatClientAgent(aiProjectClient, agentVersion, tools, clientFactory, !allowDeclarativeMode, services)); - } - - /// - /// Creates an agent version with optional tool application, using the protocol method to inject user-agent headers. - /// - internal static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, IList? tools, CancellationToken cancellationToken) - { - if (tools is { Count: > 0 }) - { - ApplyToolsToAgentDefinition(creationOptions.Definition, tools); - } - - return await CreateAgentVersionWithProtocolAsync(aiProjectClient, agentName, creationOptions, cancellationToken).ConfigureAwait(false); - } - - /// - /// Creates an agent version from , mapping options to a . - /// - internal static async Task CreateAgentVersionFromOptionsAsync( - AIProjectClient aiProjectClient, - string model, - ChatClientAgentOptions options, - CancellationToken cancellationToken) - { - PromptAgentDefinition agentDefinition = new(model) - { - Instructions = options.ChatOptions?.Instructions, - Temperature = options.ChatOptions?.Temperature, - TopP = options.ChatOptions?.TopP, - TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } - }; - - if (options.ChatOptions?.Reasoning is { } reasoning) - { - agentDefinition.ReasoningOptions = ToResponseReasoningOptions(reasoning); - } - else if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions) - { - agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; - } - - ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); - - AgentVersionCreationOptions creationOptions = new(agentDefinition); - if (!string.IsNullOrWhiteSpace(options.Description)) - { - creationOptions.Description = options.Description; - } - - return await CreateAgentVersionWithProtocolAsync(aiProjectClient, options.Name!, creationOptions, cancellationToken).ConfigureAwait(false); - } - - /// Creates a with the specified options. - internal static ChatClientAgent CreateChatClientAgent( - AIProjectClient aiProjectClient, - AgentVersion agentVersion, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, services: services); - } - - internal static ChatClientAgent CreateResponsesChatClientAgent( - AIProjectClient aiProjectClient, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - ILoggerFactory? loggerFactory, - IServiceProvider? services) - { - Throw.IfNull(aiProjectClient); - Throw.IfNull(agentOptions); - Throw.IfNull(agentOptions.ChatOptions); - Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); - - IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); - } - - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient aiProjectClient, - AgentVersion agentVersion, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - => CreateChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services); - - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient aiProjectClient, - AgentRecord agentRecord, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, services: services); - } - - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient aiProjectClient, - AgentReference agentReference, - ChatClientAgentOptions agentOptions, - Func? clientFactory, - IServiceProvider? services) - { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); - - if (clientFactory is not null) - { - chatClient = clientFactory(chatClient); - } - - return new ChatClientAgent(chatClient, agentOptions, services: services); - } - - /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient AIProjectClient, - AgentVersion agentVersion, - IList? tools, - Func? clientFactory, - bool requireInvocableTools, - IServiceProvider? services) - => AsChatClientAgent( - AIProjectClient, - agentVersion, - CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools), - clientFactory, - services); - - /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. - private static ChatClientAgent AsChatClientAgent( - AIProjectClient AIProjectClient, - AgentRecord agentRecord, - IList? tools, - Func? clientFactory, - bool requireInvocableTools, - IServiceProvider? services) - => AsChatClientAgent( - AIProjectClient, - agentRecord, - CreateChatClientAgentOptions(agentRecord.GetLatestVersion(), new ChatOptions() { Tools = tools }, requireInvocableTools), - clientFactory, - services); - - /// - /// This method creates for the specified and the provided tools. - /// - /// The agent version. - /// The to use when interacting with the agent. - /// Indicates whether to enforce the presence of invocable tools when the AIAgent is created with an agent definition that uses them. - /// The created . - /// Thrown when the agent definition requires in-process tools but none were provided. - /// Thrown when the agent definition required tools were not provided. - /// - /// This method rebuilds the agent options from the agent definition returned by the version and combine with the in-proc tools when provided - /// this ensures that all required tools are provided and the definition of the agent options are consistent with the agent definition coming from the server. - /// - internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) - { - var agentDefinition = agentVersion.Definition; - - List? agentTools = null; - if (agentDefinition is PromptAgentDefinition { Tools: { Count: > 0 } definitionTools }) - { - // Check if no tools were provided while the agent definition requires in-proc tools. - if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool)) - { - throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter."); - } - - // Agregate all missing tools for a single error message. - List? missingTools = null; - - // Check function tools - foreach (ResponseTool responseTool in definitionTools) - { - if (responseTool is FunctionTool functionTool) - { - // Check if a tool with the same type and name exists in the provided tools. - // Always prefer matching AIFunction when available, regardless of requireInvocableTools. - var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name); - - if (matchingTool is not null) - { - (agentTools ??= []).Add(matchingTool!); - continue; - } - - if (requireInvocableTools) - { - (missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}"); - continue; - } - } - - (agentTools ??= []).Add(responseTool.AsAITool()); - } - - if (requireInvocableTools && missingTools is { Count: > 0 }) - { - throw new InvalidOperationException($"The following prompt agent definition required tools were not provided: {string.Join(", ", missingTools)}"); - } - } - - // Use the agent version's ID if available, otherwise generate one from name and version. - // This handles cases where hosted agents (like MCP agents) may not have an ID assigned. - var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; - var agentId = string.IsNullOrWhiteSpace(agentVersion.Id) - ? $"{agentVersion.Name}:{version}" - : agentVersion.Id; - - var agentOptions = new ChatClientAgentOptions() - { - Id = agentId, - Name = agentVersion.Name, - Description = agentVersion.Description, - }; - - if (agentDefinition is PromptAgentDefinition promptAgentDefinition) - { - agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); - agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; - agentOptions.ChatOptions.Temperature = promptAgentDefinition.Temperature; - agentOptions.ChatOptions.TopP = promptAgentDefinition.TopP; - } - - if (agentTools is { Count: > 0 }) - { - agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); - agentOptions.ChatOptions.Tools = agentTools; - } - - return agentOptions; - } - - /// - /// Creates a new instance of configured for the specified agent version and - /// optional base options. - /// - /// The agent version to use when configuring the chat client agent options. - /// An optional instance whose relevant properties will be copied to the - /// returned options. If , only default values are used. - /// Specifies whether the returned options must include invocable tools. Set to to require - /// invocable tools; otherwise, . - /// A instance configured according to the specified parameters. - internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) - { - var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools); - if (options is not null) - { - agentOptions.AIContextProviders = options.AIContextProviders; - agentOptions.ChatHistoryProvider = options.ChatHistoryProvider; - agentOptions.UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs; - } - - return agentOptions; - } - - /// - /// Adds the specified AI tools to a prompt agent definition, while also ensuring that all invocable tools are provided. - /// - /// The agent definition to which the tools will be applied. Must be a PromptAgentDefinition to support tools. - /// A list of AI tools to add to the agent definition. If null or empty, no tools are added. - /// Thrown if tools were provided but is not a . - /// When providing functions, they need to be invokable AIFunctions. - private static void ApplyToolsToAgentDefinition(AgentDefinition agentDefinition, IList? tools) - { - if (tools is { Count: > 0 }) - { - if (agentDefinition is not PromptAgentDefinition promptAgentDefinition) - { - throw new ArgumentException("Only prompt agent definitions support tools.", nameof(agentDefinition)); - } - - // When tools are provided, those should represent the complete set of tools for the agent definition. - // This is particularly important for existing agents so no duplication happens for what was already defined. - promptAgentDefinition.Tools.Clear(); - - foreach (var tool in tools) - { - // Ensure that any AIFunctions provided are In-Proc, not just the declarations. - if (tool is not AIFunction && ( - tool.GetService() is not null // Declarative FunctionTool converted as AsAITool() - || tool is AIFunctionDeclaration)) // AIFunctionDeclaration type - { - throw new InvalidOperationException("When providing functions, they need to be invokable AIFunctions. AIFunctions can be created correctly using AIFunctionFactory.Create"); - } - - promptAgentDefinition.Tools.Add( - // If this is a converted ResponseTool as AITool, we can directly retrieve the ResponseTool instance from GetService. - tool.GetService() - // Otherwise we should be able to convert existing MEAI Tool abstractions into OpenAI ResponseTools - ?? tool.AsOpenAIResponseTool() - ?? throw new InvalidOperationException("The provided AITool could not be converted to a ResponseTool, ensure that the AITool was created using responseTool.AsAITool() extension.")); - } - } - } - - private static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) => - format switch - { - ChatResponseFormatText => ResponseTextFormat.CreateTextFormat(), - - ChatResponseFormatJson jsonFormat when StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => - ResponseTextFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AgentClientJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - HasStrict(options?.AdditionalProperties)), - - ChatResponseFormatJson => ResponseTextFormat.CreateJsonObjectFormat(), - - _ => null, - }; - - /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; - - /// Gets whether the properties specify that strict schema handling is desired. - private static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => - additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && - strictObj is bool strictValue ? - strictValue : null; - - /// - /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per - /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. - /// - private static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() - { - DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true, - MoveDefaultKeywordToDescription = true, - RequireAllProperties = true, - TransformSchemaNode = (ctx, node) => - { - // Move content from common but unsupported properties to description. In particular, we focus on properties that - // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. - - if (node is JsonObject schemaObj) - { - StringBuilder? additionalDescription = null; - - ReadOnlySpan unsupportedProperties = - [ - // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: - "contentEncoding", "contentMediaType", "not", - - // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: - "minLength", "maxLength", "pattern", "format", - "minimum", "maximum", "multipleOf", - "patternProperties", - "minItems", "maxItems", - - // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords - // as being unsupported with Azure OpenAI: - "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", - "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", - ]; - - foreach (string propName in unsupportedProperties) - { - if (schemaObj[propName] is { } propNode) - { - _ = schemaObj.Remove(propName); - AppendLine(ref additionalDescription, propName, propNode); - } - } - - if (additionalDescription is not null) - { - schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? - $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : - additionalDescription.ToString(); - } - - return node; - - static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) - { - sb ??= new(); - - if (sb.Length > 0) - { - _ = sb.AppendLine(); - } - - _ = sb.Append(propName).Append(": ").Append(propNode); - } - } - - return node; - }, - }); - - /// - /// This class is a no-op implementation of to be used to honor the argument passed - /// while triggering avoiding any unexpected exception on the caller implementation. - /// - private sealed class NoOpChatClient : IChatClient - { - public void Dispose() { } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse()); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - yield return new ChatResponseUpdate(); - } - } - #endregion - -#if NET - [GeneratedRegex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")] - private static partial Regex AgentNameValidationRegex(); -#else - private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); -#endif - - internal static string ThrowIfInvalidAgentName(string? name) - { - Throw.IfNullOrWhitespace(name); - if (!AgentNameValidationRegex().IsMatch(name)) - { - throw new ArgumentException("Agent name must be 1-63 characters long, start and end with an alphanumeric character, and can only contain alphanumeric characters or hyphens.", nameof(name)); - } - return name; - } - - private static ResponseReasoningOptions? ToResponseReasoningOptions(ReasoningOptions reasoning) - { - ResponseReasoningEffortLevel? effortLevel = reasoning.Effort switch - { - ReasoningEffort.Low => ResponseReasoningEffortLevel.Low, - ReasoningEffort.Medium => ResponseReasoningEffortLevel.Medium, - ReasoningEffort.High => ResponseReasoningEffortLevel.High, - ReasoningEffort.ExtraHigh => ResponseReasoningEffortLevel.High, - _ => null, - }; - - ResponseReasoningSummaryVerbosity? summary = reasoning.Output switch - { - ReasoningOutput.Summary => ResponseReasoningSummaryVerbosity.Concise, - ReasoningOutput.Full => ResponseReasoningSummaryVerbosity.Detailed, - _ => null, - }; - - if (effortLevel is null && summary is null) - { - return null; - } - - return new ResponseReasoningOptions - { - ReasoningEffortLevel = effortLevel, - ReasoningSummaryVerbosity = summary, - }; - } -} - -[JsonSerializable(typeof(JsonElement))] -internal sealed partial class AgentClientJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs index ec788233ed..b33af228b2 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.AI.Projects.Agents; @@ -10,7 +14,7 @@ using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs new file mode 100644 index 0000000000..d83299de90 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects.Agents; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Azure.AI.Projects; + +/// +/// Provides extension methods for . +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static partial class AzureAIProjectChatClientExtensions +{ + /// + /// Uses an existing server side agent, wrapped as a using the provided and . + /// + /// The to create the with. Cannot be . + /// The representing the name and version of the server side agent to create a for. Cannot be . + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. + /// Thrown when or is . + /// The agent with the specified name was not found. + /// + /// When instantiating a by using an , minimal information will be available about the agent in the instance level, and any logic that relies + /// on to retrieve information about the agent like will receive as the result. + /// + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + AgentReference agentReference, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentReference); + ThrowIfInvalidAgentName(agentReference.Name); + + var innerAgent = AsChatClientAgent( + aiProjectClient, + agentReference, + new ChatClientAgentOptions() + { + Id = $"{agentReference.Name}:{agentReference.Version}", + Name = agentReference.Name, + ChatOptions = new() { Tools = tools }, + }, + clientFactory, + services); + + return new FoundryAgent(aiProjectClient, innerAgent); + } + + /// + /// Uses an existing server side agent, wrapped as a using the provided and . + /// + /// The client used to interact with Azure AI Agents. Cannot be . + /// The agent record to be converted. The latest version will be used. Cannot be . + /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. + /// Thrown when or is . + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + AgentRecord agentRecord, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentRecord); + + var allowDeclarativeMode = tools is not { Count: > 0 }; + + var innerAgent = AsChatClientAgent( + aiProjectClient, + agentRecord, + tools, + clientFactory, + !allowDeclarativeMode, + services); + + return new FoundryAgent(aiProjectClient, innerAgent); + } + + /// + /// Uses an existing server side agent, wrapped as a using the provided and . + /// + /// The client used to interact with Azure AI Agents. Cannot be . + /// The agent version to be converted. Cannot be . + /// In-process invocable tools to be provided. If no tools are provided manual handling will be necessary to invoke in-process tools. + /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. + /// Thrown when or is . + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + AgentVersion agentVersion, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentVersion); + + var allowDeclarativeMode = tools is not { Count: > 0 }; + + var innerAgent = AsChatClientAgent( + aiProjectClient, + agentVersion, + tools, + clientFactory, + !allowDeclarativeMode, + services); + + return new FoundryAgent(aiProjectClient, innerAgent); + } + + /// + /// Creates a non-versioned backed by the project's Responses API using the specified model and instructions. + /// + /// The to use for Responses API calls. Cannot be . + /// The model deployment name to use for the agent. Cannot be or whitespace. + /// The instructions that guide the agent's behavior. Cannot be or whitespace. + /// Optional name for the agent. + /// Optional human-readable description for the agent. + /// Optional collection of tools that the agent can invoke during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for creating loggers used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A backed by the project's Responses API. + /// Thrown when is . + /// Thrown when or is empty or whitespace. + public static ChatClientAgent AsAIAgent( + this AIProjectClient aiProjectClient, + string model, + string instructions, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + ChatClientAgentOptions options = new() + { + Name = name, + Description = description, + ChatOptions = new ChatOptions + { + ModelId = model, + Instructions = instructions, + Tools = tools, + }, + }; + + return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + /// + /// Creates a non-versioned backed by the project's Responses API using the specified options. + /// + /// The to use for Responses API calls. Cannot be . + /// Configuration options that control the agent's behavior. is required. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for creating loggers used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A backed by the project's Responses API. + /// Thrown when or is . + /// Thrown when does not specify . + public static ChatClientAgent AsAIAgent( + this AIProjectClient aiProjectClient, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + + return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + #region Private + + /// Creates a with the specified options. + private static ChatClientAgent CreateChatClientAgent( + AIProjectClient aiProjectClient, + AgentVersion agentVersion, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + private static ChatClientAgent CreateResponsesChatClientAgent( + AIProjectClient aiProjectClient, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentOptions); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); + + IChatClient chatClient = aiProjectClient + .GetProjectOpenAIClient() + .GetResponsesClient() + .AsIChatClient(agentOptions.ChatOptions.ModelId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + AgentVersion agentVersion, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + => CreateChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services); + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + AgentRecord agentRecord, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + AgentReference agentReference, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + { + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient AIProjectClient, + AgentVersion agentVersion, + IList? tools, + Func? clientFactory, + bool requireInvocableTools, + IServiceProvider? services) + => AsChatClientAgent( + AIProjectClient, + agentVersion, + CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools), + clientFactory, + services); + + /// This method creates an with a auto-generated ChatClientAgentOptions from the specified configuration parameters. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient AIProjectClient, + AgentRecord agentRecord, + IList? tools, + Func? clientFactory, + bool requireInvocableTools, + IServiceProvider? services) + => AsChatClientAgent( + AIProjectClient, + agentRecord, + CreateChatClientAgentOptions(agentRecord.GetLatestVersion(), new ChatOptions() { Tools = tools }, requireInvocableTools), + clientFactory, + services); + + /// + /// This method creates for the specified and the provided tools. + /// + /// The agent version. + /// The to use when interacting with the agent. + /// Indicates whether to enforce the presence of invocable tools when the AIAgent is created with an agent definition that uses them. + /// The created . + /// Thrown when the agent definition requires in-process tools but none were provided. + /// Thrown when the agent definition required tools were not provided. + /// + /// This method rebuilds the agent options from the agent definition returned by the version and combine with the in-proc tools when provided + /// this ensures that all required tools are provided and the definition of the agent options are consistent with the agent definition coming from the server. + /// + private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) + { + var agentDefinition = agentVersion.Definition; + + List? agentTools = null; + if (agentDefinition is PromptAgentDefinition { Tools: { Count: > 0 } definitionTools }) + { + // Check if no tools were provided while the agent definition requires in-proc tools. + if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool)) + { + throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter."); + } + + // Agregate all missing tools for a single error message. + List? missingTools = null; + + // Check function tools + foreach (ResponseTool responseTool in definitionTools) + { + if (responseTool is FunctionTool functionTool) + { + // Check if a tool with the same type and name exists in the provided tools. + // Always prefer matching AIFunction when available, regardless of requireInvocableTools. + var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name); + + if (matchingTool is not null) + { + (agentTools ??= []).Add(matchingTool!); + continue; + } + + if (requireInvocableTools) + { + (missingTools ??= []).Add($"Function tool: {functionTool.FunctionName}"); + continue; + } + } + + (agentTools ??= []).Add(responseTool.AsAITool()); + } + + if (requireInvocableTools && missingTools is { Count: > 0 }) + { + throw new InvalidOperationException($"The following prompt agent definition required tools were not provided: {string.Join(", ", missingTools)}"); + } + } + + // Use the agent version's ID if available, otherwise generate one from name and version. + // This handles cases where hosted agents (like MCP agents) may not have an ID assigned. + var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; + var agentId = string.IsNullOrWhiteSpace(agentVersion.Id) + ? $"{agentVersion.Name}:{version}" + : agentVersion.Id; + + var agentOptions = new ChatClientAgentOptions() + { + Id = agentId, + Name = agentVersion.Name, + Description = agentVersion.Description, + }; + + if (agentDefinition is PromptAgentDefinition promptAgentDefinition) + { + agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); + agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; + agentOptions.ChatOptions.Temperature = promptAgentDefinition.Temperature; + agentOptions.ChatOptions.TopP = promptAgentDefinition.TopP; + } + + if (agentTools is { Count: > 0 }) + { + agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); + agentOptions.ChatOptions.Tools = agentTools; + } + + return agentOptions; + } + +#if NET + [GeneratedRegex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")] + private static partial Regex AgentNameValidationRegex(); +#else + private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); +#endif + + internal static string ThrowIfInvalidAgentName(string? name) + { + Throw.IfNullOrWhitespace(name); + if (!AgentNameValidationRegex().IsMatch(name)) + { + throw new ArgumentException("Agent name must be 1-63 characters long, start and end with an alphanumeric character, and can only contain alphanumeric characters or hyphens.", nameof(name)); + } + return name; + } +} + +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class AgentClientJsonContext : JsonSerializerContext; + +#endregion diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs similarity index 95% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs index 48bb20d766..a768d102f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs @@ -1,10 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Azure.AI.Projects; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; #pragma warning disable OPENAI001 internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/CompatibilitySuppressions.xml rename to dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs index 80ed48e1df..79e221f02f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Azure.AI.Projects.Agents; using Microsoft.Extensions.AI; @@ -8,7 +10,7 @@ using OpenAI.Responses; #pragma warning disable OPENAI001 -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides factory methods for creating instances from Microsoft Foundry and OpenAI response tools. diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index 66fa93e39f..b1d97a5c9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.ClientModel; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Extensions.AI; @@ -9,7 +13,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.AzureAI; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides an that uses Microsoft Foundry for AI agent capabilities. @@ -163,7 +167,29 @@ public sealed class FoundryAgent : DelegatingAIAgent }, }; - return AzureAIProjectChatClientExtensions.CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + private static ChatClientAgent CreateResponsesChatClientAgent( + AIProjectClient aiProjectClient, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentOptions); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); + + IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); } private static ChatClientAgent CreateInnerAgentFromEndpoint( diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryJsonUtilities.cs similarity index 84% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryJsonUtilities.cs index 1a0dd4f4e2..1ed4046de0 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryJsonUtilities.cs @@ -1,13 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides JSON serialization utilities for the Foundry Memory provider. /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal static class FoundryMemoryJsonUtilities { /// @@ -33,4 +36,5 @@ internal static class FoundryMemoryJsonUtilities WriteIndented = false)] [JsonSerializable(typeof(FoundryMemoryProviderScope))] [JsonSerializable(typeof(FoundryMemoryProvider.State))] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal partial class FoundryMemoryJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs index 93b343b2de..cc92c40a41 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs @@ -16,7 +16,7 @@ using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Provides a Microsoft Foundry Memory backed that persists conversation messages as memories @@ -27,7 +27,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages /// to the model, prefixed by a configurable context prompt. /// -[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryMemoryProvider : AIContextProvider { private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderOptions.cs similarity index 95% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderOptions.cs index cf4fb5ab15..668db44112 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderOptions.cs @@ -2,14 +2,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Shared.DiagnosticIds; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Options for configuring the . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryMemoryProviderOptions { /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderScope.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderScope.cs index 6646c482a3..769aff7370 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProviderScope.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Allows scoping of memories for the . @@ -13,6 +15,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// Common patterns include using a user ID, team ID, or other unique identifier /// to partition memories across different contexts. /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryMemoryProviderScope { /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs similarity index 93% rename from dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs index 9e24703d92..a696a33e5a 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/MemoryStoreExtensions.cs @@ -5,12 +5,12 @@ using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; -namespace Microsoft.Agents.AI.FoundryMemory; +namespace Microsoft.Agents.AI.Foundry; /// /// Internal extension methods for to provide MemoryStores helper operations. /// -internal static class AIProjectClientExtensions +internal static class MemoryStoreExtensions { /// /// Creates a memory store if it doesn't already exist. diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj similarity index 63% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj rename to dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 0cd8690126..03d84c9d47 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -2,21 +2,29 @@ true - enable true + $(NoWarn);OPENAI001 + + + + false + + true true + true + @@ -30,4 +38,9 @@ Provides Microsoft Agent Framework support for Foundry Agents. + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ProjectResponsesClientExtensions.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/ProjectResponsesClientExtensions.cs index 5a899d5076..cd253776a8 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ProjectResponsesClientExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs index 2705611b57..03e48e293b 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel.Primitives; +using System.Collections.Generic; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Agents.AI; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj deleted file mode 100644 index 7abc3d0bcc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - preview - $(NoWarn);OPENAI001 - - - - true - true - true - true - true - - - - - - - - - - - - - - - - - Microsoft Agent Framework - Azure AI Foundry Memory integration - Provides Azure AI Foundry Memory integration for Microsoft Agent Framework. - - - - - - - - diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml similarity index 80% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/CompatibilitySuppressions.xml rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml index 3454984fae..fbf1db84c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml @@ -1,39 +1,39 @@ - + CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true CP0002 M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll + lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll + lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll true \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj similarity index 70% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj index 5bf9f6d29e..407593536e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj @@ -13,10 +13,16 @@ + + + + false + + - Microsoft Agent Framework Declarative Workflows Azure AI - Provides Microsoft Agent Framework support for declarative workflows for Azure AI Agents. + Microsoft Agent Framework Declarative Workflows Foundry + Provides Microsoft Agent Framework support for declarative workflows for Microsoft Foundry Agents. @@ -24,7 +30,7 @@ - + diff --git a/dotnet/src/Shared/IntegrationTests/TestSettings.cs b/dotnet/src/Shared/IntegrationTests/TestSettings.cs index 880db9d1cd..de5757314c 100644 --- a/dotnet/src/Shared/IntegrationTests/TestSettings.cs +++ b/dotnet/src/Shared/IntegrationTests/TestSettings.cs @@ -16,6 +16,7 @@ internal static class TestSettings // Azure AI (Foundry) public const string AzureAIBingConnectionId = "AZURE_AI_BING_CONNECTION_ID"; + public const string AzureAIEmbeddingDeploymentName = "AZURE_AI_EMBEDDING_DEPLOYMENT_NAME"; public const string AzureAIMemoryStoreId = "AZURE_AI_MEMORY_STORE_ID"; public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME"; public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT"; diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs deleted file mode 100644 index 0e507e44c1..0000000000 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using AgentConformance.IntegrationTests; - -namespace AzureAI.IntegrationTests; - -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) -{ - public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() - { - Assert.Skip("No messages is not supported"); - return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); - } -} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs deleted file mode 100644 index a0ee72ebd4..0000000000 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using AgentConformance.IntegrationTests; - -namespace AzureAI.IntegrationTests; - -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests(() => new()) -{ - public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() - { - Assert.Skip("No messages is not supported"); - return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); - } -} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj b/dotnet/tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj similarity index 83% rename from dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj rename to dotnet/tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj index 2703360cb2..dbff50104c 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj +++ b/dotnet/tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj @@ -7,7 +7,7 @@ - + diff --git a/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs new file mode 100644 index 0000000000..c9128050fc --- /dev/null +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunStreamingTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace Foundry.IntegrationTests; + +public class FoundryVersionedAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs new file mode 100644 index 0000000000..fff2ad529f --- /dev/null +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentChatClientRunTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace Foundry.IntegrationTests; + +public class FoundryVersionedAgentChatClientRunTests() : ChatClientAgentRunTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs similarity index 70% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs index bc5f38acf2..160ab697f7 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentCreateTests.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods - using System; using System.IO; using System.Threading.Tasks; @@ -9,45 +7,43 @@ using AgentConformance.IntegrationTests.Support; using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Files; using OpenAI.Responses; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -[Obsolete("Use FoundryVersionedAgentCreateTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientCreateTests +/// +/// Integration tests for versioned creation via +/// AIProjectClient.Agents.CreateAgentVersionAsync and AIProjectClient.AsAIAgent(AgentVersion). +/// +public class FoundryVersionedAgentCreateTests { private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithFoundryOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism) + [Fact] + public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync() { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("IntegrationTestAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("IntegrationTestAgent"); const string AgentDescription = "An agent created during integration tests"; const string AgentInstructions = "You are an integration test agent"; // Act. - var agent = createMechanism switch - { - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - options: new ChatClientAgentOptions() + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + AgentName, + new AgentVersionCreationOptions( + new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - Name = AgentName, - Description = AgentDescription, - ChatOptions = new() { Instructions = AgentInstructions } - }), - "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( - name: AgentName, - creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { Instructions = AgentInstructions }) { Description = AgentDescription }), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") - }; + Instructions = AgentInstructions + }) + { + Description = AgentDescription + }); + + var agent = this._client.AsAIAgent(agentVersion); try { @@ -72,12 +68,11 @@ public class AIProjectClientCreateTests } [Theory(Skip = "For manual testing only")] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithFoundryOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism) + [InlineData("FileSearchTool")] + public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string _) { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("VectorStoreAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreAgent"); const string AgentInstructions = """ You are a helpful agent that can help fetch data from files you know about. Use the File Search Tool to look up codes for words. @@ -99,22 +94,19 @@ public class AIProjectClientCreateTests ); var vectorStoreMetadata = await projectOpenAIClient.GetProjectVectorStoresClient().CreateVectorStoreAsync(options: new() { FileIds = { uploadedAgentFile.Id }, Name = "WordCodeLookup_VectorStore" }); - // Act. - var agent = createMechanism switch + // Act — create agent version with FileSearch tool via native SDK, then wrap with AsAIAgent. + var definition = new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]), - "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + Instructions = AgentInstructions, + Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]) } }; + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + AgentName, + new AgentVersionCreationOptions(definition)); + + var agent = this._client.AsAIAgent(agentVersion); + try { // Assert. @@ -132,13 +124,11 @@ public class AIProjectClientCreateTests } } - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - [InlineData("CreateWithFoundryOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism) + [Fact] + public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync() { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("CodeInterpreterAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("CodeInterpreterAgent"); const string AgentInstructions = """ You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file and report the SECRET_NUMBER value it prints. Respond only with the number. @@ -158,24 +148,19 @@ public class AIProjectClientCreateTests purpose: FileUploadPurpose.Assistants ); - // Act. - var agent = createMechanism switch + // Act — create agent version with CodeInterpreter tool via native SDK, then wrap with AsAIAgent. + var definition = new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - // Hosted tool path (tools supplied via ChatClientAgentOptions) - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]), - // Foundry (definitions + resources provided directly) - "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - name: AgentName, - instructions: AgentInstructions, - tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + Instructions = AgentInstructions, + Tools = { ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))) } }; + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + AgentName, + new AgentVersionCreationOptions(definition)); + + var agent = this._client.AsAIAgent(agentVersion); + try { // Assert. @@ -202,7 +187,7 @@ public class AIProjectClientCreateTests public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync() { // Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("OpenAPITestAgent"); const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; const string CountriesOpenApiSpec = """ @@ -320,28 +305,29 @@ public class AIProjectClientCreateTests } } - [Theory] - [InlineData("CreateWithChatClientAgentOptionsAsync")] - public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) + [Fact] + public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync() { // Arrange. - string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("WeatherAgent"); + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("WeatherAgent"); const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; var weatherFunction = AIFunctionFactory.Create(GetWeather); - FoundryAgent agent = createMechanism switch + // Create agent version with the function tool registered in the server-side definition, + // then wrap with AsAIAgent passing the local AIFunction implementation. + var definition = new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { - "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( - model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), - options: new ChatClientAgentOptions() - { - Name = AgentName, - ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] } - }), - _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + Instructions = AgentInstructions, }; + definition.Tools.Add(weatherFunction.AsOpenAIResponseTool()); + + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + AgentName, + new AgentVersionCreationOptions(definition)); + + FoundryAgent agent = this._client.AsAIAgent(agentVersion, tools: [weatherFunction]); try { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs similarity index 70% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs index 112c76571b..2c06404eb6 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentFixture.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods - using System; using System.Collections.Generic; using System.Linq; @@ -10,16 +8,21 @@ using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; +using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -[Obsolete("Use FoundryVersionedAgentFixture instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientFixture : IChatClientAgentFixture +/// +/// Integration test fixture that creates versioned Foundry agents via +/// AIProjectClient.Agents.CreateAgentVersionAsync and wraps them +/// with AIProjectClient.AsAIAgent(AgentVersion). +/// +public class FoundryVersionedAgentFixture : IChatClientAgentFixture { private FoundryAgent _agent = null!; private AIProjectClient _client = null!; @@ -40,7 +43,6 @@ public class AIProjectClientFixture : IChatClientAgentFixture if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) { - // Conversation sessions do not persist message history. return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId); } @@ -119,14 +121,48 @@ public class AIProjectClientFixture : IChatClientAgentFixture string instructions = "You are a helpful assistant.", IList? aiTools = null) { - return (await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: instructions, tools: aiTools)).GetService()!; + var definition = new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = instructions + }; + + // Register AIFunction tool definitions in the server-side agent definition so the model + // can invoke them. The local AIFunction implementations are matched by name via AsAIAgent. + if (aiTools is not null) + { + foreach (var tool in aiTools) + { + if (tool.AsOpenAIResponseTool() is ResponseTool responseTool) + { + definition.Tools.Add(responseTool); + } + } + } + + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + GenerateUniqueAgentName(name), + new AgentVersionCreationOptions(definition)); + + return this._client.AsAIAgent(agentVersion, tools: aiTools).GetService()!; } public async Task CreateChatClientAgentAsync(ChatClientAgentOptions options) { options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); - return (await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options)).GetService()!; + var definition = new PromptAgentDefinition( + options.ChatOptions?.ModelId ?? TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = options.ChatOptions?.Instructions + }; + + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + options.Name, + new AgentVersionCreationOptions(definition) { Description = options.Description }); + + var agent = this._client.AsAIAgent(agentVersion, tools: options.ChatOptions?.Tools); + + return agent.GetService()!; } public static string GenerateUniqueAgentName(string baseName) => @@ -174,13 +210,33 @@ public class AIProjectClientFixture : IChatClientAgentFixture public virtual async ValueTask InitializeAsync() { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - this._agent = await this._client.CreateAIAgentAsync(GenerateUniqueAgentName("HelpfulAssistant"), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: "You are a helpful assistant."); + + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + GenerateUniqueAgentName("HelpfulAssistant"), + new AgentVersionCreationOptions( + new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = "You are a helpful assistant." + })); + + this._agent = this._client.AsAIAgent(agentVersion); } public async Task InitializeAsync(ChatClientAgentOptions options) { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); - this._agent = await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options); + + var definition = new PromptAgentDefinition( + options.ChatOptions?.ModelId ?? TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = options.ChatOptions?.Instructions + }; + + var agentVersion = await this._client.Agents.CreateAgentVersionAsync( + options.Name, + new AgentVersionCreationOptions(definition) { Description = options.Description }); + + this._agent = this._client.AsAIAgent(agentVersion, tools: options.ChatOptions?.Tools); } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs similarity index 57% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs index ad10ec5b7a..f5df9f1f42 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunStreamingTests.cs @@ -5,11 +5,9 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) +public class FoundryVersionedAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() { @@ -18,8 +16,7 @@ public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) +public class FoundryVersionedAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs similarity index 56% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs index 0f2b123fd9..9cefbd0f46 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentRunTests.cs @@ -5,11 +5,9 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) +public class FoundryVersionedAgentRunPreviousResponseTests() : RunTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() { @@ -18,8 +16,7 @@ public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStream } } -[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) +public class FoundryVersionedAgentRunConversationTests() : RunTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs similarity index 71% rename from dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs index b3782a6601..015877df05 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs @@ -1,25 +1,23 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; -#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture -[Obsolete("Use FoundryVersionedAgentStructuredOutputRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] -public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new AIProjectClientStructuredOutputFixture()) +public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new FoundryVersionedAgentStructuredOutputFixture()) { - private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time."; + private const string NotSupported = "Versioned Foundry agents do not support specifying structured output type at invocation time."; + private const string ResponseFormatNotSupported = "AzureAIProjectChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition."; /// /// Verifies that response format provided at agent initialization is used when invoking RunAsync. /// /// - [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)] public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() { // Arrange @@ -39,14 +37,14 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu } /// - /// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization. + /// Verifies that generic RunAsync works with versioned Foundry agents when structured output is configured at agent initialization. /// /// - /// AIProjectClient does not support specifying the structured output type at invocation time yet. + /// Versioned Foundry agents do not support specifying the structured output type at invocation time yet. /// The type T provided to RunAsync<T> is ignored by AzureAIProjectChatClient and is only used /// for deserializing the agent response by AgentResponse<T>.Result. /// - [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)] public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() { // Arrange @@ -88,10 +86,9 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu } /// -/// Represents a fixture for testing AIProjectClient with structured output of type provided at agent initialization. +/// Represents a fixture for testing versioned Foundry agents with structured output of type provided at agent initialization. /// -[Obsolete("Use FoundryVersionedAgentStructuredOutputFixture instead.")] -public class AIProjectClientStructuredOutputFixture : AIProjectClientFixture +public class FoundryVersionedAgentStructuredOutputFixture : FoundryVersionedAgentFixture { public override async ValueTask InitializeAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Foundry.IntegrationTests/Memory/FoundryMemoryProviderTests.cs similarity index 57% rename from dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs rename to dotnet/tests/Foundry.IntegrationTests/Memory/FoundryMemoryProviderTests.cs index d6092f5231..9b9b39cbdf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/Memory/FoundryMemoryProviderTests.cs @@ -1,14 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods - using System; using System.Threading.Tasks; using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using OpenAI.Responses; using Shared.IntegrationTests; -namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; +namespace Foundry.IntegrationTests.Memory; /// /// Integration tests for against a configured Azure AI Foundry Memory service. @@ -16,7 +19,6 @@ namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; /// /// These integration tests are skipped by default and require a live Azure AI Foundry Memory service. /// The tests need to be updated to use the new AIAgent-based API pattern. -/// Set to null to enable them after configuring the service. /// public sealed class FoundryMemoryProviderTests : IDisposable { @@ -25,6 +27,7 @@ public sealed class FoundryMemoryProviderTests : IDisposable private readonly AIProjectClient? _client; private readonly string? _memoryStoreName; private readonly string? _deploymentName; + private readonly string? _embeddingDeploymentName; private bool _disposed; public FoundryMemoryProviderTests() @@ -38,13 +41,15 @@ public sealed class FoundryMemoryProviderTests : IDisposable var endpoint = configuration[TestSettings.AzureAIProjectEndpoint]; var memoryStoreName = configuration[TestSettings.AzureAIMemoryStoreId]; var deploymentName = configuration[TestSettings.AzureAIModelDeploymentName]; + var embeddingDeploymentName = configuration[TestSettings.AzureAIEmbeddingDeploymentName]; if (!string.IsNullOrWhiteSpace(endpoint) && !string.IsNullOrWhiteSpace(memoryStoreName)) { - this._client = new AIProjectClient(new Uri(endpoint), TestAzureCliCredentials.CreateAzureCliCredential()); + this._client = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); this._memoryStoreName = memoryStoreName; this._deploymentName = deploymentName ?? "gpt-4.1-mini"; + this._embeddingDeploymentName = embeddingDeploymentName ?? "text-embedding-ada-002"; } } @@ -57,8 +62,17 @@ public sealed class FoundryMemoryProviderTests : IDisposable this._memoryStoreName!, stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1"))); - AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!, - options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] }); + await memoryProvider.EnsureMemoryStoreCreatedAsync(this._deploymentName!, this._embeddingDeploymentName!); + + AIAgent agent = this._client!.AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = this._deploymentName!, + Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details." + }, + AIContextProviders = [memoryProvider] + }); AgentSession session = await agent.CreateSessionAsync(); @@ -72,6 +86,15 @@ public sealed class FoundryMemoryProviderTests : IDisposable await memoryProvider.WhenUpdatesCompletedAsync(); await Task.Delay(2000); + // Assert - verify memories were actually created in the store before querying via agent + var searchResult = await this._client!.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName!, + new MemorySearchOptions("it-user-1") + { + Items = { ResponseItem.CreateUserMessageItem("Caoimhe") } + }); + Assert.NotEmpty(searchResult.Value.Memories); + AgentResponse resultAfter = await agent.RunAsync("What is my name?", session); // Cleanup @@ -95,10 +118,27 @@ public sealed class FoundryMemoryProviderTests : IDisposable this._memoryStoreName!, stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b"))); - AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!, - options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] }); - AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!, - options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] }); + await memoryProvider1.EnsureMemoryStoreCreatedAsync(this._deploymentName!, this._embeddingDeploymentName!); + + AIAgent agent1 = this._client!.AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = this._deploymentName!, + Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details." + }, + AIContextProviders = [memoryProvider1] + }); + + AIAgent agent2 = this._client!.AsAIAgent(new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = this._deploymentName!, + Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details." + }, + AIContextProviders = [memoryProvider2] + }); AgentSession session1 = await agent1.CreateSessionAsync(); AgentSession session2 = await agent2.CreateSessionAsync(); @@ -111,8 +151,25 @@ public sealed class FoundryMemoryProviderTests : IDisposable await memoryProvider1.WhenUpdatesCompletedAsync(); await Task.Delay(2000); - AgentResponse result1 = await agent1.RunAsync("What is your name?", session1); - AgentResponse result2 = await agent2.RunAsync("What is your name?", session2); + // Assert - verify memories were created in scope A but not in scope B + var searchResultA = await this._client!.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName!, + new MemorySearchOptions("it-scope-a") + { + Items = { ResponseItem.CreateUserMessageItem("Caoimhe") } + }); + Assert.NotEmpty(searchResultA.Value.Memories); + + var searchResultB = await this._client.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName!, + new MemorySearchOptions("it-scope-b") + { + Items = { ResponseItem.CreateUserMessageItem("Caoimhe") } + }); + Assert.Empty(searchResultB.Value.Memories); + + AgentResponse result1 = await agent1.RunAsync("What is my name?", session1); + AgentResponse result2 = await agent2.RunAsync("What is my name?", session2); // Assert Assert.Contains("Caoimhe", result1.Text); diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs similarity index 93% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs index 5895ceb8b9..c07509e04e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunTests.cs similarity index 92% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunTests.cs index d80b25deb2..100a3c001b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentChatClientRunTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentExtensionCreateTests.cs similarity index 79% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentExtensionCreateTests.cs index cd8dc6cb03..af358a9e55 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentExtensionCreateTests.cs @@ -3,13 +3,13 @@ using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; /// /// Integration tests for non-versioned creation via extension methods. @@ -30,16 +30,19 @@ public class ResponsesAgentExtensionCreateTests const string AgentDescription = "Integration test agent created from AIProjectClient.AsAIAgent(model, instructions)."; const string VerificationToken = "integration-extension-ok"; - FoundryAgent agent = this._client.AsAIAgent( + ChatClientAgent agent = this._client.AsAIAgent( model: Model, instructions: $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.", name: AgentName, description: AgentDescription); - AgentSession session = await agent.CreateSessionAsync(); + AgentSession? session = null; try { + var conversation = await CreateConversationAsync(this._client); + session = await agent.CreateSessionAsync(conversation.Id); + // Act AgentResponse response = await agent.RunAsync("Return the verification token.", session); @@ -47,7 +50,6 @@ public class ResponsesAgentExtensionCreateTests Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); Assert.Equal(AgentDescription, agent.Description); - Assert.Same(this._client, agent.GetService()); Assert.NotNull(agent.GetService()); Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase); } @@ -73,19 +75,22 @@ public class ResponsesAgentExtensionCreateTests }, }; - FoundryAgent agent = this._client.AsAIAgent(options); - ChatClientAgentSession session = await agent.CreateConversationSessionAsync(); + ChatClientAgent agent = this._client.AsAIAgent(options); + + ChatClientAgentSession? session = null; try { + var conversation = await CreateConversationAsync(this._client); + session = ((await agent.CreateSessionAsync(conversation.Id)) as ChatClientAgentSession)!; + // Act AgentResponse response = await agent.RunAsync("Return the verification token.", session); // Assert - Assert.StartsWith("conv_", session.ConversationId, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("conv_", session!.ConversationId, StringComparison.OrdinalIgnoreCase); Assert.Equal(options.Name, agent.Name); Assert.Equal(options.Description, agent.Description); - Assert.Same(this._client, agent.GetService()); Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase); } finally @@ -94,8 +99,13 @@ public class ResponsesAgentExtensionCreateTests } } - private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession session) + private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession? session) { + if (session is null) + { + return; + } + ChatClientAgentSession typedSession = (ChatClientAgentSession)session; if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) @@ -119,4 +129,10 @@ public class ResponsesAgentExtensionCreateTests await DeleteResponseChainAsync(client, response.Value.PreviousResponseId); } } + + private static async Task CreateConversationAsync(AIProjectClient client) + { + ProjectConversationsClient conversationsClient = client.GetProjectOpenAIClient().GetProjectConversationsClient(); + return (await conversationsClient.CreateProjectConversationAsync()).Value!; + } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentFixture.cs similarity index 98% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentFixture.cs index ec678a00d9..7bd0da0d95 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentFixture.cs @@ -9,19 +9,18 @@ using AgentConformance.IntegrationTests.Support; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Responses; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; /// /// Integration test fixture that creates non-versioned Responses agents via the direct AIProjectClient.AsAIAgent(...) path. /// public class ResponsesAgentFixture : IChatClientAgentFixture { - private FoundryAgent _agent = null!; + private ChatClientAgent _agent = null!; private AIProjectClient _client = null!; public IChatClient ChatClient => this._agent.GetService()!.ChatClient; diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunStreamingTests.cs similarity index 96% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunStreamingTests.cs index 5f21c316c4..09f5fe6b2e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunStreamingTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunTests.cs similarity index 96% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunTests.cs index 71460f7737..0635b0f4ac 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentRunTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using AgentConformance.IntegrationTests; using Microsoft.Agents.AI; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentRunPreviousResponseTests() : RunTests(() => new()) { diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs similarity index 99% rename from dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs rename to dotnet/tests/Foundry.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs index 19ebdb4e28..a561fbae1b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs @@ -7,7 +7,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Shared.IntegrationTests; -namespace AzureAI.IntegrationTests; +namespace Foundry.IntegrationTests; public class ResponsesAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new()) { diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs deleted file mode 100644 index 5c954d30e8..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ /dev/null @@ -1,3472 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Microsoft.Extensions.AI; -using Moq; -using OpenAI.Responses; - -namespace Microsoft.Agents.AI.AzureAI.UnitTests; - -#pragma warning disable CS0618 -/// -/// Unit tests for the class. -/// -[Obsolete("Includes coverage for obsolete AIProjectClient compatibility extension methods.")] -public sealed class AzureAIProjectChatClientExtensionsTests -{ - #region AsAIAgent(AIProjectClient, model, instructions) Tests - - /// - /// Verify that the non-versioned AsAIAgent overload throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithModelAndInstructions_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - client!.AsAIAgent("gpt-4o-mini", "You are helpful.")); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that the non-versioned AsAIAgent overload creates a valid ChatClientAgent. - /// - [Fact] - public void AsAIAgent_WithModelAndInstructions_CreatesChatClientAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - List tools = - [ - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - ]; - - // Act - FoundryAgent agent = client.AsAIAgent( - "gpt-4o-mini", - "You are helpful.", - name: "test-agent", - description: "A test agent", - tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - Assert.Equal("A test agent", agent.Description); - Assert.Same(client, agent.GetService()); - Assert.NotNull(agent.GetService()); - } - - /// - /// Verify that the non-versioned AsAIAgent overload applies the clientFactory. - /// - [Fact] - public void AsAIAgent_WithModelAndInstructions_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - TestChatClient? testChatClient = null; - - // Act - FoundryAgent agent = client.AsAIAgent( - "gpt-4o-mini", - "You are helpful.", - clientFactory: innerClient => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - TestChatClient? retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that the options-based non-versioned AsAIAgent overload creates a valid ChatClientAgent. - /// - [Fact] - public void AsAIAgent_WithOptions_CreatesChatClientAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - ChatClientAgentOptions options = new() - { - Name = "options-agent", - Description = "Agent from options", - ChatOptions = new ChatOptions - { - ModelId = "gpt-4o-mini", - Instructions = "You are helpful.", - }, - }; - - // Act - FoundryAgent agent = client.AsAIAgent(options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("options-agent", agent.Name); - Assert.Equal("Agent from options", agent.Description); - Assert.Same(client, agent.GetService()); - } - - /// - /// Verify that the non-versioned AsAIAgent overload adds the MEAI user-agent header to Responses API requests. - /// - [Fact] - public async Task AsAIAgent_WithModelAndInstructions_UserAgentHeaderAddedToResponsesRequestsAsync() - { - // Arrange - bool userAgentFound = false; - using HttpHandlerAssert httpHandler = new(request => - { - if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) - { - foreach (string value in values) - { - if (value.Contains("MEAI")) - { - userAgentFound = true; - } - } - } - - if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - TestDataUtil.GetOpenAIDefaultResponseJson(), - Encoding.UTF8, - "application/json") - }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json") - }; - }); - -#pragma warning disable CA5399 - using HttpClient httpClient = new(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient aiProjectClient = new( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - FoundryAgent agent = aiProjectClient.AsAIAgent( - "gpt-4o-mini", - "You are helpful."); - - // Act - AgentSession session = await agent.CreateSessionAsync(); - await agent.RunAsync("Hello", session); - - // Assert - Assert.True(userAgentFound, "MEAI user-agent header was not found in any request"); - } - - #endregion - - #region AsAIAgent(AIProjectClient, AgentRecord) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent(agentRecord)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when agentRecord is null. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_WithNullAgentRecord_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((AgentRecord)null!)); - - Assert.Equal("agentRecord", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with AgentRecord creates a valid agent. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_CreatesValidAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - - // Assert - Assert.NotNull(agent); - Assert.Equal("agent_abc123", agent.Name); - } - - /// - /// Verify that AsAIAgent with AgentRecord and clientFactory applies the factory. - /// - [Fact] - public void AsAIAgent_WithAgentRecord_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - TestChatClient? testChatClient = null; - - // Act - var agent = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - #endregion - - #region AsAIAgent(AIProjectClient, AgentVersion) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent(agentVersion)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when agentVersion is null. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithNullAgentVersion_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((AgentVersion)null!)); - - Assert.Equal("agentVersion", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with AgentVersion creates a valid agent. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_CreatesValidAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - Assert.Equal("agent_abc123", agent.Name); - } - - /// - /// Verify that AsAIAgent with AgentVersion and clientFactory applies the factory. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - TestChatClient? testChatClient = null; - - // Act - var agent = client.AsAIAgent( - agentVersion, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that AsAIAgent with requireInvocableTools=true enforces invocable tools. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsTrue_EnforcesInvocableTools() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = client.AsAIAgent(agentVersion, tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that AsAIAgent with requireInvocableTools=false allows declarative functions. - /// - [Fact] - public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsFalse_AllowsDeclarativeFunctions() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - should not throw even without tools when requireInvocableTools is false - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region GetAIAgentAsync(AIProjectClient, ChatClientAgentOptions) Tests - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentNullException when client is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AIProjectClient? client = null; - var options = new ChatClientAgentOptions { Name = "test-agent" }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client!.GetAIAgentAsync(options)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentNullException when options is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullOptions_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync((ChatClientAgentOptions)null!)); - - Assert.Equal("options", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions creates a valid agent. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_CreatesValidAgentAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent"); - var options = new ChatClientAgentOptions { Name = "test-agent" }; - - // Act - var agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - } - - #endregion - - #region AsAIAgent(AIProjectClient, string) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_ByName_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent("test-agent")); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when name is null. - /// - [Fact] - public void AsAIAgent_ByName_WithNullName_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((string)null!)); - - Assert.Equal("name", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentException when name is empty. - /// - [Fact] - public void AsAIAgent_ByName_WithEmptyName_ThrowsArgumentException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent(string.Empty)); - - Assert.Equal("name", exception.ParamName); - } - - #endregion - - #region GetAIAgentAsync(AIProjectClient, string) Tests - - /// - /// Verify that GetAIAgentAsync throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public async Task GetAIAgentAsync_ByName_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AIProjectClient? client = null; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client!.GetAIAgentAsync("test-agent")); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync throws ArgumentNullException when name is null. - /// - [Fact] - public async Task GetAIAgentAsync_ByName_WithNullName_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync(name: null!)); - - Assert.Equal("name", exception.ParamName); - } - - /// - /// Verify that GetAIAgentAsync throws InvalidOperationException when agent is not found. - /// - [Fact] - public async Task GetAIAgentAsync_ByName_WithNonExistentAgent_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - var mockAgentOperations = new Mock(); - mockAgentOperations - .Setup(c => c.GetAgentAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); - - var mockClient = new Mock(); - mockClient.SetupGet(c => c.Agents).Returns(mockAgentOperations.Object); - mockClient.Setup(x => x.GetConnection(It.IsAny())).Returns(new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None)); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync("non-existent-agent")); - - Assert.Contains("not found", exception.Message); - } - - #endregion - - #region AsAIAgent(AIProjectClient, AgentRecord) with tools Tests - - /// - /// Verify that AsAIAgent with additional tools when the definition has no tools does not throw and results in an agent with no tools. - /// - [Fact] - public void AsAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools_ShouldNotThrow() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = client.AsAIAgent(agentRecord, tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var agentVersion = chatClient.GetService(); - Assert.NotNull(agentVersion); - var definition = Assert.IsType(agentVersion.Definition); - Assert.Empty(definition.Tools); - } - - /// - /// Verify that AsAIAgent with null tools works correctly. - /// - [Fact] - public void AsAIAgent_WithAgentRecordAndNullTools_WorksCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord, tools: null); - - // Assert - Assert.NotNull(agent); - Assert.Equal("agent_abc123", agent.Name); - } - - #endregion - - #region GetAIAgentAsync(AIProjectClient, string) with tools Tests - - /// - /// Verify that GetAIAgentAsync with tools parameter creates an agent. - /// - [Fact] - public async Task GetAIAgentAsync_WithNameAndTools_CreatesAgentAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = await client.GetAIAgentAsync("test-agent", tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with model and options creates a valid agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_CreatesValidAgentAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", instructions: "Test instructions"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" } - }; - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - Assert.Equal("Test instructions", agent.GetService()!.Instructions); - } - - /// - /// Verify that CreateAIAgentAsync with model and options and clientFactory applies the factory. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithClientFactory_AppliesFactoryCorrectlyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", instructions: "Test instructions"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" } - }; - TestChatClient? testChatClient = null; - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-model", - options, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - #endregion - - #region CreateAIAgentAsync(AIProjectClient, string, AgentDefinition) Tests - - /// - /// Verify that CreateAIAgentAsync throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithAgentDefinition_WithNullClient_ThrowsArgumentNullExceptionAsync() - { - // Arrange - AIProjectClient? client = null; - var definition = new PromptAgentDefinition("test-model"); - var options = new AgentVersionCreationOptions(definition); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client!.CreateAIAgentAsync("agent-name", options)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that CreateAIAgentAsync throws ArgumentNullException when creationOptions is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithAgentDefinition_WithNullDefinition_ThrowsArgumentNullExceptionAsync() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.CreateAIAgentAsync(name: "agent-name", null!)); - - Assert.Equal("creationOptions", exception.ParamName); - } - - #endregion - - #region Tool Validation Tests - - /// - /// Verify that CreateAIAgent creates an agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgent without tools parameter creates an agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutToolsParameter_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - var definitionResponse = GeneratePromptDefinitionResponse(definition, null); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgent without tools in definition creates an agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutToolsInDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definition); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgent uses tools from the definition when no separate tools parameter is provided. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDefinitionTools_UsesDefinitionToolsAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - // Add a function tool to the definition - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - - // Create a response definition with the same tool - var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Single(promptDef.Tools); - Assert.Equal("required_tool", (promptDef.Tools.First() as FunctionTool)?.FunctionName); - } - } - - /// - /// Verify that CreateAIAgent creates an agent successfully when definition has a mix of custom and hosted tools. - /// - [Fact] - public async Task CreateAIAgentAsync_WithMixedToolsInDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); - definition.Tools.Add(new HostedFileSearchTool().GetService() ?? new HostedFileSearchTool().AsOpenAIResponseTool()); - - // Simulate agent definition response with the tools - var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - foreach (var tool in definition.Tools) - { - definitionResponse.Tools.Add(tool); - } - - using var testClient = CreateTestAgentClientWithHandler(agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Equal(3, promptDef.Tools.Count); - } - } - - /// - /// Verify that CreateAIAgentAsync when AI Tools are provided, uses them for the definition via http request. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNameAndAITools_SendsToolDefinitionViaHttpAsync() - { - // Arrange - using var httpHandler = new HttpHandlerAssert(async (request) => - { - if (request.Content is not null) - { - var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - - Assert.Contains("required_tool", requestBody); - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Act - var agent = await client.CreateAIAgentAsync( - name: "test-agent", - model: "test-model", - instructions: "Test", - tools: [AIFunctionFactory.Create(() => true, "required_tool")]); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - Assert.IsType(agentVersion.Definition); - } - - /// - /// Verify that when providing AITools with AsAIAgent, any additional tool that doesn't match the tools in agent definition are ignored. - /// - [Fact] - public void AsAIAgent_AdditionalAITools_WhenNotInTheDefinitionAreIgnored() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentVersion = this.CreateTestAgentVersion(); - - // Manually add tools to the definition to simulate inline tools - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - promptDef.Tools.Add(ResponseTool.CreateFunctionTool("inline_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - } - - var invocableInlineAITool = AIFunctionFactory.Create(() => "test", "inline_tool", "An invocable AIFunction for the inline function"); - var shouldBeIgnoredTool = AIFunctionFactory.Create(() => "test", "additional_tool", "An additional test function that should be ignored"); - - // Act & Assert - var agent = client.AsAIAgent(agentVersion, tools: [invocableInlineAITool, shouldBeIgnoredTool]); - Assert.NotNull(agent); - var version = agent.GetService(); - Assert.NotNull(version); - var definition = Assert.IsType(version.Definition); - Assert.NotEmpty(definition.Tools); - Assert.NotNull(GetAgentChatOptions(agent)); - Assert.NotNull(GetAgentChatOptions(agent)!.Tools); - Assert.Single(GetAgentChatOptions(agent)!.Tools!); - Assert.Equal("inline_tool", (definition.Tools.First() as FunctionTool)?.FunctionName); - } - - #endregion - - #region Inline Tools vs Parameter Tools Tests - - /// - /// Verify that tools passed as parameters are accepted by AsAIAgent. - /// - [Fact] - public void AsAIAgent_WithParameterTools_AcceptsTools() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - var tools = new List - { - AIFunctionFactory.Create(() => "tool1", "param_tool_1", "First parameter tool"), - AIFunctionFactory.Create(() => "tool2", "param_tool_2", "Second parameter tool") - }; - - // Act - var agent = client.AsAIAgent(agentRecord, tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var chatClient = agent.GetService(); - Assert.NotNull(chatClient); - var agentVersion = chatClient.GetService(); - Assert.NotNull(agentVersion); - } - - /// - /// Verify that CreateAIAgent with string parameters and tools creates an agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithStringParamsAndTools_CreatesAgentAsync() - { - // Arrange - var tools = new List - { - AIFunctionFactory.Create(() => "weather", "string_param_tool", "Tool from string params") - }; - - var definitionResponse = GeneratePromptDefinitionResponse(new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }, tools); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-agent", - "test-model", - "Test instructions", - tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Single(promptDef.Tools); - } - } - - /// - /// Verify that CreateAIAgentAsync with tools in definition creates an agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDefinitionTools_CreatesAgentAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("async_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that GetAIAgentAsync with tools parameter creates an agent. - /// - [Fact] - public async Task GetAIAgentAsync_WithToolsParameter_CreatesAgentAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var tools = new List - { - AIFunctionFactory.Create(() => "async_get_result", "async_get_tool", "An async get tool") - }; - - // Act - var agent = await client.GetAIAgentAsync("test-agent", tools: tools); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region Declarative Function Handling Tests - - /// - /// Verifies that CreateAIAgent uses tools from definition when they are ResponseTool instances, resulting in successful agent creation. - /// - [Fact] - public async Task CreateAIAgentAsync_WithResponseToolsInDefinition_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - - var fabricToolOptions = new FabricDataAgentToolOptions(); - fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); - - var sharepointOptions = new SharePointGroundingToolOptions(); - sharepointOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); - - var structuredOutputs = new StructuredOutputDefinition("name", "description", new Dictionary { ["schema"] = BinaryData.FromString(AIJsonUtilities.CreateJsonSchema(new { id = "test" }.GetType()).ToString()) }, false); - - // Add tools to the definition - definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add((ResponseTool)AgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolOptions([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); - definition.Tools.Add((ResponseTool)AgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolOptions(new BrowserAutomationToolConnectionParameters("id")))); - definition.Tools.Add(AgentTool.CreateA2ATool(new Uri("https://test-uri.microsoft.com"))); - definition.Tools.Add((ResponseTool)AgentTool.CreateBingGroundingTool(new BingGroundingSearchToolOptions([new BingGroundingSearchConfiguration("connection-id")]))); - definition.Tools.Add((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)); - definition.Tools.Add((ResponseTool)AgentTool.CreateOpenApiTool(new OpenApiFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); - definition.Tools.Add((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)); - definition.Tools.Add((ResponseTool)AgentTool.CreateStructuredOutputsTool(structuredOutputs)); - definition.Tools.Add((ResponseTool)AgentTool.CreateAzureAISearchTool(new AzureAISearchToolOptions([new AzureAISearchToolIndex() { IndexName = "name" }]))); - - // Generate agent definition response with the tools - var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); - - using var testClient = CreateTestAgentClientWithHandler(agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Equal(10, promptDef.Tools.Count); - } - } - - /// - /// Verify that CreateAIAgentAsync accepts FunctionTools from definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithFunctionToolsInDefinition_AcceptsDeclarativeFunctionAsync() - { - // Arrange - var functionTool = ResponseTool.CreateFunctionTool( - functionName: "get_user_name", - functionParameters: BinaryData.FromString("{}"), - strictModeEnabled: false, - functionDescription: "Gets the user's name, as used for friendly address." - ); - - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - definition.Tools.Add(functionTool); - - // Generate response with the declarative function - var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - definitionResponse.Tools.Add(functionTool); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync accepts declarative functions from definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDeclarativeFunctionFromDefinition_AcceptsDeclarativeFunctionAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration - using var doc = JsonDocument.Parse("{}"); - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); - - // Add to definition - definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync accepts declarative functions from definition. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDeclarativeFunctionInDefinition_AcceptsDeclarativeFunctionAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration - using var doc = JsonDocument.Parse("{}"); - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); - - // Add to definition - definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); - - // Generate response with the declarative function - var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - definitionResponse.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region Options Generation Validation Tests - - /// - /// Verify that ChatClientAgentOptions are generated correctly without tools. - /// - [Fact] - public async Task CreateAIAgentAsync_GeneratesCorrectChatClientAgentOptionsAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; - - var definitionResponse = GeneratePromptDefinitionResponse(definition, null); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-agent", options); - - // Assert - Assert.NotNull(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - Assert.Equal("test-agent", agentVersion.Name); - Assert.Equal("Test instructions", (agentVersion.Definition as PromptAgentDefinition)?.Instructions); - } - - /// - /// Verify that GetAIAgentAsync with options preserves custom properties from input options. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_PreservesCustomPropertiesAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(agentName: "test-agent", instructions: "Custom instructions", description: "Custom description"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - Description = "Custom description", - ChatOptions = new ChatOptions { Instructions = "Custom instructions" } - }; - - // Act - var agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-agent", agent.Name); - Assert.Equal("Custom instructions", agent.GetService()!.Instructions); - Assert.Equal("Custom description", agent.Description); - } - - /// - /// Verify that CreateAIAgentAsync with options and tools generates correct ChatClientAgentOptions. - /// - [Fact] - public async Task CreateAIAgentAsync_WithOptionsAndTools_GeneratesCorrectOptionsAsync() - { - // Arrange - var tools = new List - { - AIFunctionFactory.Create(() => "result", "option_tool", "A tool from options") - }; - - var definitionResponse = GeneratePromptDefinitionResponse( - new PromptAgentDefinition("test-model") { Instructions = "Test" }, - tools); - - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test", Tools = tools } - }; - - // Act - var agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - if (agentVersion.Definition is PromptAgentDefinition promptDef) - { - Assert.NotEmpty(promptDef.Tools); - Assert.Single(promptDef.Tools); - } - } - - #endregion - - #region AgentName Validation Tests - - /// - /// Verify that AsAIAgent throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public void AsAIAgent_ByName_WithInvalidAgentName_ThrowsArgumentException(string invalidName) - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent(invalidName)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task GetAIAgentAsync_ByName_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.GetAIAgentAsync(invalidName)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task GetAIAgentAsync_WithOptions_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = invalidName }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task CreateAIAgentAsync_WithBasicParams_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.CreateAIAgentAsync(invalidName, "model", "instructions")); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with AgentVersionCreationOptions throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task CreateAIAgentAsync_WithAgentDefinition_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - var mockClient = new Mock(); - var definition = new PromptAgentDefinition("test-model"); - var options = new AgentVersionCreationOptions(definition); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - mockClient.Object.CreateAIAgentAsync(invalidName, options)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with ChatClientAgentOptions throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public async Task CreateAIAgentAsync_WithOptions_WithInvalidAgentName_ThrowsArgumentExceptionAsync(string invalidName) - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = invalidName }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - /// - /// Verify that AsAIAgent with AgentReference throws ArgumentException when agent name is invalid. - /// - [Theory] - [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] - public void AsAIAgent_WithAgentReference_WithInvalidAgentName_ThrowsArgumentException(string invalidName) - { - // Arrange - var mockClient = new Mock(); - var agentReference = new AgentReference(invalidName, "1"); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent(agentReference)); - - Assert.Equal("name", exception.ParamName); - Assert.Contains("Agent name must be 1-63 characters long", exception.Message); - } - - #endregion - - #region AzureAIChatClient Behavior Tests - - /// - /// Verify that the underlying chat client created by extension methods can be wrapped with clientFactory. - /// - [Fact] - public void AsAIAgent_WithClientFactory_WrapsUnderlyingChatClient() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - int factoryCallCount = 0; - - // Act - var agent = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => - { - factoryCallCount++; - return new TestChatClient(innerClient); - }); - - // Assert - Assert.NotNull(agent); - Assert.Equal(1, factoryCallCount); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - } - - /// - /// Verify that clientFactory is called with the correct underlying chat client. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_ReceivesCorrectUnderlyingClientAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - IChatClient? receivedClient = null; - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-agent", - options, - clientFactory: (innerClient) => - { - receivedClient = innerClient; - return new TestChatClient(innerClient); - }); - - // Assert - Assert.NotNull(agent); - Assert.NotNull(receivedClient); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - } - - /// - /// Verify that multiple clientFactory calls create independent wrapped clients. - /// - [Fact] - public void AsAIAgent_MultipleCallsWithClientFactory_CreatesIndependentClients() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent1 = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - var agent2 = client.AsAIAgent( - agentRecord, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent1); - Assert.NotNull(agent2); - var client1 = agent1.GetService(); - var client2 = agent2.GetService(); - Assert.NotNull(client1); - Assert.NotNull(client2); - Assert.NotSame(client1, client2); - } - - /// - /// Verify that agent created with clientFactory maintains agent properties. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_PreservesAgentPropertiesAsync() - { - // Arrange - const string AgentName = "test-agent"; - const string Model = "test-model"; - const string Instructions = "Test instructions"; - using var testClient = CreateTestAgentClientWithHandler(AgentName, Instructions); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - AgentName, - Model, - Instructions, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - Assert.Equal(AgentName, agent.Name); - Assert.Equal(Instructions, agent.GetService()!.Instructions); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - } - - /// - /// Verify that agent created with clientFactory is created successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithClientFactory_CreatesAgentSuccessfullyAsync() - { - // Arrange - var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; - - var agentDefinitionResponse = GeneratePromptDefinitionResponse(definition, null); - using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: agentDefinitionResponse); - - var options = new AgentVersionCreationOptions(definition); - - // Act - var agent = await testClient.Client.CreateAIAgentAsync( - "test-agent", - options, - clientFactory: (innerClient) => new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var wrappedClient = agent.GetService(); - Assert.NotNull(wrappedClient); - var agentVersion = agent.GetService(); - Assert.NotNull(agentVersion); - } - - #endregion - - #region User-Agent Header Tests - - /// - /// Verifies that the MEAI user-agent header is added to CreateAIAgentAsync POST requests - /// via the protocol method's RequestOptions pipeline policy. - /// - [Fact] - public async Task CreateAIAgentAsync_UserAgentHeaderAddedToRequestsAsync() - { - using var httpHandler = new HttpHandlerAssert(request => - { - Assert.Equal("POST", request.Method.Method); - - // Verify MEAI user-agent header is present on CreateAgentVersion POST request - Assert.True(request.Headers.TryGetValues("User-Agent", out var userAgentValues)); - Assert.Contains(userAgentValues, v => v.Contains("MEAI")); - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - // Arrange - var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; - - // Act - var agent = await aiProjectClient.CreateAIAgentAsync("test", agentOptions); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verifies that the user-agent header is added to asynchronous GetAIAgentAsync requests. - /// - [Fact] - public async Task GetAIAgent_UserAgentHeaderAddedToRequestsAsync() - { - using var httpHandler = new HttpHandlerAssert(request => - { - Assert.Equal("GET", request.Method.Method); - Assert.Contains("MEAI", request.Headers.UserAgent.ToString()); - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - // Arrange - var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Act - var agent = await aiProjectClient.GetAIAgentAsync("test"); - - // Assert - Assert.NotNull(agent); - } - - #endregion - - #region GetAIAgent(AIProjectClient, AgentReference) Tests - - /// - /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithNullClient_ThrowsArgumentNullException() - { - // Arrange - AIProjectClient? client = null; - var agentReference = new AgentReference("test-name", "1"); - - // Act & Assert - var exception = Assert.Throws(() => - client!.AsAIAgent(agentReference)); - - Assert.Equal("aiProjectClient", exception.ParamName); - } - - /// - /// Verify that AsAIAgent throws ArgumentNullException when agentReference is null. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithNullAgentReference_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - - // Act & Assert - var exception = Assert.Throws(() => - mockClient.Object.AsAIAgent((AgentReference)null!)); - - Assert.Equal("agentReference", exception.ParamName); - } - - /// - /// Verify that AsAIAgent with AgentReference creates a valid agent. - /// - [Fact] - public void AsAIAgent_WithAgentReference_CreatesValidAgent() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - - // Act - var agent = client.AsAIAgent(agentReference); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-name", agent.Name); - Assert.Equal("test-name:1", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentReference and clientFactory applies the factory. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithClientFactory_AppliesFactoryCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - TestChatClient? testChatClient = null; - - // Act - var agent = client.AsAIAgent( - agentReference, - clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); - - // Assert - Assert.NotNull(agent); - var retrievedTestClient = agent.GetService(); - Assert.NotNull(retrievedTestClient); - Assert.Same(testChatClient, retrievedTestClient); - } - - /// - /// Verify that AsAIAgent with AgentReference sets the agent ID correctly. - /// - [Fact] - public void AsAIAgent_WithAgentReference_SetsAgentIdCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "2"); - - // Act - var agent = client.AsAIAgent(agentReference); - - // Assert - Assert.NotNull(agent); - Assert.Equal("test-name:2", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentReference and tools includes the tools in ChatOptions. - /// - [Fact] - public void AsAIAgent_WithAgentReference_WithTools_IncludesToolsInChatOptions() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - // Act - var agent = client.AsAIAgent(agentReference, tools: tools); - - // Assert - Assert.NotNull(agent); - var chatOptions = GetAgentChatOptions(agent); - Assert.NotNull(chatOptions); - Assert.NotNull(chatOptions.Tools); - Assert.Single(chatOptions.Tools); - } - - #endregion - - #region GetService Tests - - /// - /// Verify that GetService returns AgentRecord for agents created from AgentRecord. - /// - [Fact] - public void GetService_WithAgentRecord_ReturnsAgentRecord() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - var retrievedRecord = agent.GetService(); - - // Assert - Assert.NotNull(retrievedRecord); - Assert.Equal(agentRecord.Id, retrievedRecord.Id); - } - - /// - /// Verify that GetService returns null for AgentRecord when agent is created from AgentReference. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsNullForAgentRecord() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedRecord = agent.GetService(); - - // Assert - Assert.Null(retrievedRecord); - } - - #endregion - - #region GetService Tests - - /// - /// Verify that GetService returns AgentVersion for agents created from AgentVersion. - /// - [Fact] - public void GetService_WithAgentVersion_ReturnsAgentVersion() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - var retrievedVersion = agent.GetService(); - - // Assert - Assert.NotNull(retrievedVersion); - Assert.Equal(agentVersion.Id, retrievedVersion.Id); - } - - /// - /// Verify that GetService returns null for AgentVersion when agent is created from AgentReference. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsNullForAgentVersion() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-name", "1"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedVersion = agent.GetService(); - - // Assert - Assert.Null(retrievedVersion); - } - - #endregion - - #region ChatClientMetadata Tests - - /// - /// Verify that ChatClientMetadata is properly populated for agents created from AgentRecord. - /// - [Fact] - public void ChatClientMetadata_WithAgentRecord_IsPopulatedCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - var metadata = agent.GetService(); - - // Assert - Assert.NotNull(metadata); - Assert.NotNull(metadata.DefaultModelId); - } - - /// - /// Verify that ChatClientMetadata.DefaultModelId is set from PromptAgentDefinition model property. - /// - [Fact] - public void ChatClientMetadata_WithPromptAgentDefinition_SetsDefaultModelIdFromModel() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var definition = new PromptAgentDefinition("gpt-4-turbo") - { - Instructions = "Test instructions" - }; - AgentRecord agentRecord = this.CreateTestAgentRecord(definition); - - // Act - var agent = client.AsAIAgent(agentRecord); - var metadata = agent.GetService(); - - // Assert - Assert.NotNull(metadata); - // The metadata should contain the model information from the agent definition - Assert.NotNull(metadata.DefaultModelId); - Assert.Equal("gpt-4-turbo", metadata.DefaultModelId); - } - - /// - /// Verify that ChatClientMetadata is properly populated for agents created from AgentVersion. - /// - [Fact] - public void ChatClientMetadata_WithAgentVersion_IsPopulatedCorrectly() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - var metadata = agent.GetService(); - - // Assert - Assert.NotNull(metadata); - Assert.NotNull(metadata.DefaultModelId); - Assert.Equal((agentVersion.Definition as PromptAgentDefinition)!.Model, metadata.DefaultModelId); - } - - #endregion - - #region AgentReference Availability Tests - - /// - /// Verify that GetService returns AgentReference for agents created from AgentReference. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsAgentReference() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("test-agent", "1.0"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal("test-agent", retrievedReference.Name); - Assert.Equal("1.0", retrievedReference.Version); - } - - /// - /// Verify that GetService returns null for AgentReference when agent is created from AgentRecord. - /// - [Fact] - public void GetService_WithAgentRecord_ReturnsAlsoAgentReference() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentRecord agentRecord = this.CreateTestAgentRecord(); - - // Act - var agent = client.AsAIAgent(agentRecord); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal(agentRecord.Name, retrievedReference.Name); - } - - /// - /// Verify that GetService returns null for AgentReference when agent is created from AgentVersion. - /// - [Fact] - public void GetService_WithAgentVersion_ReturnsAlsoAgentReference() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = this.CreateTestAgentVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal(agentVersion.Name, retrievedReference.Name); - } - - /// - /// Verify that GetService returns AgentReference with correct version information. - /// - [Fact] - public void GetService_WithAgentReference_ReturnsCorrectVersionInformation() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var agentReference = new AgentReference("versioned-agent", "3.5"); - - // Act - var agent = client.AsAIAgent(agentReference); - var retrievedReference = agent.GetService(); - - // Assert - Assert.NotNull(retrievedReference); - Assert.Equal("versioned-agent", retrievedReference.Name); - Assert.Equal("3.5", retrievedReference.Version); - } - - #endregion - - #region GetAIAgentAsync - Empty Name Tests - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when name is null. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithNullName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = null }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when name is empty. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithEmptyName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = string.Empty }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that GetAIAgentAsync with ChatClientAgentOptions throws ArgumentException when name is whitespace. - /// - [Fact] - public async Task GetAIAgentAsync_WithOptions_WithWhitespaceName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions { Name = " " }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - #endregion - - #region CreateAIAgentAsync - Empty Name Tests - - /// - /// Verify that CreateAIAgentAsync with model and options throws ArgumentException when name is null. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithNullName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = null, - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with model and options throws ArgumentException when name is empty. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithEmptyName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = string.Empty, - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with model and options throws ArgumentException when name is whitespace. - /// - [Fact] - public async Task CreateAIAgentAsync_WithModelAndOptions_WithWhitespaceName_ThrowsArgumentExceptionAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = " ", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync("test-model", options)); - - Assert.Equal("options", exception.ParamName); - Assert.Contains("Agent name must be provided", exception.Message); - } - - #endregion - - #region CreateAIAgentAsync - Response Format Tests - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatText response format creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithTextResponseFormat_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = ChatResponseFormat.Text - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson response format without schema creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithoutSchema_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = ChatResponseFormat.Json - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson with schema creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchema_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - JsonElement schemaElement = AIJsonUtilities.CreateJsonSchema(typeof(TestSchema)); - var jsonFormat = ChatResponseFormat.ForJsonSchema(schemaElement, "test_schema", "A test schema"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = jsonFormat - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson with schema and strict mode creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchemaAndStrictMode_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - JsonElement schemaElement = AIJsonUtilities.CreateJsonSchema(typeof(TestSchema)); - var jsonFormat = ChatResponseFormat.ForJsonSchema(schemaElement, "test_schema", "A test schema"); - var additionalProps = new AdditionalPropertiesDictionary - { - ["strictJsonSchema"] = true - }; - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = jsonFormat, - AdditionalProperties = additionalProps - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with ChatResponseFormatJson with schema and strict mode false creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchemaAndStrictModeFalse_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - JsonElement schemaElement = AIJsonUtilities.CreateJsonSchema(typeof(TestSchema)); - var jsonFormat = ChatResponseFormat.ForJsonSchema(schemaElement, "test_schema", "A test schema"); - var additionalProps = new AdditionalPropertiesDictionary - { - ["strictJsonSchema"] = false - }; - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - ResponseFormat = jsonFormat, - AdditionalProperties = additionalProps - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region CreateAIAgentAsync - RawRepresentationFactory Tests - - /// - /// Verify that CreateAIAgentAsync with RawRepresentationFactory that returns CreateResponseOptions creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithRawRepresentationFactory_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - RawRepresentationFactory = _ => new CreateResponseOptions() - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with RawRepresentationFactory that returns null does not fail. - /// - [Fact] - public async Task CreateAIAgentAsync_WithRawRepresentationFactoryReturningNull_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - RawRepresentationFactory = _ => null - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with RawRepresentationFactory that returns non-CreateResponseOptions does not fail. - /// - [Fact] - public async Task CreateAIAgentAsync_WithRawRepresentationFactoryReturningNonCreateResponseOptions_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - RawRepresentationFactory = _ => new object() - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region CreateAIAgentAsync - Description Tests - - /// - /// Verify that CreateAIAgentAsync with description sets description on the agent. - /// - [Fact] - public async Task CreateAIAgentAsync_WithDescription_SetsDescriptionAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(description: "Test description"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - Description = "Test description", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.Equal("Test description", agent.Description); - } - - /// - /// Verify that CreateAIAgentAsync without description still creates agent successfully. - /// - [Fact] - public async Task CreateAIAgentAsync_WithoutDescription_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - } - - #endregion - - #region CreateChatClientAgentOptions - Missing Tools Tests - - /// - /// Verify that when invocable tools are required but not provided, an exception is thrown. - /// - [Fact] - public async Task GetAIAgentAsync_WithToolsRequiredButNotProvided_ThrowsArgumentExceptionAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act & Assert - ArgumentException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Contains("in-process tools must be provided", exception.Message); - } - - /// - /// Verify that when specific invocable tools are required but wrong ones are provided, InvalidOperationException is thrown. - /// - [Fact] - public async Task GetAIAgentAsync_WithWrongToolsProvided_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "wrong_function", "Wrong function") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Contains("required_function", exception.Message); - Assert.Contains("were not provided", exception.Message); - } - - /// - /// Verify that when tools are provided that match the definition, agent is created successfully. - /// - [Fact] - public async Task GetAIAgentAsync_WithMatchingToolsProvided_CreatesAgentSuccessfullyAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var tools = new List - { - AIFunctionFactory.Create(() => "test", "required_function", "Required function") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region CreateChatClientAgentOptions - Options Preservation Tests - - /// - /// Verify that CreateChatClientAgentOptions preserves AIContextProviders. - /// - [Fact] - public async Task GetAIAgentAsync_WithAIContextProviders_PreservesProviderAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - AIContextProviders = [new TestAIContextProvider()] - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that CreateChatClientAgentOptions preserves ChatHistoryProvider. - /// - [Fact] - public async Task GetAIAgentAsync_WithChatHistoryProvider_PreservesProviderAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - ChatHistoryProvider = new TestChatHistoryProvider() - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that CreateChatClientAgentOptions preserves UseProvidedChatClientAsIs. - /// - [Fact] - public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesSettingAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClient(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - UseProvidedChatClientAsIs = true - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that GetAIAgentAsync with UseProvidedChatClientAsIs=true skips tool validation - /// and does not throw even when server-side function tools exist without matching invocable tools. - /// - [Fact] - public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_SkipsToolValidationAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("required_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" }, - UseProvidedChatClientAsIs = true - }; - - // Act - should not throw even without tools when UseProvidedChatClientAsIs is true - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - } - - /// - /// Verify that GetAIAgentAsync with UseProvidedChatClientAsIs=true still matches provided AIFunction tools - /// to server-side function definitions, instead of falling back to the ResponseToolAITool wrapper. - /// - [Fact] - public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesProvidedToolsAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("my_function", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var providedTool = AIFunctionFactory.Create(() => "test", "my_function", "A test function"); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - UseProvidedChatClientAsIs = true, - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = [providedTool] - }, - }; - - // Act - UseProvidedChatClientAsIs is true, but provided AIFunctions should still be matched and preserved - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - - // Verify the provided AIFunction was matched and preserved in ChatOptions.Tools (not replaced by AsAITool wrapper) - var chatOptions = agent.GetService(); - Assert.NotNull(chatOptions); - Assert.NotNull(chatOptions!.Tools); - Assert.Contains(chatOptions.Tools, t => t is AIFunction af && af.Name == "my_function"); - } - - #endregion - - #region Empty Version and ID Handling Tests - - /// - /// Verify that GetAIAgentAsync handles an agent with empty version by using "latest" as fallback. - /// - [Fact] - public async Task GetAIAgentAsync_WithEmptyVersion_CreatesAgentSuccessfullyAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - // Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentRecord handles empty version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentRecordEmptyVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); - AgentRecord agentRecord = this.CreateTestAgentRecordWithEmptyVersion(); - - // Act - var agent = client.AsAIAgent(agentRecord); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentVersion handles empty version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentVersionEmptyVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); - AgentVersion agentVersion = this.CreateTestAgentVersionWithEmptyVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that GetAIAgentAsync handles an agent with whitespace-only version by using "latest" as fallback. - /// - [Fact] - public async Task GetAIAgentAsync_WithWhitespaceVersion_CreatesAgentSuccessfullyAsync() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions { Instructions = "Test" } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - // Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentRecord handles whitespace-only version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentRecordWhitespaceVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); - AgentRecord agentRecord = this.CreateTestAgentRecordWithWhitespaceVersion(); - - // Act - var agent = client.AsAIAgent(agentRecord); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - /// - /// Verify that AsAIAgent with AgentVersion handles whitespace-only version by using "latest" as fallback. - /// - [Fact] - public void AsAIAgent_WithAgentVersionWhitespaceVersion_CreatesAgentWithGeneratedId() - { - // Arrange - AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); - AgentVersion agentVersion = this.CreateTestAgentVersionWithWhitespaceVersion(); - - // Act - var agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" - Assert.Equal("agent_abc123:latest", agent.Id); - } - - #endregion - - #region ApplyToolsToAgentDefinition Tests - - /// - /// Verify that CreateAIAgentAsync with non-PromptAgentDefinition and tools throws ArgumentException. - /// - [Fact] - public async Task CreateAIAgentAsync_WithNonPromptAgentDefinitionAndTools_ThrowsArgumentExceptionAsync() - { - // Arrange - var tools = new List - { - AIFunctionFactory.Create(() => "test", "test_function", "A test function") - }; - - using HttpHandlerAssert httpHandler = new(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") - }); - -#pragma warning disable CA5399 - using HttpClient httpClient = new(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient client = new(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Create a mock AgentDefinition that is not PromptAgentDefinition - // Since we can't easily create a non-PromptAgentDefinition in the public API, we test this path via the CreateAIAgentAsync that builds a PromptAgentDefinition - // The ApplyToolsToAgentDefinition is only called when tools.Count > 0, and we provide tools - // But PromptAgentDefinition is always created by CreateAIAgentAsync(name, model, instructions, tools) - // So this path is hard to hit without mocking. Let's test the declarative function rejection instead. - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", JsonDocument.Parse("{}").RootElement); - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync( - name: "test-agent", - model: "test-model", - instructions: "Test", - tools: [declarativeFunction])); - - Assert.Contains("invokable AIFunctions", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with AIFunctionDeclaration tools throws InvalidOperationException. - /// - [Fact] - public async Task CreateAIAgentAsync_WithAIFunctionDeclarationTool_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - using var doc = JsonDocument.Parse("{}"); - var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); - - using HttpHandlerAssert httpHandler = new(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") - }); - -#pragma warning disable CA5399 - using HttpClient httpClient = new(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient client = new(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.CreateAIAgentAsync( - name: "test-agent", - model: "test-model", - instructions: "Test", - tools: [declarativeFunction])); - - Assert.Contains("invokable AIFunctions", exception.Message); - } - - /// - /// Verify that CreateAIAgentAsync with ResponseTool converted via AsAITool works. - /// - [Fact] - public async Task CreateAIAgentAsync_WithResponseToolAsAITool_CreatesAgentSuccessfullyAsync() - { - // Arrange - ResponseTool responseTool = ResponseTool.CreateFunctionTool("response_tool", BinaryData.FromString("{}"), strictModeEnabled: false); - AITool convertedTool = responseTool.AsAITool(); - - // Create a definition with the function tool already in it - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(responseTool); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - // Matching invokable tool must be provided - var invokableTool = AIFunctionFactory.Create(() => "test", "response_tool", "Invokable version of the tool"); - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = [invokableTool] - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that CreateAIAgentAsync with hosted tool types works correctly. - /// - [Fact] - public async Task CreateAIAgentAsync_WithHostedToolTypes_CreatesAgentSuccessfullyAsync() - { - // Arrange - using var testClient = CreateTestAgentClientWithHandler(); - var webSearchTool = new HostedWebSearchTool(); - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = [webSearchTool] - } - }; - - // Act - FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that when the server returns tools but matching tools are provided, the agent is created. - /// - [Fact] - public async Task GetAIAgentAsync_WithServerDefinedToolsAndMatchingProvidedTools_CreatesAgentAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - // Add multiple function tools - definition.Tools.Add(ResponseTool.CreateFunctionTool("tool_one", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add(ResponseTool.CreateFunctionTool("tool_two", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var tools = new List - { - AIFunctionFactory.Create(() => "one", "tool_one", "Tool one"), - AIFunctionFactory.Create(() => "two", "tool_two", "Tool two") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that when the server returns mixed tools (function and hosted), the agent handles them correctly. - /// - [Fact] - public async Task GetAIAgentAsync_WithMixedServerTools_MatchesFunctionToolsOnlyAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - // Add a function tool - definition.Tools.Add(ResponseTool.CreateFunctionTool("function_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - // Add a hosted tool - definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var tools = new List - { - AIFunctionFactory.Create(() => "result", "function_tool", "The function tool") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act - FoundryAgent agent = await client.GetAIAgentAsync(options); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - /// - /// Verify that when partial tools are provided (some missing), InvalidOperationException is thrown listing missing tools. - /// - [Fact] - public async Task GetAIAgentAsync_WithPartialToolsProvided_ThrowsInvalidOperationWithMissingToolNamesAsync() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(ResponseTool.CreateFunctionTool("provided_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add(ResponseTool.CreateFunctionTool("missing_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - - AIProjectClient client = this.CreateTestAgentClient(agentDefinitionResponse: definition); - - var tools = new List - { - // Only providing one of two required tools - AIFunctionFactory.Create(() => "result", "provided_tool", "The provided tool") - }; - - var options = new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new ChatOptions - { - Instructions = "Test", - Tools = tools - } - }; - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => - client.GetAIAgentAsync(options)); - - Assert.Contains("missing_tool", exception.Message); - Assert.DoesNotContain("provided_tool", exception.Message); - } - - /// - /// Verify that when AsAIAgent is called without requireInvocableTools, hosted tools are correctly added. - /// - [Fact] - public void AsAIAgent_WithServerHostedTools_AddsToolsToAgentOptions() - { - // Arrange - PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; - definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); - - AIProjectClient client = this.CreateTestAgentClient(); - AgentVersion agentVersion = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson(agentDefinition: definition)))!; - - // Act - no tools provided, but requireInvocableTools is false when no tools param is passed - FoundryAgent agent = client.AsAIAgent(agentVersion); - - // Assert - Assert.NotNull(agent); - Assert.IsType(agent); - } - - #endregion - - #region Helper Methods - - /// - /// Creates a test AIProjectClient with fake behavior. - /// - private FakeAgentClient CreateTestAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse); - } - - /// - /// Creates a test AIProjectClient backed by an HTTP handler that returns canned responses. - /// Used for tests that exercise the protocol-method code path (CreateAgentVersion). - /// The returned client must be disposed to clean up the underlying HttpClient/handler. - /// - private static DisposableTestClient CreateTestAgentClientWithHandler(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - var responseJson = TestDataUtil.GetAgentVersionResponseJson(agentName, agentDefinitionResponse, instructions, description); - - var httpHandler = new HttpHandlerAssert(_ => - new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson, Encoding.UTF8, "application/json") }); - -#pragma warning disable CA5399 - var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - var client = new AIProjectClient( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new() { Transport = new HttpClientPipelineTransport(httpClient) }); - - return new DisposableTestClient(client, httpClient, httpHandler); - } - - /// - /// Wraps an AIProjectClient and its disposable dependencies for deterministic cleanup. - /// - private sealed class DisposableTestClient : IDisposable - { - private readonly HttpClient _httpClient; - private readonly HttpHandlerAssert _httpHandler; - - public DisposableTestClient(AIProjectClient client, HttpClient httpClient, HttpHandlerAssert httpHandler) - { - this.Client = client; - this._httpClient = httpClient; - this._httpHandler = httpHandler; - } - - public AIProjectClient Client { get; } - - public void Dispose() - { - this._httpClient.Dispose(); - this._httpHandler.Dispose(); - } - } - - /// - /// Creates a test AgentRecord for testing. - /// - private AgentRecord CreateTestAgentRecord(AgentDefinition? agentDefinition = null) - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!; - } - - /// - /// Creates a test AIProjectClient with empty version fields for testing hosted MCP agents. - /// - private FakeAgentClient CreateTestAgentClientWithEmptyVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, useEmptyVersion: true); - } - - /// - /// Creates a test AgentRecord with empty version for testing hosted MCP agents. - /// - private AgentRecord CreateTestAgentRecordWithEmptyVersion(AgentDefinition? agentDefinition = null) - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithEmptyVersion(agentDefinition: agentDefinition)))!; - } - - /// - /// Creates a test AgentVersion with empty version for testing hosted MCP agents. - /// - private AgentVersion CreateTestAgentVersionWithEmptyVersion() - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion()))!; - } - - /// - /// Creates a test AIProjectClient with whitespace-only version fields for testing hosted MCP agents. - /// - private FakeAgentClient CreateTestAgentClientWithWhitespaceVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) - { - return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, versionMode: VersionMode.Whitespace); - } - - /// - /// Creates a test AgentRecord with whitespace-only version for testing hosted MCP agents. - /// - private AgentRecord CreateTestAgentRecordWithWhitespaceVersion(AgentDefinition? agentDefinition = null) - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(agentDefinition: agentDefinition)))!; - } - - /// - /// Creates a test AgentVersion with whitespace-only version for testing hosted MCP agents. - /// - private AgentVersion CreateTestAgentVersionWithWhitespaceVersion() - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion()))!; - } - - private const string OpenAPISpec = """ - { - "openapi": "3.0.3", - "info": { "title": "Tiny Test API", "version": "1.0.0" }, - "paths": { - "/ping": { - "get": { - "summary": "Health check", - "operationId": "getPing", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "message": { "type": "string" } }, - "required": ["message"] - }, - "example": { "message": "pong" } - } - } - } - } - } - } - } - } - """; - - /// - /// Creates a test AgentVersion for testing. - /// - private AgentVersion CreateTestAgentVersion() - { - return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; - } - - /// - /// Specifies the version mode for test data generation. - /// - private enum VersionMode - { - Normal, - Empty, - Whitespace - } - - /// - /// Fake AIProjectClient for testing. - /// - private sealed class FakeAgentClient : AIProjectClient - { - public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, bool useEmptyVersion = false, VersionMode versionMode = VersionMode.Normal) - { - // Handle backward compatibility with bool parameter - var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode; - this.Agents = new FakeAgentsClient(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode); - } - - public override ClientConnection GetConnection(string connectionId) - { - return new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None); - } - - public override AgentsClient Agents { get; } - - private sealed class FakeAgentsClient : AgentsClient - { - private readonly string? _agentName; - private readonly string? _instructions; - private readonly string? _description; - private readonly AgentDefinition? _agentDefinition; - private readonly VersionMode _versionMode; - - public FakeAgentsClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal) - { - this._agentName = agentName; - this._instructions = instructions; - this._description = description; - this._agentDefinition = agentDefinitionResponse; - this._versionMode = versionMode; - } - - private string GetAgentResponseJson() - { - return this._versionMode switch - { - VersionMode.Empty => TestDataUtil.GetAgentResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - VersionMode.Whitespace => TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - _ => TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) - }; - } - - private string GetAgentVersionResponseJson() - { - return this._versionMode switch - { - VersionMode.Empty => TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - VersionMode.Whitespace => TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), - _ => TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) - }; - } - - public override ClientResult GetAgent(string agentName, RequestOptions options) - { - var responseJson = this.GetAgentResponseJson(); - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); - } - - public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentResponseJson(); - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); - } - - public override Task GetAgentAsync(string agentName, RequestOptions options) - { - var responseJson = this.GetAgentResponseJson(); - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); - } - - public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentResponseJson(); - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); - } - - public override ClientResult CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentVersionResponseJson(); - return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); - } - - public override Task> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) - { - var responseJson = this.GetAgentVersionResponseJson(); - return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); - } - } - } - - private static PromptAgentDefinition GeneratePromptDefinitionResponse(PromptAgentDefinition inputDefinition, List? tools) - { - var definitionResponse = new PromptAgentDefinition(inputDefinition.Model) { Instructions = inputDefinition.Instructions }; - if (tools is not null) - { - foreach (var tool in tools) - { - definitionResponse.Tools.Add(tool.GetService() ?? tool.AsOpenAIResponseTool()); - } - } - - return definitionResponse; - } - - /// - /// Test custom chat client that can be used to verify clientFactory functionality. - /// - private sealed class TestChatClient : DelegatingChatClient - { - public TestChatClient(IChatClient innerClient) : base(innerClient) - { - } - } - - /// - /// Mock pipeline response for testing ClientResult wrapping. - /// - private sealed class MockPipelineResponse : PipelineResponse - { - private readonly MockPipelineResponseHeaders _headers; - - public MockPipelineResponse(int status, BinaryData? content = null) - { - this.Status = status; - this.Content = content ?? BinaryData.Empty; - this._headers = new MockPipelineResponseHeaders(); - } - - public override int Status { get; } - - public override string ReasonPhrase => "OK"; - - public override Stream? ContentStream - { - get => null; - set { } - } - - public override BinaryData Content { get; } - - protected override PipelineResponseHeaders HeadersCore => this._headers; - - public override BinaryData BufferContent(CancellationToken cancellationToken = default) => - throw new NotSupportedException("Buffering content is not supported for mock responses."); - - public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException("Buffering content asynchronously is not supported for mock responses."); - - public override void Dispose() - { - } - - private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders - { - private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase) - { - { "Content-Type", "application/json" }, - { "x-ms-request-id", "test-request-id" } - }; - - public override bool TryGetValue(string name, out string? value) - { - return this._headers.TryGetValue(name, out value); - } - - public override bool TryGetValues(string name, out IEnumerable? values) - { - if (this._headers.TryGetValue(name, out var value)) - { - values = [value]; - return true; - } - - values = null; - return false; - } - - public override IEnumerator> GetEnumerator() - { - return this._headers.GetEnumerator(); - } - } - } - - #endregion - - /// - /// Helper method to access internal ChatOptions property via reflection. - /// - private static ChatOptions? GetAgentChatOptions(AIAgent agent) - { - ChatClientAgent? chatClientAgent = agent as ChatClientAgent ?? agent.GetService(); - if (chatClientAgent is null) - { - return null; - } - - var chatOptionsProperty = typeof(ChatClientAgent).GetProperty( - "ChatOptions", - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Instance); - - return chatOptionsProperty?.GetValue(chatClientAgent) as ChatOptions; - } - - /// - /// Test schema for JSON response format tests. - /// -#pragma warning disable CA1812 // Avoid uninstantiated internal classes - used via reflection by AIJsonUtilities - private sealed class TestSchema - { - public string? Name { get; set; } - public int Value { get; set; } - } -#pragma warning restore CA1812 - - /// - /// Test AIContextProvider for options preservation tests. - /// - private sealed class TestAIContextProvider : AIContextProvider - { - protected override ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - return new ValueTask(context.AIContext); - } - } - - /// - /// Test ChatHistoryProvider for options preservation tests. - /// - private sealed class TestChatHistoryProvider : ChatHistoryProvider - { - protected override ValueTask> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - return new ValueTask>(context.RequestMessages); - } - - protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default) - { - return default; - } - } -} -#pragma warning restore CS0618 - -/// -/// Provides test data for invalid agent name validation tests. -/// -internal static class InvalidAgentNameTestData -{ - /// - /// Gets a collection of invalid agent names for theory-based testing. - /// - /// Collection of invalid agent name test cases. - public static IEnumerable GetInvalidAgentNames() - { - yield return new object[] { "-agent" }; - yield return new object[] { "agent-" }; - yield return new object[] { "agent_name" }; - yield return new object[] { "agent name" }; - yield return new object[] { "agent@name" }; - yield return new object[] { "agent#name" }; - yield return new object[] { "agent$name" }; - yield return new object[] { "agent%name" }; - yield return new object[] { "agent&name" }; - yield return new object[] { "agent*name" }; - yield return new object[] { "agent.name" }; - yield return new object[] { "agent/name" }; - yield return new object[] { "agent\\name" }; - yield return new object[] { "agent:name" }; - yield return new object[] { "agent;name" }; - yield return new object[] { "agent,name" }; - yield return new object[] { "agentname" }; - yield return new object[] { "agent?name" }; - yield return new object[] { "agent!name" }; - yield return new object[] { "agent~name" }; - yield return new object[] { "agent`name" }; - yield return new object[] { "agent^name" }; - yield return new object[] { "agent|name" }; - yield return new object[] { "agent[name" }; - yield return new object[] { "agent]name" }; - yield return new object[] { "agent{name" }; - yield return new object[] { "agent}name" }; - yield return new object[] { "agent(name" }; - yield return new object[] { "agent)name" }; - yield return new object[] { "agent+name" }; - yield return new object[] { "agent=name" }; - yield return new object[] { "a" + new string('b', 63) }; - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs new file mode 100644 index 0000000000..c362fb4ec6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -0,0 +1,1814 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Moq; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +#pragma warning disable CS0618 +/// +/// Unit tests for the class. +/// +public sealed class AzureAIProjectChatClientExtensionsTests +{ + #region AsAIAgent(AIProjectClient, model, instructions) Tests + + /// + /// Verify that the non-versioned AsAIAgent overload throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + client!.AsAIAgent("gpt-4o-mini", "You are helpful.")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that the non-versioned AsAIAgent overload creates a valid ChatClientAgent. + /// + [Fact] + public void AsAIAgent_Rapi_WithModelAndInstructions_CreatesChatClientAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + List tools = + [ + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + ]; + + // Act + ChatClientAgent agent = client.AsAIAgent( + "gpt-4o-mini", + "You are helpful.", + name: "test-agent", + description: "A test agent", + tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("A test agent", agent.Description); + Assert.NotNull(agent.GetService()); + Assert.Null(agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload applies the clientFactory. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + TestChatClient? testChatClient = null; + + // Act + ChatClientAgent agent = client.AsAIAgent( + "gpt-4o-mini", + "You are helpful.", + clientFactory: innerClient => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + TestChatClient? retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that the options-based non-versioned AsAIAgent overload creates a valid ChatClientAgent. + /// + [Fact] + public void AsAIAgent_Rapi_WithOptions_CreatesChatClientAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ChatClientAgentOptions options = new() + { + Name = "options-agent", + Description = "Agent from options", + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Instructions = "You are helpful.", + }, + }; + + // Act + ChatClientAgent agent = client.AsAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("options-agent", agent.Name); + Assert.Equal("Agent from options", agent.Description); + Assert.Null(agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload adds the MEAI user-agent header to Responses API requests. + /// + [Fact] + public async Task AsAIAgent_Rapi_WithModelAndInstructions_UserAgentHeaderAddedToResponsesRequestsAsync() + { + // Arrange + bool userAgentFound = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) + { + foreach (string value in values) + { + if (value.Contains("MEAI")) + { + userAgentFound = true; + } + } + } + + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient aiProjectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + ChatClientAgent agent = aiProjectClient.AsAIAgent( + "gpt-4o-mini", + "You are helpful."); + + // Act + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + // Assert + Assert.True(userAgentFound, "MEAI user-agent header was not found in any request"); + } + + #endregion + + #region AsAIAgent(AIProjectClient, AgentRecord) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent(agentRecord)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when agentRecord is null. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_WithNullAgentRecord_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((AgentRecord)null!)); + + Assert.Equal("agentRecord", exception.ParamName); + } + + /// + /// Verify that AsAIAgent with AgentRecord creates a valid agent. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("agent_abc123", agent.Name); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that AsAIAgent with AgentRecord and clientFactory applies the factory. + /// + [Fact] + public void AsAIAgent_WithAgentRecord_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + #endregion + + #region AsAIAgent(AIProjectClient, AgentVersion) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent(agentVersion)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when agentVersion is null. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithNullAgentVersion_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((AgentVersion)null!)); + + Assert.Equal("agentVersion", exception.ParamName); + } + + /// + /// Verify that AsAIAgent with AgentVersion creates a valid agent. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("agent_abc123", agent.Name); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that AsAIAgent with AgentVersion and clientFactory applies the factory. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.AsAIAgent( + agentVersion, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that AsAIAgent with requireInvocableTools=true enforces invocable tools. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsTrue_EnforcesInvocableTools() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.AsAIAgent(agentVersion, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that AsAIAgent with requireInvocableTools=false allows declarative functions. + /// + [Fact] + public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsFalse_AllowsDeclarativeFunctions() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act - should not throw even without tools when requireInvocableTools is false + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region AsAIAgent(AIProjectClient, string) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_ByName_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent("test-agent")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when name is null. + /// + [Fact] + public void AsAIAgent_ByName_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((string)null!)); + + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentException when name is empty. + /// + [Fact] + public void AsAIAgent_ByName_WithEmptyName_ThrowsArgumentException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent(string.Empty)); + + Assert.Equal("name", exception.ParamName); + } + + #endregion + + #region AsAIAgent(AIProjectClient, AgentRecord) with tools Tests + + /// + /// Verify that AsAIAgent with additional tools when the definition has no tools does not throw and results in an agent with no tools. + /// + [Fact] + public void AsAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools_ShouldNotThrow() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.AsAIAgent(agentRecord, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var agentVersion = chatClient.GetService(); + Assert.NotNull(agentVersion); + var definition = Assert.IsType(agentVersion.Definition); + Assert.Empty(definition.Tools); + } + + /// + /// Verify that AsAIAgent with null tools works correctly. + /// + [Fact] + public void AsAIAgent_WithAgentRecordAndNullTools_WorksCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord, tools: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("agent_abc123", agent.Name); + } + + #endregion + + #region Tool Validation Tests + + /// + /// Verify that when providing AITools with AsAIAgent, any additional tool that doesn't match the tools in agent definition are ignored. + /// + [Fact] + public void AsAIAgent_AdditionalAITools_WhenNotInTheDefinitionAreIgnored() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentVersion = this.CreateTestAgentVersion(); + + // Manually add tools to the definition to simulate inline tools + if (agentVersion.Definition is PromptAgentDefinition promptDef) + { + promptDef.Tools.Add(ResponseTool.CreateFunctionTool("inline_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + } + + var invocableInlineAITool = AIFunctionFactory.Create(() => "test", "inline_tool", "An invocable AIFunction for the inline function"); + var shouldBeIgnoredTool = AIFunctionFactory.Create(() => "test", "additional_tool", "An additional test function that should be ignored"); + + // Act & Assert + var agent = client.AsAIAgent(agentVersion, tools: [invocableInlineAITool, shouldBeIgnoredTool]); + Assert.NotNull(agent); + var version = agent.GetService(); + Assert.NotNull(version); + var definition = Assert.IsType(version.Definition); + Assert.NotEmpty(definition.Tools); + Assert.NotNull(GetAgentChatOptions(agent)); + Assert.NotNull(GetAgentChatOptions(agent)!.Tools); + Assert.Single(GetAgentChatOptions(agent)!.Tools!); + Assert.Equal("inline_tool", (definition.Tools.First() as FunctionTool)?.FunctionName); + } + + #endregion + + #region Inline Tools vs Parameter Tools Tests + + /// + /// Verify that tools passed as parameters are accepted by AsAIAgent. + /// + [Fact] + public void AsAIAgent_WithParameterTools_AcceptsTools() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + var tools = new List + { + AIFunctionFactory.Create(() => "tool1", "param_tool_1", "First parameter tool"), + AIFunctionFactory.Create(() => "tool2", "param_tool_2", "Second parameter tool") + }; + + // Act + var agent = client.AsAIAgent(agentRecord, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var agentVersion = chatClient.GetService(); + Assert.NotNull(agentVersion); + } + + #endregion + + #region Declarative Function Handling Tests + + /// + /// Verifies that CreateAIAgent uses tools from definition when they are ResponseTool instances, resulting in successful agent creation. + /// + [Fact] + public async Task CreateAIAgentAsync_WithResponseToolsInDefinition_CreatesAgentSuccessfullyAsync() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }; + + var fabricToolOptions = new FabricDataAgentToolOptions(); + fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); + + var sharepointOptions = new SharePointGroundingToolOptions(); + sharepointOptions.ProjectConnections.Add(new ToolProjectConnection("connection-id")); + + var structuredOutputs = new StructuredOutputDefinition("name", "description", new Dictionary { ["schema"] = BinaryData.FromString(AIJsonUtilities.CreateJsonSchema(new { id = "test" }.GetType()).ToString()) }, false); + + // Add tools to the definition + definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); + definition.Tools.Add((ResponseTool)AgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolOptions([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); + definition.Tools.Add((ResponseTool)AgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolOptions(new BrowserAutomationToolConnectionParameters("id")))); + definition.Tools.Add(AgentTool.CreateA2ATool(new Uri("https://test-uri.microsoft.com"))); + definition.Tools.Add((ResponseTool)AgentTool.CreateBingGroundingTool(new BingGroundingSearchToolOptions([new BingGroundingSearchConfiguration("connection-id")]))); + definition.Tools.Add((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)); + definition.Tools.Add((ResponseTool)AgentTool.CreateOpenApiTool(new OpenApiFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); + definition.Tools.Add((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)); + definition.Tools.Add((ResponseTool)AgentTool.CreateStructuredOutputsTool(structuredOutputs)); + definition.Tools.Add((ResponseTool)AgentTool.CreateAzureAISearchTool(new AzureAISearchToolOptions([new AzureAISearchToolIndex() { IndexName = "name" }]))); + + // Generate agent definition response with the tools + var definitionResponse = GeneratePromptDefinitionResponse(definition, definition.Tools.Select(t => t.AsAITool()).ToList()); + + using var testClient = CreateTestAgentClientWithHandler(agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.Agents.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion2 = agent.GetService()!; + Assert.NotNull(agentVersion); + if (agentVersion2.Definition is PromptAgentDefinition promptDef) + { + Assert.NotEmpty(promptDef.Tools); + Assert.Equal(10, promptDef.Tools.Count); + } + } + + /// + /// Verify that AsAIAgentAsync accepts FunctionTools from definition. + /// + [Fact] + public async Task AsAIAgent_WithFunctionToolsInDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + var functionTool = ResponseTool.CreateFunctionTool( + functionName: "get_user_name", + functionParameters: BinaryData.FromString("{}"), + strictModeEnabled: false, + functionDescription: "Gets the user's name, as used for friendly address." + ); + + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definition.Tools.Add(functionTool); + + // Generate response with the declarative function + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(functionTool); + + using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.Agents.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that AsAIAgentAsync accepts declarative functions from definition. + /// + [Fact] + public async Task AsAIAgent_WithDeclarativeFunctionFromDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + using var testClient = CreateTestAgentClientWithHandler(); + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.Agents.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + /// + /// Verify that AsAIAgentAsync accepts declarative functions from definition. + /// + [Fact] + public async Task AsAIAgent_WithDeclarativeFunctionInDefinition_AcceptsDeclarativeFunctionAsync() + { + // Arrange + var definition = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + + // Create a declarative function (not invocable) using AIFunctionFactory.CreateDeclaration + using var doc = JsonDocument.Parse("{}"); + var declarativeFunction = AIFunctionFactory.CreateDeclaration("test_function", "A test function", doc.RootElement); + + // Add to definition + definition.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + // Generate response with the declarative function + var definitionResponse = new PromptAgentDefinition("test-model") { Instructions = "Test" }; + definitionResponse.Tools.Add(declarativeFunction.AsOpenAIResponseTool() ?? throw new InvalidOperationException()); + + using var testClient = CreateTestAgentClientWithHandler(agentName: "test-agent", agentDefinitionResponse: definitionResponse); + + var options = new AgentVersionCreationOptions(definition); + + // Act + var agentVersion = (await testClient.Client.Agents.CreateAgentVersionAsync("test-agent", options)).Value; + var agent = testClient.Client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region AgentName Validation Tests + + /// + /// Verify that AsAIAgent throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void AsAIAgent_ByName_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent(invalidName)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + /// + /// Verify that AsAIAgent with AgentReference throws ArgumentException when agent name is invalid. + /// + [Theory] + [MemberData(nameof(InvalidAgentNameTestData.GetInvalidAgentNames), MemberType = typeof(InvalidAgentNameTestData))] + public void AsAIAgent_WithAgentReference_WithInvalidAgentName_ThrowsArgumentException(string invalidName) + { + // Arrange + var mockClient = new Mock(); + var agentReference = new AgentReference(invalidName, "1"); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent(agentReference)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Agent name must be 1-63 characters long", exception.Message); + } + + #endregion + + #region AzureAIChatClient Behavior Tests + + /// + /// Verify that the underlying chat client created by extension methods can be wrapped with clientFactory. + /// + [Fact] + public void AsAIAgent_WithClientFactory_WrapsUnderlyingChatClient() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + int factoryCallCount = 0; + + // Act + var agent = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => + { + factoryCallCount++; + return new TestChatClient(innerClient); + }); + + // Assert + Assert.NotNull(agent); + Assert.Equal(1, factoryCallCount); + var wrappedClient = agent.GetService(); + Assert.NotNull(wrappedClient); + } + + /// + /// Verify that multiple clientFactory calls create independent wrapped clients. + /// + [Fact] + public void AsAIAgent_MultipleCallsWithClientFactory_CreatesIndependentClients() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent1 = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + var agent2 = client.AsAIAgent( + agentRecord, + clientFactory: (innerClient) => new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + var client1 = agent1.GetService(); + var client2 = agent2.GetService(); + Assert.NotNull(client1); + Assert.NotNull(client2); + Assert.NotSame(client1, client2); + } + + #endregion + + #region User-Agent Header Tests + + /// + /// Verifies that the MEAI user-agent header is added to Responses API POST requests + /// via the protocol method's RequestOptions pipeline policy. + /// + [Fact] + public async Task AsAIAgent_Rapi_UserAgentHeaderAddedToRequestsAsync() + { + bool userAgentFound = false; + using var httpHandler = new HttpHandlerAssert(request => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + // Verify MEAI user-agent header is present on Responses API POST request + if (request.Headers.TryGetValues("User-Agent", out var userAgentValues) + && userAgentValues.Any(v => v.Contains("MEAI"))) + { + userAgentFound = true; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agentOptions = new ChatClientAgentOptions + { + Name = "test-agent", + ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini" } + }; + + // Act + var agent = aiProjectClient.AsAIAgent(agentOptions); + + var response = await agent.RunAsync("Hello"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(response); + Assert.True(userAgentFound, "MEAI user-agent header was not found in any Responses API request"); + } + + /// + /// Verifies that the MEAI user-agent header is added to Responses API POST requests + /// when using a versioned agent created via CreateAgentVersionAsync. + /// + [Fact] + public async Task AsAIAgent_Versioned_UserAgentHeaderAddedToRequestsAsync() + { + bool userAgentFound = false; + using var httpHandler = new HttpHandlerAssert(request => + { + Assert.Equal("POST", request.Method.Method); + + if (request.RequestUri!.PathAndQuery.Contains("/responses")) + { + // Verify MEAI user-agent header is present on Responses API POST request + Assert.True(request.Headers.TryGetValues("User-Agent", out var userAgentValues)); + Assert.Contains(userAgentValues, v => v.Contains("MEAI")); + userAgentFound = true; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + // CreateAgentVersion POST — return agent version response + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var aiProjectClient = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agentVersion = (await aiProjectClient.Agents.CreateAgentVersionAsync("test-agent", new AgentVersionCreationOptions(new PromptAgentDefinition("test-model") { Instructions = "Test instructions" }))).Value; + + // Act + var agent = aiProjectClient.AsAIAgent(agentVersion); + + var response = await agent.RunAsync("Hello"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(response); + Assert.True(userAgentFound, "MEAI user-agent header was not found in any Responses API request"); + } + + #endregion + + #region GetAIAgent(AIProjectClient, AgentReference) Tests + + /// + /// Verify that AsAIAgent throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + var agentReference = new AgentReference("test-name", "1"); + + // Act & Assert + var exception = Assert.Throws(() => + client!.AsAIAgent(agentReference)); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that AsAIAgent throws ArgumentNullException when agentReference is null. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithNullAgentReference_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + var exception = Assert.Throws(() => + mockClient.Object.AsAIAgent((AgentReference)null!)); + + Assert.Equal("agentReference", exception.ParamName); + } + + /// + /// Verify that AsAIAgent with AgentReference creates a valid agent. + /// + [Fact] + public void AsAIAgent_WithAgentReference_CreatesValidAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.AsAIAgent(agentReference); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("test-name", agent.Name); + Assert.Equal("test-name:1", agent.Id); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that AsAIAgent with AgentReference and clientFactory applies the factory. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + TestChatClient? testChatClient = null; + + // Act + var agent = client.AsAIAgent( + agentReference, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that AsAIAgent with AgentReference sets the agent ID correctly. + /// + [Fact] + public void AsAIAgent_WithAgentReference_SetsAgentIdCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "2"); + + // Act + var agent = client.AsAIAgent(agentReference); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-name:2", agent.Id); + } + + /// + /// Verify that AsAIAgent with AgentReference and tools includes the tools in ChatOptions. + /// + [Fact] + public void AsAIAgent_WithAgentReference_WithTools_IncludesToolsInChatOptions() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + var tools = new List + { + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + }; + + // Act + var agent = client.AsAIAgent(agentReference, tools: tools); + + // Assert + Assert.NotNull(agent); + var chatOptions = GetAgentChatOptions(agent); + Assert.NotNull(chatOptions); + Assert.NotNull(chatOptions.Tools); + Assert.Single(chatOptions.Tools); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns AgentRecord for agents created from AgentRecord. + /// + [Fact] + public void GetService_WithAgentRecord_ReturnsAgentRecord() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + var retrievedRecord = agent.GetService(); + + // Assert + Assert.NotNull(retrievedRecord); + Assert.Equal(agentRecord.Id, retrievedRecord.Id); + } + + /// + /// Verify that GetService returns null for AgentRecord when agent is created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsNullForAgentRecord() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedRecord = agent.GetService(); + + // Assert + Assert.Null(retrievedRecord); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns AgentVersion for agents created from AgentVersion. + /// + [Fact] + public void GetService_WithAgentVersion_ReturnsAgentVersion() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + var retrievedVersion = agent.GetService(); + + // Assert + Assert.NotNull(retrievedVersion); + Assert.Equal(agentVersion.Id, retrievedVersion.Id); + } + + /// + /// Verify that GetService returns null for AgentVersion when agent is created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsNullForAgentVersion() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-name", "1"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedVersion = agent.GetService(); + + // Assert + Assert.Null(retrievedVersion); + } + + #endregion + + #region ChatClientMetadata Tests + + /// + /// Verify that ChatClientMetadata is properly populated for agents created from AgentRecord. + /// + [Fact] + public void ChatClientMetadata_WithAgentRecord_IsPopulatedCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(metadata.DefaultModelId); + } + + /// + /// Verify that ChatClientMetadata.DefaultModelId is set from PromptAgentDefinition model property. + /// + [Fact] + public void ChatClientMetadata_WithPromptAgentDefinition_SetsDefaultModelIdFromModel() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var definition = new PromptAgentDefinition("gpt-4-turbo") + { + Instructions = "Test instructions" + }; + AgentRecord agentRecord = this.CreateTestAgentRecord(definition); + + // Act + var agent = client.AsAIAgent(agentRecord); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + // The metadata should contain the model information from the agent definition + Assert.NotNull(metadata.DefaultModelId); + Assert.Equal("gpt-4-turbo", metadata.DefaultModelId); + } + + /// + /// Verify that ChatClientMetadata is properly populated for agents created from AgentVersion. + /// + [Fact] + public void ChatClientMetadata_WithAgentVersion_IsPopulatedCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + var metadata = agent.GetService(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(metadata.DefaultModelId); + Assert.Equal((agentVersion.Definition as PromptAgentDefinition)!.Model, metadata.DefaultModelId); + } + + #endregion + + #region AgentReference Availability Tests + + /// + /// Verify that GetService returns AgentReference for agents created from AgentReference. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("test-agent", "1.0"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal("test-agent", retrievedReference.Name); + Assert.Equal("1.0", retrievedReference.Version); + } + + /// + /// Verify that GetService returns null for AgentReference when agent is created from AgentRecord. + /// + [Fact] + public void GetService_WithAgentRecord_ReturnsAlsoAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentRecord agentRecord = this.CreateTestAgentRecord(); + + // Act + var agent = client.AsAIAgent(agentRecord); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal(agentRecord.Name, retrievedReference.Name); + } + + /// + /// Verify that GetService returns null for AgentReference when agent is created from AgentVersion. + /// + [Fact] + public void GetService_WithAgentVersion_ReturnsAlsoAgentReference() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = this.CreateTestAgentVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal(agentVersion.Name, retrievedReference.Name); + } + + /// + /// Verify that GetService returns AgentReference with correct version information. + /// + [Fact] + public void GetService_WithAgentReference_ReturnsCorrectVersionInformation() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + var agentReference = new AgentReference("versioned-agent", "3.5"); + + // Act + var agent = client.AsAIAgent(agentReference); + var retrievedReference = agent.GetService(); + + // Assert + Assert.NotNull(retrievedReference); + Assert.Equal("versioned-agent", retrievedReference.Name); + Assert.Equal("3.5", retrievedReference.Version); + } + + #endregion + + #region Empty Version and ID Handling Tests + + /// + /// Verify that AsAIAgent with AgentRecord handles empty version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentRecordEmptyVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); + AgentRecord agentRecord = this.CreateTestAgentRecordWithEmptyVersion(); + + // Act + var agent = client.AsAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + /// + /// Verify that AsAIAgent with AgentVersion handles empty version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentVersionEmptyVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion(); + AgentVersion agentVersion = this.CreateTestAgentVersionWithEmptyVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + /// + /// Verify that AsAIAgent with AgentRecord handles whitespace-only version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentRecordWhitespaceVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); + AgentRecord agentRecord = this.CreateTestAgentRecordWithWhitespaceVersion(); + + // Act + var agent = client.AsAIAgent(agentRecord); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent record name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + /// + /// Verify that AsAIAgent with AgentVersion handles whitespace-only version by using "latest" as fallback. + /// + [Fact] + public void AsAIAgent_WithAgentVersionWhitespaceVersion_CreatesAgentWithGeneratedId() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion(); + AgentVersion agentVersion = this.CreateTestAgentVersionWithWhitespaceVersion(); + + // Act + var agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + // Verify the agent ID is generated from agent version name ("agent_abc123") and "latest" + Assert.Equal("agent_abc123:latest", agent.Id); + } + + #endregion + + #region ApplyToolsToAgentDefinition Tests + + /// + /// Verify that when AsAIAgent is called without requireInvocableTools, hosted tools are correctly added. + /// + [Fact] + public void AsAIAgent_WithServerHostedTools_AddsToolsToAgentOptions() + { + // Arrange + PromptAgentDefinition definition = new("test-model") { Instructions = "Test" }; + definition.Tools.Add(new HostedWebSearchTool().GetService() ?? new HostedWebSearchTool().AsOpenAIResponseTool()); + + AIProjectClient client = this.CreateTestAgentClient(); + AgentVersion agentVersion = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson(agentDefinition: definition)))!; + + // Act - no tools provided, but requireInvocableTools is false when no tools param is passed + FoundryAgent agent = client.AsAIAgent(agentVersion); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a test AIProjectClient with fake behavior. + /// + private FakeAgentClient CreateTestAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse); + } + + /// + /// Creates a test AIProjectClient backed by an HTTP handler that returns canned responses. + /// Used for tests that exercise the protocol-method code path (CreateAgentVersion). + /// The returned client must be disposed to clean up the underlying HttpClient/handler. + /// + private static DisposableTestClient CreateTestAgentClientWithHandler(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + var responseJson = TestDataUtil.GetAgentVersionResponseJson(agentName, agentDefinitionResponse, instructions, description); + + var httpHandler = new HttpHandlerAssert(_ => + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson, Encoding.UTF8, "application/json") }); + +#pragma warning disable CA5399 + var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + return new DisposableTestClient(client, httpClient, httpHandler); + } + + /// + /// Wraps an AIProjectClient and its disposable dependencies for deterministic cleanup. + /// + private sealed class DisposableTestClient : IDisposable + { + private readonly HttpClient _httpClient; + private readonly HttpHandlerAssert _httpHandler; + + public DisposableTestClient(AIProjectClient client, HttpClient httpClient, HttpHandlerAssert httpHandler) + { + this.Client = client; + this._httpClient = httpClient; + this._httpHandler = httpHandler; + } + + public AIProjectClient Client { get; } + + public void Dispose() + { + this._httpClient.Dispose(); + this._httpHandler.Dispose(); + } + } + + /// + /// Creates a test AgentRecord for testing. + /// + private AgentRecord CreateTestAgentRecord(AgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!; + } + + /// + /// Creates a test AIProjectClient with empty version fields for testing hosted MCP agents. + /// + private FakeAgentClient CreateTestAgentClientWithEmptyVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, useEmptyVersion: true); + } + + /// + /// Creates a test AgentRecord with empty version for testing hosted MCP agents. + /// + private AgentRecord CreateTestAgentRecordWithEmptyVersion(AgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithEmptyVersion(agentDefinition: agentDefinition)))!; + } + + /// + /// Creates a test AgentVersion with empty version for testing hosted MCP agents. + /// + private AgentVersion CreateTestAgentVersionWithEmptyVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion()))!; + } + + /// + /// Creates a test AIProjectClient with whitespace-only version fields for testing hosted MCP agents. + /// + private FakeAgentClient CreateTestAgentClientWithWhitespaceVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null) + { + return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, versionMode: VersionMode.Whitespace); + } + + /// + /// Creates a test AgentRecord with whitespace-only version for testing hosted MCP agents. + /// + private AgentRecord CreateTestAgentRecordWithWhitespaceVersion(AgentDefinition? agentDefinition = null) + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(agentDefinition: agentDefinition)))!; + } + + /// + /// Creates a test AgentVersion with whitespace-only version for testing hosted MCP agents. + /// + private AgentVersion CreateTestAgentVersionWithWhitespaceVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion()))!; + } + + private const string OpenAPISpec = """ + { + "openapi": "3.0.3", + "info": { "title": "Tiny Test API", "version": "1.0.0" }, + "paths": { + "/ping": { + "get": { + "summary": "Health check", + "operationId": "getPing", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + "example": { "message": "pong" } + } + } + } + } + } + } + } + } + """; + + /// + /// Creates a test AgentVersion for testing. + /// + private AgentVersion CreateTestAgentVersion() + { + return ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; + } + + /// + /// Specifies the version mode for test data generation. + /// + private enum VersionMode + { + Normal, + Empty, + Whitespace + } + + /// + /// Fake AIProjectClient for testing. + /// + private sealed class FakeAgentClient : AIProjectClient + { + public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, bool useEmptyVersion = false, VersionMode versionMode = VersionMode.Normal) + { + // Handle backward compatibility with bool parameter + var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode; + this.Agents = new FakeAgentsClient(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode); + } + + public override ClientConnection GetConnection(string connectionId) + { + return new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None); + } + + public override AgentsClient Agents { get; } + + private sealed class FakeAgentsClient : AgentsClient + { + private readonly string? _agentName; + private readonly string? _instructions; + private readonly string? _description; + private readonly AgentDefinition? _agentDefinition; + private readonly VersionMode _versionMode; + + public FakeAgentsClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal) + { + this._agentName = agentName; + this._instructions = instructions; + this._description = description; + this._agentDefinition = agentDefinitionResponse; + this._versionMode = versionMode; + } + + private string GetAgentResponseJson() + { + return this._versionMode switch + { + VersionMode.Empty => TestDataUtil.GetAgentResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + VersionMode.Whitespace => TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + _ => TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) + }; + } + + private string GetAgentVersionResponseJson() + { + return this._versionMode switch + { + VersionMode.Empty => TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + VersionMode.Whitespace => TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description), + _ => TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description) + }; + } + + public override ClientResult GetAgent(string agentName, RequestOptions options) + { + var responseJson = this.GetAgentResponseJson(); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))); + } + + public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentResponseJson(); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); + } + + public override Task GetAgentAsync(string agentName, RequestOptions options) + { + var responseJson = this.GetAgentResponseJson(); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)))); + } + + public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentResponseJson(); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); + } + + public override ClientResult CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentVersionResponseJson(); + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)); + } + + public override Task> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, string? foundryFeatures = null, CancellationToken cancellationToken = default) + { + var responseJson = this.GetAgentVersionResponseJson(); + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200))); + } + } + } + + private static PromptAgentDefinition GeneratePromptDefinitionResponse(PromptAgentDefinition inputDefinition, List? tools) + { + var definitionResponse = new PromptAgentDefinition(inputDefinition.Model) { Instructions = inputDefinition.Instructions }; + if (tools is not null) + { + foreach (var tool in tools) + { + definitionResponse.Tools.Add(tool.GetService() ?? tool.AsOpenAIResponseTool()); + } + } + + return definitionResponse; + } + + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : DelegatingChatClient + { + public TestChatClient(IChatClient innerClient) : base(innerClient) + { + } + } + + /// + /// Mock pipeline response for testing ClientResult wrapping. + /// + private sealed class MockPipelineResponse : PipelineResponse + { + private readonly MockPipelineResponseHeaders _headers; + + public MockPipelineResponse(int status, BinaryData? content = null) + { + this.Status = status; + this.Content = content ?? BinaryData.Empty; + this._headers = new MockPipelineResponseHeaders(); + } + + public override int Status { get; } + + public override string ReasonPhrase => "OK"; + + public override Stream? ContentStream + { + get => null; + set { } + } + + public override BinaryData Content { get; } + + protected override PipelineResponseHeaders HeadersCore => this._headers; + + public override BinaryData BufferContent(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Buffering content is not supported for mock responses."); + + public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException("Buffering content asynchronously is not supported for mock responses."); + + public override void Dispose() + { + } + + private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders + { + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase) + { + { "Content-Type", "application/json" }, + { "x-ms-request-id", "test-request-id" } + }; + + public override bool TryGetValue(string name, out string? value) + { + return this._headers.TryGetValue(name, out value); + } + + public override bool TryGetValues(string name, out IEnumerable? values) + { + if (this._headers.TryGetValue(name, out var value)) + { + values = [value]; + return true; + } + + values = null; + return false; + } + + public override IEnumerator> GetEnumerator() + { + return this._headers.GetEnumerator(); + } + } + } + + #endregion + + /// + /// Helper method to access internal ChatOptions property via reflection. + /// + private static ChatOptions? GetAgentChatOptions(AIAgent agent) + { + ChatClientAgent? chatClientAgent = agent as ChatClientAgent ?? agent.GetService(); + if (chatClientAgent is null) + { + return null; + } + + var chatOptionsProperty = typeof(ChatClientAgent).GetProperty( + "ChatOptions", + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + return chatOptionsProperty?.GetValue(chatClientAgent) as ChatOptions; + } + + /// + /// Test schema for JSON response format tests. + /// +#pragma warning disable CA1812 // Avoid uninstantiated internal classes - used via reflection by AIJsonUtilities + private sealed class TestSchema + { + public string? Name { get; set; } + public int Value { get; set; } + } +#pragma warning restore CA1812 +#pragma warning restore CS0618 + +} + +/// +/// Provides test data for invalid agent name validation tests. +/// +internal static class InvalidAgentNameTestData +{ + /// + /// Gets a collection of invalid agent names for theory-based testing. + /// + /// Collection of invalid agent name test cases. + public static IEnumerable GetInvalidAgentNames() + { + yield return new object[] { "-agent" }; + yield return new object[] { "agent-" }; + yield return new object[] { "agent_name" }; + yield return new object[] { "agent name" }; + yield return new object[] { "agent@name" }; + yield return new object[] { "agent#name" }; + yield return new object[] { "agent$name" }; + yield return new object[] { "agent%name" }; + yield return new object[] { "agent&name" }; + yield return new object[] { "agent*name" }; + yield return new object[] { "agent.name" }; + yield return new object[] { "agent/name" }; + yield return new object[] { "agent\\name" }; + yield return new object[] { "agent:name" }; + yield return new object[] { "agent;name" }; + yield return new object[] { "agent,name" }; + yield return new object[] { "agentname" }; + yield return new object[] { "agent?name" }; + yield return new object[] { "agent!name" }; + yield return new object[] { "agent~name" }; + yield return new object[] { "agent`name" }; + yield return new object[] { "agent^name" }; + yield return new object[] { "agent|name" }; + yield return new object[] { "agent[name" }; + yield return new object[] { "agent]name" }; + yield return new object[] { "agent{name" }; + yield return new object[] { "agent}name" }; + yield return new object[] { "agent(name" }; + yield return new object[] { "agent)name" }; + yield return new object[] { "agent+name" }; + yield return new object[] { "agent=name" }; + yield return new object[] { "a" + new string('b', 63) }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs similarity index 82% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs index 8582ccc2b6..e3461d8191 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs @@ -6,33 +6,35 @@ using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; #pragma warning disable CS0618 -[Obsolete("Uses obsolete AIProjectClient.GetAIAgentAsync compatibility extensions while validating chat-client behavior.")] public class AzureAIProjectChatClientTests { /// - /// Verify that when the ChatOptions has a "conv_" prefixed conversation ID, the chat client uses conversation in the http requests via the chat client + /// Verify that after the first RunAsync, the session's ConversationId is set from the + /// response, and subsequent requests include that conversation ID automatically. /// [Fact] public async Task ChatClient_UsesDefaultConversationIdAsync() { // Arrange - var requestTriggered = false; + var responsesRequestCount = 0; using var httpHandler = new HttpHandlerAssert(async (request) => { if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) { - requestTriggered = true; + responsesRequestCount++; - // Assert - if (request.Content is not null) + // Assert: On the second Responses API call, verify the conversation ID + // from the first response is automatically included in the request body. + if (responsesRequestCount == 2 && request.Content is not null) { var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.Contains("conv_12345", requestBody); + Assert.Contains("resp_0888a", requestBody); } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; @@ -50,20 +52,17 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_12345" } - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); await agent.RunAsync("Hello", session); + await agent.RunAsync("Follow up", session); - Assert.True(requestTriggered); + // Assert + Assert.Equal(2, responsesRequestCount); var chatClientSession = Assert.IsType(session); - Assert.Equal("conv_12345", chatClientSession.ConversationId); + Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); } /// @@ -102,12 +101,7 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" }, - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); @@ -154,12 +148,7 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_should_not_use_default" } - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); @@ -206,12 +195,7 @@ public class AzureAIProjectChatClientTests new FakeAuthenticationTokenProvider(), new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await projectClient.GetAIAgentAsync( - new ChatClientAgentOptions - { - Name = "test-agent", - ChatOptions = new() { Instructions = "Test instructions" }, - }); + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); // Act var session = await agent.CreateSessionAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FakeAuthenticationTokenProvider.cs similarity index 95% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FakeAuthenticationTokenProvider.cs index d37ed881ff..594ed85af3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FakeAuthenticationTokenProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FakeAuthenticationTokenProvider.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider { diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 1b77e8f57e..1ddc8c7c82 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Azure.AI.Projects; using Microsoft.Extensions.AI; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Unit tests for the class. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HttpHandlerAssert.cs similarity index 96% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HttpHandlerAssert.cs index 3b8025ed9e..0febf216b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/HttpHandlerAssert.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HttpHandlerAssert.cs @@ -5,7 +5,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; internal sealed class HttpHandlerAssert : HttpClientHandler { diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs similarity index 98% rename from dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs index 226596a374..b1696d3162 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs @@ -2,7 +2,7 @@ using System; -namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory; /// /// Tests for constructor validation. diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs index 25c041f754..f1c4c75718 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; using Azure.AI.Projects; using Azure.Core; -namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory; /// /// Creates a testable AIProjectClient with a mock HTTP handler. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj similarity index 65% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 193a7d47da..7b85de0384 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -1,7 +1,12 @@ - + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs index d10ef861c9..fda9962e12 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -6,7 +6,7 @@ using Azure.AI.Extensions.OpenAI; using Microsoft.Extensions.AI; using OpenAI.Responses; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Unit tests for the class. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentResponse.json similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentResponse.json rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentResponse.json diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentVersionResponse.json similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/AgentVersionResponse.json rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/AgentVersionResponse.json diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/OpenAIDefaultResponse.json similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestData/OpenAIDefaultResponse.json rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/OpenAIDefaultResponse.json diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs index 9cd2ecea46..0a541f5562 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs @@ -4,7 +4,7 @@ using System.ClientModel.Primitives; using System.IO; using Azure.AI.Projects.Agents; -namespace Microsoft.Agents.AI.AzureAI.UnitTests; +namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Utility class for loading and processing test data files. diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj deleted file mode 100644 index af184142ca..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - True - True - - - - - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj deleted file mode 100644 index 1fe8dc57bd..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - false - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 37c0fa98cf..bc503de675 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -10,7 +10,7 @@ - +