diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 1b1c8066c6..2d12425312 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -60,9 +60,8 @@ jobs: environment: integration timeout-minutes: 60 env: - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} @@ -96,10 +95,10 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + 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.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -126,7 +125,9 @@ jobs: uv run pytest --import-mode=importlib packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/openai/tests/openai/test_openai_embedding_client_azure.py packages/azure-ai/tests/azure_openai + --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -202,15 +203,15 @@ jobs: timeout-minutes: 60 env: UV_PYTHON: "3.11" - 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 }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} 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 }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -248,17 +249,19 @@ jobs: --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - # Azure AI integration tests - python-tests-azure-ai: - name: Python Integration Tests - Azure AI + # Foundry integration tests + python-tests-foundry: + name: Python Integration Tests - Foundry runs-on: ubuntu-latest environment: integration timeout-minutes: 60 env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -282,9 +285,14 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: | - uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: > + uv run pytest --import-mode=importlib + packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py + packages/foundry/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 # Azure Cosmos integration tests python-tests-cosmos: @@ -341,7 +349,7 @@ jobs: python-tests-azure-openai, python-tests-misc-integration, python-tests-functions, - python-tests-azure-ai, + python-tests-foundry, python-tests-cosmos ] steps: diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index a46beb40cb..f32fceccb5 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -141,9 +141,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} @@ -195,10 +194,10 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + 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.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -223,7 +222,9 @@ jobs: uv run pytest --import-mode=importlib packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/openai/tests/openai/test_openai_embedding_client_azure.py packages/azure-ai/tests/azure_openai + --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -333,15 +334,15 @@ jobs: environment: integration env: UV_PYTHON: "3.11" - 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 }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} 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 }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -387,8 +388,8 @@ jobs: fail-on-empty: false title: Functions integration test results - python-tests-azure-ai: - name: Python Tests - Azure AI + python-tests-foundry: + name: Python Integration Tests - Foundry needs: paths-filter if: > github.event_name != 'pull_request' && @@ -401,8 +402,10 @@ jobs: env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -424,9 +427,14 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: | - uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: > + uv run pytest --import-mode=importlib + packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py + packages/foundry/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 working-directory: ./python - name: Test Azure AI samples timeout-minutes: 10 @@ -513,7 +521,7 @@ jobs: python-tests-azure-openai, python-tests-misc-integration, python-tests-functions, - python-tests-azure-ai, + python-tests-foundry, python-tests-cosmos, ] steps: diff --git a/docs/decisions/0020-foundry-agent-type-naming.md b/docs/decisions/0020-foundry-agent-type-naming.md new file mode 100644 index 0000000000..03d43f64ac --- /dev/null +++ b/docs/decisions/0020-foundry-agent-type-naming.md @@ -0,0 +1,125 @@ +--- +status: accepted +contact: rogerbarreto +date: 2026-03-06 +deciders: rogerbarreto, alliscode +consulted: "" +informed: "" +--- + +# Foundry agent surface stays centered on `ChatClientAgent` + +## Context + +The Microsoft Foundry integration exposes two distinct usage patterns: + +1. Direct Responses usage, where callers provide model, instructions, and tools at runtime. +2. Server-side versioned agents, where callers create and manage `AgentVersion` resources through `AIProjectClient.Agents`. + +We briefly explored adding public wrapper types such as `FoundryAgent`, `FoundryVersionedAgent`, and `FoundryResponsesChatClient` to make those paths feel more specialized. That direction created extra public types, duplicated existing `ChatClientAgent` behavior, and pushed samples toward compatibility helpers instead of the native Azure SDK flow. + +## Decision + +Keep the public surface centered on `ChatClientAgent`. + +- Direct Responses scenarios use `AIProjectClient.AsAIAgent(...)`. +- Server-side versioned scenarios use native `AIProjectClient.Agents` APIs to create or retrieve agent resources, then wrap `AgentRecord` or `AgentVersion` with `AIProjectClient.AsAIAgent(...)`. +- Compatibility helpers such as `AIProjectClient.CreateAIAgentAsync(...)` and `AIProjectClient.GetAIAgentAsync(...)` remain only as obsolete migration shims. +- Public wrapper types `FoundryAgent`, `FoundryVersionedAgent`, `FoundryResponsesChatClient`, and `FoundryResponsesChatClientAgent` are not part of the chosen direction. + +## Why + +- `ChatClientAgent` is already the framework abstraction used everywhere else. +- `AIProjectClient` is the native Azure SDK entry point for versioned agent lifecycle operations. +- A single agent abstraction avoids parallel type hierarchies for the same backend. +- Samples become clearer when they show either: + - direct Responses construction via `AIProjectClient.AsAIAgent(...)`, or + - native Foundry resource management via `AIProjectClient.Agents`. + +## Consequences + +### Direct Responses path + +Use the convenience overloads on `AIProjectClient`: + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); + +ChatClientAgent agent = aiProjectClient.AsAIAgent( + model: deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +Or use composed `ChatClientAgent` + +```csharp +ProjectResponsesClient projectResponsesClient = new(new Uri(endpoint), new DefaultAzureCredential(), new AgentReference($"model:{deploymentName}")); + +ChatClientAgent agent = new( + chatClient: projectResponsesClient.AsIChatClient(), + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +This path is code-first and does not create a persistent server-side agent. + +### Versioned agent path + +Use the convenience overloads on `AIProjectClient`: + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); + +AgentVersion version = await aiProjectClient.Agents.CreateAgentVersionAsync( + "JokerAgent", + new AgentVersionCreationOptions( + new PromptAgentDefinition(deploymentName) + { + Instructions = "You are good at telling jokes." + })); + +ChatClientAgent agent = aiProjectClient.AsAIAgent(version); +``` + +Or use composed `ChatClientAgent` + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); + +AgentVersion version = await aiProjectClient.Agents.CreateAgentVersionAsync( + "JokerAgent", + new AgentVersionCreationOptions( + new PromptAgentDefinition(deploymentName) + { + Instructions = "You are good at telling jokes." + })); + +ProjectResponsesClient projectResponsesClient = aiProjectClient + .GetProjectOpenAIClient() + .GetProjectResponsesClientForAgent(new AgentReference(version.Name, version.Version)); + +ChatClientAgent agent = new( + chatClient: projectResponsesClient.AsIChatClient(), + name: "JokerAgent"); +``` + +### Samples + +- `FoundryAgents/` samples show the direct Responses path with `AIProjectClient.AsAIAgent(...)`. +- `FoundryVersionedAgents/` samples should show native `AIProjectClient.Agents` create/get/delete flows plus `AsAIAgent(...)`. + +### Compatibility APIs + +Obsolete helper extensions remain only to ease migration of existing code. New samples and new guidance should not be written against them. + +## Rejected direction + +Do not introduce or preserve separate public wrapper types whose main purpose is to forward to `ChatClientAgent` while carrying Foundry-specific naming. + +That approach: + +- duplicates lifecycle concepts already present on `AIProjectClient`, +- fragments the public API, +- complicates samples and docs, +- and makes migration harder by encouraging wrapper-specific affordances. diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a4ffe13958..1d0178e615 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -1,4 +1,4 @@ - + @@ -104,7 +104,7 @@ - + @@ -121,6 +121,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -143,35 +171,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -318,8 +317,8 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs index 5d770ff3fd..0c3e75cf96 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs @@ -70,7 +70,7 @@ if (approvalRequest.AdditionalProperties != null) { - approvalResponse.AdditionalProperties = new AdditionalPropertiesDictionary(); + approvalResponse.AdditionalProperties = []; foreach (var kvp in approvalRequest.AdditionalProperties) { approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value; diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs index 866bbfad31..135d87bffd 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs @@ -131,9 +131,9 @@ private static List ProcessOutgoingServerFunctionApprovals( transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); approvalCalls.Remove(functionResult.CallId); } - else if (transformedContents != null) + else { - transformedContents.Add(content); + transformedContents?.Add(content); } } @@ -155,10 +155,10 @@ private static List ProcessOutgoingServerFunctionApprovals( result ??= CopyMessagesUpToIndex(messages, messageIndex); result.Add(newMessage); } - else if (result != null) + else { // We're already copying messages, so copy this unchanged message too - result.Add(message); + result?.Add(message); } // If result is null, we haven't made any changes yet, so keep processing } diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs index ff3e6ffbb1..8c1f27eea9 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs @@ -57,16 +57,10 @@ private static ToolApprovalRequestContent ConvertToolCallToApprovalRequest(Funct throw new InvalidOperationException("Invalid request_approval tool call"); } - var request = toolCall.Arguments.TryGetValue("request", out var reqObj) && + var request = (toolCall.Arguments.TryGetValue("request", out var reqObj) && reqObj is JsonElement argsElement && argsElement.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is ApprovalRequest approvalRequest && - approvalRequest != null ? approvalRequest : null; - - if (request == null) - { - throw new InvalidOperationException("Failed to deserialize approval request from tool call"); - } - + approvalRequest != null ? approvalRequest : null) ?? throw new InvalidOperationException("Failed to deserialize approval request from tool call"); return new ToolApprovalRequestContent( requestId: request.ApprovalId, new FunctionCallContent( @@ -77,17 +71,11 @@ reqObj is JsonElement argsElement && private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions) { - var approvalResponse = result.Result is JsonElement je ? + var approvalResponse = (result.Result is JsonElement je ? (ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : result.Result is string str ? (ApprovalResponse?)JsonSerializer.Deserialize(str, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : - result.Result as ApprovalResponse; - - if (approvalResponse == null) - { - throw new InvalidOperationException("Failed to deserialize approval response from tool result"); - } - + result.Result as ApprovalResponse) ?? throw new InvalidOperationException("Failed to deserialize approval response from tool result"); return approval.CreateResponse(approvalResponse.Approved); } #pragma warning restore MEAI001 @@ -121,7 +109,7 @@ private static List ProcessIncomingFunctionApprovals( // Track approval ID to original call ID mapping _ = new Dictionary(); #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - Dictionary trackedRequestApprovalToolCalls = new(); // Remote approvals + Dictionary trackedRequestApprovalToolCalls = []; // Remote approvals for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++) { var message = messages[messageIndex]; @@ -146,7 +134,7 @@ private static List ProcessIncomingFunctionApprovals( }); } else if (content is FunctionResultContent toolResult && - trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval) == true) + trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval)) { result ??= CopyMessagesUpToIndex(messages, messageIndex); transformedContents ??= CopyContentsUpToIndex(message.Contents, j); @@ -161,9 +149,9 @@ private static List ProcessIncomingFunctionApprovals( AdditionalProperties = message.AdditionalProperties }); } - else if (result != null) + else { - result.Add(message); + result?.Add(message); } } } diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs index 41c94d5686..8a8062befe 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs @@ -72,10 +72,9 @@ protected override async IAsyncEnumerable RunCoreStreamingA if (content is DataContent dataContent && dataContent.MediaType == "application/json") { // Deserialize the state - TState? newState = JsonSerializer.Deserialize( + if (JsonSerializer.Deserialize( dataContent.Data.Span, - this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) as TState; - if (newState != null) + this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) is TState newState) { this.State = newState; } diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 69d71e7b88..8e1f4245b6 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -18,6 +18,7 @@ #region Setup Telemetry +// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories. const string SourceName = "OpenTelemetryAspire.ConsoleApp"; const string ServiceName = "AgentOpenTelemetry"; @@ -40,7 +41,6 @@ var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) .AddSource(SourceName) // Our custom activity source - .AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry .AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)); @@ -54,8 +54,7 @@ // Setup metrics with resource and instrument name filtering using var meterProvider = Sdk.CreateMeterProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) - .AddMeter(SourceName) // Our custom meter - .AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics + .AddMeter(SourceName) // Our custom meter source .AddHttpClientInstrumentation() // HTTP client metrics .AddRuntimeInstrumentation() // .NET runtime metrics .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)) @@ -128,7 +127,7 @@ static async Task GetWeatherAsync([Description("The location to get the instructions: "You are a helpful assistant that provides concise and informative responses.", tools: [AIFunctionFactory.Create(GetWeatherAsync)]) .AsBuilder() - .UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level + .UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level .Build(); var session = await agent.CreateSessionAsync(); 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 aab95d5b38..b2cd14f68a 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -6,6 +6,7 @@ using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; 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"; @@ -30,14 +31,18 @@ // agentVersion.Name = // You can use an AIAgent with an already created server side agent version. -AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); +FoundryAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); // You can also create another AIAgent version by providing the same name with a different definition. -AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes."); +AgentVersion newJokerAgentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + JokerName, + new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are extremely hilarious at telling jokes." })); +FoundryAgent newJokerAgent = aiProjectClient.AsAIAgent(newJokerAgentVersion); // You can also get the AIAgent latest version just providing its name. -AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName); -var latestAgentVersion = jokerAgentLatest.GetService()!; +AgentRecord jokerAgentRecord = await aiProjectClient.Agents.GetAgentAsync(JokerName); +FoundryAgent jokerAgentLatest = aiProjectClient.AsAIAgent(jokerAgentRecord); +AgentVersion latestAgentVersion = jokerAgentRecord.GetLatestVersion(); // The AIAgent version can be accessed via the GetService method. Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs deleted file mode 100644 index 9b0a4b4f99..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Agent Skills with a ChatClientAgent. -// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. -// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. -// -// This sample includes the expense-report skill: -// - Policy-based expense filing with references and assets - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -// --- Configuration --- -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// --- Skills Provider --- -// Discovers skills from the 'skills' directory and makes them available to the agent -var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); - -// --- Agent Setup --- -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient() - .AsAIAgent(new ChatClientAgentOptions - { - Name = "SkillsAgent", - ChatOptions = new() - { - Instructions = "You are a helpful assistant.", - }, - AIContextProviders = [skillsProvider], - }, - model: deploymentName); - -// --- Example 1: Expense policy question (loads FAQ resource) --- -Console.WriteLine("Example 1: Checking expense policy FAQ"); -Console.WriteLine("---------------------------------------"); -AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); -Console.WriteLine($"Agent: {response1.Text}\n"); - -// --- Example 2: Filing an expense report (multi-turn with template asset) --- -Console.WriteLine("Example 2: Filing an expense report"); -Console.WriteLine("---------------------------------------"); -AgentSession session = await agent.CreateSessionAsync(); -AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", - session); -Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md deleted file mode 100644 index 78099fa8a5..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Agent Skills Sample - -This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. - -## What are Agent Skills? - -Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: - -1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) -2. **Load**: Full instructions are loaded on-demand via `load_skill` tool -3. **Resources**: References and other files loaded via `read_skill_resource` tool - -## Skills Included - -### expense-report -Policy-based expense filing with spending limits, receipt requirements, and approval workflows. -- `references/POLICY_FAQ.md` — Detailed expense policy Q&A -- `assets/expense-report-template.md` — Submission template - -## Project Structure - -``` -Agent_Step01_BasicSkills/ -├── Program.cs -├── Agent_Step01_BasicSkills.csproj -└── skills/ - └── expense-report/ - ├── SKILL.md - ├── references/ - │ └── POLICY_FAQ.md - └── assets/ - └── expense-report-template.md -``` - -## Running the Sample - -### Prerequisites -- .NET 10.0 SDK -- Azure OpenAI endpoint with a deployed model - -### Setup -1. Set environment variables: - ```bash - export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" - export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - ``` - -2. Run the sample: - ```bash - dotnet run - ``` - -### Examples - -The sample runs two examples: - -1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource -2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset - -## Learn More - -- [Agent Skills Specification](https://agentskills.io/) -- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md deleted file mode 100644 index fc6c83cf30..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: expense-report -description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. -metadata: - author: contoso-finance - version: "2.1" ---- - -# Expense Report - -## Categories and Limits - -| Category | Limit | Receipt | Approval | -|---|---|---|---| -| Meals — solo | $50/day | >$25 | No | -| Meals — team/client | $75/person | Always | Manager if >$200 total | -| Lodging | $250/night | Always | Manager if >3 nights | -| Ground transport | $100/day | >$15 | No | -| Airfare | Economy | Always | Manager; VP if >$1,500 | -| Conference/training | $2,000/event | Always | Manager + L&D | -| Office supplies | $100 | Yes | No | -| Software/subscriptions | $50/month | Yes | Manager if >$200/year | - -## Filing Process - -1. Collect receipts — must show vendor, date, amount, payment method. -2. Categorize per table above. -3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). -4. For client/team meals: list attendee names and business purpose. -5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. -6. Reimbursement: 10 business days via direct deposit. - -## Policy Rules - -- Submit within 30 days of transaction. -- Alcohol is never reimbursable. -- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. -- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. -- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. -- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md deleted file mode 100644 index 3f7c7dc36c..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md +++ /dev/null @@ -1,5 +0,0 @@ -# Expense Report Template - -| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | -|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| -| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md deleted file mode 100644 index 8e971192f8..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md +++ /dev/null @@ -1,55 +0,0 @@ -# Expense Policy — Frequently Asked Questions - -## Meals - -**Q: Can I expense coffee or snacks during the workday?** -A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. - -**Q: What if a team dinner exceeds the per-person limit?** -A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. - -**Q: Do I need to list every attendee?** -A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. - -## Travel - -**Q: Can I book a premium economy or business class flight?** -A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. - -**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** -A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. - -**Q: Are tips reimbursable?** -A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. - -## Lodging - -**Q: What if the $250/night limit isn't enough for the city I'm visiting?** -A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. - -**Q: Can I stay with friends/family instead and get a per-diem?** -A: No. Contoso reimburses actual lodging costs only, not per-diems. - -## Subscriptions and Software - -**Q: Can I expense a personal productivity tool?** -A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. - -**Q: What about annual subscriptions?** -A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. - -## Receipts and Documentation - -**Q: My receipt is faded/damaged. What do I do?** -A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. - -**Q: Do I need a receipt for parking meters or tolls?** -A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. - -## Approval and Reimbursement - -**Q: My manager is on leave. Who approves my report?** -A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. - -**Q: Can I submit expenses from a previous quarter?** -A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj similarity index 86% rename from dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj rename to dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj index 2a503bbfb2..7e7e9ef0fa 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs new file mode 100644 index 0000000000..b787bb86a3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent. +// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern: +// 1. Advertise — skill names and descriptions in the system prompt +// 2. Load — full instructions loaded on demand via load_skill tool +// 3. Read resources — reference files read via read_skill_resource tool +// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor +// +// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory containing SKILL.md files. +// The script runner runs file-based scripts (e.g. Python) as local subprocesses. +var skillsProvider = new AgentSkillsProvider( + Path.Combine(AppContext.BaseDirectory, "skills"), + SubprocessScriptRunner.RunAsync); +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with file-based skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md new file mode 100644 index 0000000000..41b813b98f --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -0,0 +1,51 @@ +# File-Based Agent Skills Sample + +This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`. + +## What it demonstrates + +- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` +- The progressive disclosure pattern: advertise → load → read resources → run scripts +- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor +- Running file-based scripts (Python) via a subprocess-based executor + +## Skills Included + +### unit-converter + +Converts between common units (miles↔km, pounds↔kg) using a multiplication factor. + +- `references/conversion-table.md` — Conversion factor table +- `scripts/convert.py` — Python script that performs the conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model +- Python 3 installed and available as `python3` on your PATH + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with file-based skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md new file mode 100644 index 0000000000..6a8e692ff2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md @@ -0,0 +1,11 @@ +--- +name: unit-converter +description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms. +--- + +## Usage + +When the user requests a unit conversion: +1. First, review `references/conversion-table.md` to find the correct factor +2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`) +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md new file mode 100644 index 0000000000..7a0160b854 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md @@ -0,0 +1,10 @@ +# Conversion Tables + +Formula: **result = value × factor** + +| From | To | Factor | +|-------------|-------------|----------| +| miles | kilometers | 1.60934 | +| kilometers | miles | 0.621371 | +| pounds | kilograms | 0.453592 | +| kilograms | pounds | 2.20462 | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py new file mode 100644 index 0000000000..228c8809ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py @@ -0,0 +1,29 @@ +# Unit conversion script +# Converts a value using a multiplication factor: result = value × factor +# +# Usage: +# python scripts/convert.py --value 26.2 --factor 1.60934 +# python scripts/convert.py --value 75 --factor 2.20462 + +import argparse +import json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert a value using a multiplication factor.", + epilog="Examples:\n" + " python scripts/convert.py --value 26.2 --factor 1.60934\n" + " python scripts/convert.py --value 75 --factor 2.20462", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") + parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") + args = parser.parse_args() + + result = round(args.value * args.factor, 4) + print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) + + +if __name__ == "__main__": + main() diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 8488ec9eed..75b850f077 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -4,4 +4,4 @@ Samples demonstrating Agent Skills capabilities. | Sample | Description | |--------|-------------| -| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | +| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs new file mode 100644 index 0000000000..e95bde61df --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample subprocess-based skill script runner. +// Executes file-based skill scripts as local subprocesses. +// This is provided for demonstration purposes only. + +using System.Diagnostics; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +/// +/// This runner uses the script's absolute path, converts the arguments +/// to CLI flags, and returns captured output. It is intended for +/// demonstration purposes only. +/// +internal static class SubprocessScriptRunner +{ + /// + /// Runs a skill script as a local subprocess. + /// + public static async Task RunAsync( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + if (!File.Exists(script.FullPath)) + { + return $"Error: Script file not found: {script.FullPath}"; + } + + string extension = Path.GetExtension(script.FullPath); + string? interpreter = extension switch + { + ".py" => "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is not null) + { + foreach (var (key, value) in arguments) + { + if (value is bool boolValue) + { + if (boolValue) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + } + } + else if (value is not null) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + startInfo.ArgumentList.Add(value.ToString()!); + } + } + } + + Process? process = null; + try + { + process = Process.Start(startInfo); + if (process is null) + { + return $"Error: Failed to start process for script '{script.Name}'."; + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + output += $"\nScript exited with code {process.ExitCode}"; + } + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Kill the process on cancellation to avoid leaving orphaned subprocesses. + process?.Kill(entireProcessTree: true); + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; + } + finally + { + process?.Dispose(); + } + } + + /// + /// Normalizes a parameter key to a consistent --flag format. + /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). + /// + private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); +} diff --git a/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs b/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs index 3d9c715588..04df345cd6 100644 --- a/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs +++ b/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs @@ -5,20 +5,13 @@ using Anthropic; using Anthropic.Core; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is not set."); var model = Environment.GetEnvironmentVariable("ANTHROPIC_CHAT_MODEL_NAME") ?? "claude-haiku-4-5"; -AIAgent agent = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) +AIAgent agent = + new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. -var response = await agent.RunAsync("Tell me a joke about a pirate."); -Console.WriteLine(response); - -// Invoke the agent with streaming support. -await foreach (var update in agent.RunStreamingAsync("Tell me a joke about a pirate.")) -{ - Console.WriteLine(update); -} +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); 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 914eda330a..d6410d3308 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -11,6 +11,7 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Agents.AI.FoundryMemory; string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); @@ -19,6 +20,9 @@ string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; // Create an AIProjectClient for Foundry with Azure Identity authentication. +// 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. DefaultAzureCredential credential = new(); AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); @@ -33,11 +37,15 @@ memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123"))); -AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName, - options: new ChatClientAgentOptions() +FoundryAgent agent = projectClient.AsAIAgent( + new ChatClientAgentOptions() { Name = "TravelAssistantWithFoundryMemory", - ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." }, + ChatOptions = new() + { + ModelId = deploymentName, + Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." + }, AIContextProviders = [memoryProvider] }); diff --git a/dotnet/samples/02-agents/AgentWithMemory/README.md b/dotnet/samples/02-agents/AgentWithMemory/README.md index 87818c77d6..aa95012f68 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/README.md @@ -1,4 +1,4 @@ -# Agent Framework Retrieval Augmented Generation (RAG) +# Agent Framework Retrieval Augmented Generation (RAG) These samples show how to create an agent with the Agent Framework that uses Memory to remember previous conversations or facts from previous conversations. @@ -10,4 +10,4 @@ These samples show how to create an agent with the Agent Framework that uses Mem |[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.| |[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](../FoundryAgents/FoundryAgents_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 Azure Foundry agents. diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs index e2bd31055a..e82f420105 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs @@ -4,28 +4,14 @@ using System.ClientModel; using Microsoft.Agents.AI; -using OpenAI; -using OpenAI.Chat; +using OpenAI.Responses; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; -AIAgent agent = new OpenAIClient(apiKey) - .GetChatClient(model) - .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); +AIAgent agent = + new ResponsesClient(new ApiKeyCredential(apiKey)) + .AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker"); -UserChatMessage chatMessage = new("Tell me a joke about a pirate."); - -// Invoke the agent and output the text result. -ChatCompletion chatCompletion = await agent.RunAsync([chatMessage]); -Console.WriteLine(chatCompletion.Content.Last().Text); - -// Invoke the agent with streaming support. -AsyncCollectionResult completionUpdates = agent.RunStreamingAsync([chatMessage]); -await foreach (StreamingChatCompletionUpdate completionUpdate in completionUpdates) -{ - if (completionUpdate.ContentUpdate.Count > 0) - { - Console.WriteLine(completionUpdate.ContentUpdate[0].Text); - } -} +// Once you have the agent, you can invoke it like any other AIAgent. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs index 603f8b8e7b..a8dc73839a 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs @@ -73,16 +73,28 @@ using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString()); foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray()) { + // Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property + if (!element.TryGetProperty("role"u8, out var roleElement)) + { + continue; + } + string messageId = element.GetProperty("id"u8).ToString(); - string messageRole = element.GetProperty("role"u8).ToString(); + string messageRole = roleElement.ToString(); Console.WriteLine($" Message ID: {messageId}"); Console.WriteLine($" Message Role: {messageRole}"); - foreach (var content in element.GetProperty("content").EnumerateArray()) + if (element.TryGetProperty("content"u8, out var contentElement)) { - string messageContentText = content.GetProperty("text"u8).ToString(); - Console.WriteLine($" Message Text: {messageContentText}"); + foreach (var content in contentElement.EnumerateArray()) + { + if (content.TryGetProperty("text"u8, out var textElement)) + { + Console.WriteLine($" Message Text: {textElement}"); + } + } } + Console.WriteLine(); } } 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 c356bccbd9..e049618a72 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -4,11 +4,13 @@ using System.ClientModel; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; +using Microsoft.Agents.AI.AzureAI; using OpenAI; using OpenAI.Files; +using OpenAI.Responses; using OpenAI.VectorStores; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); @@ -37,14 +39,20 @@ FileIds = { uploadResult.Value.Id } }); -var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreCreate.Value.Id)] }; +// Use the native OpenAI SDK FileSearchTool directly with the vector store ID. +#pragma warning disable OPENAI001 +FileSearchTool fileSearchTool = new([vectorStoreCreate.Value.Id]); +#pragma warning restore OPENAI001 -AIAgent agent = await aiProjectClient - .CreateAIAgentAsync( - model: deploymentName, - name: "AskContoso", - instructions: "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - tools: [fileSearchTool]); +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + "AskContoso", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + Tools = { fileSearchTool } + })); +FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs index 7bc6478968..a63063b6a5 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs @@ -3,6 +3,7 @@ // This sample shows how to expose an AI agent as an MCP tool. using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; @@ -18,11 +19,17 @@ var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // Create a server side agent and expose it as an AIAgent. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - instructions: "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", - name: "Joker", - description: "An agent that tells jokes."); +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + "Joker", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", + }) + { + Description = "An agent that tells jokes.", + }); +AIAgent agent = aiProjectClient.AsAIAgent(agentVersion); // Convert the agent to an AIFunction and then to an MCP tool. // The agent name and description will be used as the mcp tool name and description. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj index 73a41005f1..2b01c47354 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj @@ -16,5 +16,11 @@ + + + + Always + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Assets/walkway.jpg b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Assets/walkway.jpg rename to dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs index 984a9e3b5c..08e5b63139 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs @@ -22,7 +22,7 @@ ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg") + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); var session = await agent.CreateSessionAsync(); 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 18969ed66e..bab09bc886 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs @@ -189,9 +189,9 @@ static string FilterPii(string content) // Regex patterns for PII detection (simplified for demonstration) Regex[] piiPatterns = [ - new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) - new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address - new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + MyRegex(), // Phone number (e.g., 123-456-7890) + EmailRegex(), // Email address + FullNameRegex() // Full name (e.g., John Doe) ]; foreach (var pattern in piiPatterns) @@ -309,3 +309,15 @@ protected override ValueTask> ProvideMessagesAsync( ]); } } + +internal partial class Program +{ + [GeneratedRegex(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled)] + private static partial Regex MyRegex(); + + [GeneratedRegex(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled)] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled)] + private static partial Regex FullNameRegex(); +} 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 7a76f73455..11d3f561f4 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -17,10 +17,10 @@ PersistentAgentsAdministrationClientOptions persistentAgentsClientOptions = new(); persistentAgentsClientOptions.Retry.NetworkTimeout = TimeSpan.FromMinutes(20); -// Get a client to create/retrieve server side agents with. // 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. +// Get a client to create/retrieve server side agents with. PersistentAgentsClient persistentAgentsClient = new(endpoint, new DefaultAzureCredential(), persistentAgentsClientOptions); // Define and configure the Deep Research tool. 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 dc24ba4554..1848b10826 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md @@ -23,12 +23,14 @@ Before running this sample, ensure you have: Pay special attention to the purple `Note` boxes in the Azure documentation. -**Note**: The Bing Connection ID must be from the **project**, not the resource. It has the following format: +**Note**: The Bing Grounding Connection ID must be the **full ARM resource URI** from the project, not just the connection name. It has the following format: ``` -/subscriptions//resourceGroups//providers//accounts//projects//connections/ +/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). + ## Environment Variables Set the following environment variables: @@ -37,8 +39,8 @@ Set the following environment variables: # Replace with your Azure AI Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" -# Replace with your Bing connection ID from the project -$env:AZURE_AI_BING_CONNECTION_ID="/subscriptions/.../connections/your-bing-connection" +# Replace with your Bing Grounding connection ID (full ARM resource URI) +$env:AZURE_AI_BING_CONNECTION_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" # Optional, defaults to o3-deep-research $env:AZURE_AI_REASONING_DEPLOYMENT_NAME="o3-deep-research" diff --git a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs index e3913c9f0e..0fd21833e1 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs @@ -24,12 +24,12 @@ Func> loadNextThreeCalendarEvents = async () => { // In a real implementation, this method would connect to a calendar service - return new string[] - { + return + [ "Doctor's appointment today at 15:00", "Team meeting today at 17:00", "Birthday party today at 20:00" - }; + ]; }; // Create an agent with an AI context provider attached that aggregates two other providers: @@ -87,7 +87,7 @@ namespace SampleApp internal sealed class TodoListAIContextProvider : AIContextProvider { private static List GetTodoItems(AgentSession? session) - => session?.StateBag.GetValue>(nameof(TodoListAIContextProvider)) ?? new List(); + => session?.StateBag.GetValue>(nameof(TodoListAIContextProvider)) ?? []; private static void SetTodoItems(AgentSession? session, List items) => session?.StateBag.SetValue(nameof(TodoListAIContextProvider), items); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj similarity index 92% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj index daf7e24494..d861331d9f 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs new file mode 100644 index 0000000000..c6b2d5c764 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -0,0 +1,36 @@ +// 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: +// create agent version -> wrap as FoundryAgent -> run -> delete. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +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"; + +const string JokerName = "JokerAgent"; + +// Create the AIProjectClient to manage server-side agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Create a server-side agent version using the native SDK. +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + JokerName, + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = "You are good at telling jokes.", + })); + +// Wrap the agent version as a FoundryAgent using the AsAIAgent extension. +FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); + +// Once you have the agent, you can invoke it like any other AIAgent. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); + +// Cleanup: deletes the agent and all its versions. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md new file mode 100644 index 0000000000..738a1d2e42 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md @@ -0,0 +1,23 @@ +# Agent Step 00 - FoundryAgent Lifecycle + +This sample demonstrates the full lifecycle of a `FoundryAgent` backed by a server-side versioned agent in Microsoft Foundry: create → run → delete. + +## Prerequisites + +- A Microsoft Foundry project endpoint +- A model deployment name (defaults to `gpt-4o-mini`) +- Azure CLI installed and authenticated + +## Environment Variables + +| Variable | Description | Required | +| --- | --- | --- | +| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | Yes | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name | No (defaults to `gpt-4o-mini`) | + +## Running the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step00_FoundryAgentLifecycle +``` 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs new file mode 100644 index 0000000000..cd89116db7 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...). + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; + +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"; + +// 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 AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(model: deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); + +// Once you have the agent, you can invoke it like any other AIAgent. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md new file mode 100644 index 0000000000..88eebb2a82 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md @@ -0,0 +1,55 @@ +# Creating and Running a Basic Agent with the Responses API + +This sample demonstrates how to create and run a basic AI agent using the `ChatClientAgent`, which uses the Microsoft Foundry Responses API directly without creating server-side agent definitions. + +## What this sample demonstrates + +- Creating a `ChatClientAgent` with instructions and a model +- Running a simple single-turn conversation +- No server-side agent creation or cleanup required + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- 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 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" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step01_Basics +``` + +## Alternative: Composable approach + +You can also create the same agent by composing the underlying `IChatClient` directly. This gives you full control over the chat client pipeline: + +```csharp +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AIAgent agent = new ChatClientAgent( + chatClient: aiProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient().AsIChatClient(deploymentName), + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +This approach is useful when you need to customize the chat client pipeline or swap providers (e.g., Anthropic, OpenAI) while keeping the same agent code. diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj similarity index 70% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj index d77c0bb0d3..5e73fd236a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj @@ -9,8 +9,7 @@ - - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs new file mode 100644 index 0000000000..15b880102f --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create a multi-turn conversation agent using sessions. +// Context is preserved across multiple runs via response ID chaining in the session. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; + +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"; + +// 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 AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); + +// Create a session to maintain context across multiple runs. +AgentSession session = await agent.CreateSessionAsync(); + +// First turn +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); + +// Second turn — the agent remembers the first turn via the 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_Step02.1_MultiturnConversation/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md new file mode 100644 index 0000000000..a895981952 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md @@ -0,0 +1,36 @@ +# Multi-turn Conversation + +This sample demonstrates how to implement multi-turn conversations where context is preserved across multiple agent runs using sessions and response ID chaining. + +## What this sample demonstrates + +- Creating an agent with instructions +- Using sessions to maintain conversation context across multiple runs +- Response ID chaining for multi-turn conversations +- No server-side conversation creation required + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- 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 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" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step02.1_MultiturnConversation +``` 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + 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 new file mode 100644 index 0000000000..7a66555c18 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use server-side conversations with a FoundryAgent. +// 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.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"; + +// 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()) + .AsAIAgent(deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); + +// 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(); + +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)); + +// Streaming with server-side conversation context. +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("Tell me another joke, but about a ninja this time.", session)) +{ + Console.Write(update); +} + +Console.WriteLine(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md new file mode 100644 index 0000000000..9d35133b39 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md @@ -0,0 +1,36 @@ +# Multi-turn Conversation with Server-Side Conversations + +This sample demonstrates how to use server-side conversations with a `FoundryAgent`. Server-side conversations persist on the Foundry service and are visible in the Foundry Project UI, making them ideal when you need conversation history to be stored and accessible server-side. + +## What this sample demonstrates + +- Creating a `FoundryAgent` with instructions +- Using `CreateConversationSessionAsync` to create a server-side `ProjectConversation` +- Multi-turn conversations with both text and streaming output +- Server-side conversation persistence visible in the Foundry Project UI + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- 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 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" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step02.2_MultiturnWithServerConversations +``` 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs new file mode 100644 index 0000000000..7935835a24 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use function tools. + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +// Define the function tool. +AITool tool = AIFunctionFactory.Create(GetWeather); + +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"; + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a AIAgent with function tools. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant that can get weather information.", + name: "WeatherAssistant", + tools: [tool]); + +// Non-streaming agent interaction with function tools. +AgentSession session = await agent.CreateSessionAsync(); +Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", session)); + +// Streaming agent interaction with function tools. +session = await agent.CreateSessionAsync(); +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is the weather like in Amsterdam?", session)) +{ + Console.Write(update); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md new file mode 100644 index 0000000000..9aa2a4ee69 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md @@ -0,0 +1,37 @@ +# Using Function Tools with the Responses API + +This sample demonstrates how to use function tools with the `ChatClientAgent`, allowing the agent to call custom functions to retrieve information. + +## What this sample demonstrates + +- Creating function tools using `AIFunctionFactory` +- Passing function tools to a `ChatClientAgent` +- Running agents with function tools (text output) +- Running agents with function tools (streaming output) +- No server-side agent creation or cleanup required + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- 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 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" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step03_UsingFunctionTools +``` 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs similarity index 69% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs index 08051a500e..9a85dba83b 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -1,9 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates how to use an agent with function tools that require a human in the loop for approvals. -// It shows both non-streaming and streaming agent interactions using weather-related tools. -// If the agent is hosted in a service, with a remote user, combine this sample with the Persisted Conversations sample to persist the chat history -// while the agent is waiting for user input. using System.ComponentModel; using Azure.AI.Projects; @@ -11,18 +8,13 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -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"; - -// Create a sample function tool that the agent can use. [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; -const string AssistantInstructions = "You are a helpful assistant that can get weather information."; -const string AssistantName = "WeatherAssistant"; +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"; -// Get a client to create/retrieve/delete server side agents with Azure 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. @@ -30,16 +22,16 @@ static string GetWeather([Description("The location to get the weather for.")] s ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather))); -// Create AIAgent directly -AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [approvalTool]); +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant that can get weather information.", + name: "WeatherAssistant", + tools: [approvalTool]); // Call the agent with approval-required function tools. -// The agent will request approval before invoking the function. AgentSession session = await agent.CreateSessionAsync(); AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session); // Check if there are any approval requests. -// For simplicity, we are assuming here that only function approvals are pending. List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) @@ -53,13 +45,8 @@ static string GetWeather([Description("The location to get the weather for.")] s return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); }); - // Pass the user input responses back to the agent for further processing. response = await agent.RunAsync(userInputMessages, session); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } Console.WriteLine($"\nAgent: {response}"); - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md new file mode 100644 index 0000000000..430c57548d --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md @@ -0,0 +1,30 @@ +# Using Function Tools with Approvals via the Responses API + +This sample demonstrates how to use function tools that require human-in-the-loop approval before execution. + +## What this sample demonstrates + +- Creating function tools that require approval using `ApprovalRequiredAIFunction` +- Handling approval requests from the agent +- Passing approval responses back to the agent +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step04_UsingFunctionToolsWithApprovals +``` 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs similarity index 53% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs index 3c02a4cec2..28cd4cf6bc 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs @@ -15,29 +15,23 @@ 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"; -const string AssistantInstructions = "You are a helpful assistant that extracts structured information about people."; -const string AssistantName = "StructuredOutputAssistant"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create ChatClientAgent directly -ChatClientAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - new ChatClientAgentOptions() +AIAgent agent = aiProjectClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "StructuredOutputAssistant", + ChatOptions = new() { - Name = AssistantName, - ChatOptions = new() - { - Instructions = AssistantInstructions, - ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() - } - }); + ModelId = deploymentName, + Instructions = "You are a helpful assistant that extracts structured information about people.", + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() + } +}); -// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input. +// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output. AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); // Access the structured output via the Result property of the agent response. @@ -46,39 +40,21 @@ Console.WriteLine($"Age: {response.Result.Age}"); Console.WriteLine($"Occupation: {response.Result.Occupation}"); -// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce. -ChatClientAgent agentWithPersonInfo = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - new ChatClientAgentOptions() - { - Name = AssistantName, - ChatOptions = new() - { - Instructions = AssistantInstructions, - ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() - } - }); +// Invoke the agent with streaming support, then deserialize the assembled response. +IAsyncEnumerable updates = agent.RunStreamingAsync("Please provide information about Jane Doe, who is a 28-year-old data scientist."); -// Invoke the agent with some unstructured input while streaming, to extract the structured information from. -IAsyncEnumerable updates = agentWithPersonInfo.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); - -// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json, -// then deserialize the response into the PersonInfo class. PersonInfo personInfo = JsonSerializer.Deserialize((await updates.ToAgentResponseAsync()).Text, JsonSerializerOptions.Web) ?? throw new InvalidOperationException("Failed to deserialize the streamed response into PersonInfo."); -Console.WriteLine("Assistant Output:"); +Console.WriteLine("\nStreaming Assistant Output:"); Console.WriteLine($"Name: {personInfo.Name}"); Console.WriteLine($"Age: {personInfo.Age}"); Console.WriteLine($"Occupation: {personInfo.Occupation}"); -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - namespace SampleApp { /// - /// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent. + /// Represents information about a person. /// [Description("Information about a person including their name, age, and occupation")] public class PersonInfo diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md new file mode 100644 index 0000000000..f5421b3f64 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md @@ -0,0 +1,29 @@ +# Structured Output with the Responses API + +This sample demonstrates how to configure an agent to produce structured output using JSON schema. + +## What this sample demonstrates + +- Using `RunAsync()` to get typed structured output from the agent +- Deserializing streamed responses into structured types +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step05_StructuredOutput +``` 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs similarity index 78% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs index d8a5a7cd35..c9774cb1bf 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a simple AI agent with a conversation that can be persisted to disk. +// This sample shows how to persist and resume conversations. using System.Text.Json; using Azure.AI.Projects; @@ -10,16 +10,14 @@ 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"; -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions); +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); // Start a new session for the agent conversation. AgentSession session = await agent.CreateSessionAsync(); @@ -42,6 +40,3 @@ // Run the agent again with the resumed session. Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedSession)); - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md new file mode 100644 index 0000000000..a8cfda07ca --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md @@ -0,0 +1,30 @@ +# Persisted Conversations with the Responses API + +This sample demonstrates how to persist and resume agent conversations using session serialization. + +## What this sample demonstrates + +- Serializing agent sessions to JSON for persistence +- Saving and loading sessions from disk +- Resuming conversations with preserved context +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step06_PersistedConversations +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj similarity index 85% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj index 5ceeabb204..1189939bc0 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj @@ -9,8 +9,6 @@ - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs similarity index 71% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs index 257e24859f..68bfb91af0 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs @@ -1,6 +1,6 @@ // 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 logs telemetry using OpenTelemetry. +// This sample shows how to add OpenTelemetry observability to an agent. using Azure.AI.Projects; using Azure.Identity; @@ -9,15 +9,11 @@ using OpenTelemetry; using OpenTelemetry.Trace; +string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); 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"; -string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; -// Create TracerProvider with console exporter -// This will output the telemetry data to the console. +// Create TracerProvider with console exporter. string sourceName = Guid.NewGuid().ToString("N"); TracerProviderBuilder tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -28,14 +24,16 @@ } using var tracerProvider = tracerProviderBuilder.Build(); -// Get a client to create/retrieve/delete server side agents with Azure 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = (await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions)) +AIAgent agent = aiProjectClient + .AsAIAgent( + deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent") .AsBuilder() .UseOpenTelemetry(sourceName: sourceName) .Build(); @@ -48,8 +46,7 @@ session = await agent.CreateSessionAsync(); await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("Tell me a joke about a pirate.", session)) { - Console.WriteLine(update); + Console.Write(update); } -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +Console.WriteLine(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md new file mode 100644 index 0000000000..cb3bb729ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md @@ -0,0 +1,31 @@ +# Observability with the Responses API + +This sample demonstrates how to add OpenTelemetry observability to an agent using console and Azure Monitor exporters. + +## What this sample demonstrates + +- Configuring OpenTelemetry tracing with console exporter +- Optional Azure Application Insights integration +- Using `.AsBuilder().UseOpenTelemetry()` to add telemetry to the agent +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:APPLICATIONINSIGHTS_CONNECTION_STRING="..." # Optional +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step07_Observability +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj similarity index 83% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj index f1812befeb..72af634725 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj @@ -11,8 +11,6 @@ - - diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs new file mode 100644 index 0000000000..52a7d73132 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use dependency injection to register a AIAgent and use it from a hosted service. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SampleApp; + +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"; + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); + +// Create a host builder that we will register services with and then run. +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Add the AI agent to the service collection. +builder.Services.AddSingleton(agent); + +// Add a sample service that will use the agent to respond to user input. +builder.Services.AddHostedService(); + +// Build and run the host. +using IHost host = builder.Build(); +await host.RunAsync().ConfigureAwait(false); + +namespace SampleApp +{ + /// + /// A sample service that uses an AI agent to respond to user input. + /// + internal sealed class SampleService(AIAgent agent, IHostApplicationLifetime appLifetime) : IHostedService + { + private AgentSession? _session; + + public async Task StartAsync(CancellationToken cancellationToken) + { + this._session = await agent.CreateSessionAsync(cancellationToken); + _ = this.RunAsync(appLifetime.ApplicationStopping); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + await Task.Delay(100, cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + Console.WriteLine("\nAgent: Ask me to tell you a joke about a specific topic. To exit just press Ctrl+C or enter without any input.\n"); + Console.Write("> "); + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) + { + appLifetime.StopApplication(); + break; + } + + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, this._session, cancellationToken: cancellationToken)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Console.WriteLine("\nShutting down..."); + return Task.CompletedTask; + } + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md new file mode 100644 index 0000000000..c9ad936fd6 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md @@ -0,0 +1,30 @@ +# Dependency Injection with the Responses API + +This sample demonstrates how to register a `ChatClientAgent` in a dependency injection container and use it from a hosted service. + +## What this sample demonstrates + +- Registering `ChatClientAgent` as an `AIAgent` in the service collection +- Using the agent from a `IHostedService` with an interactive chat loop +- Streaming responses in a hosted service context +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step08_DependencyInjection +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj similarity index 84% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj index 1e3e6f57e3..96cdf948fe 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj @@ -6,16 +6,15 @@ enable enable - $(NoWarn);CA1812 - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs new file mode 100644 index 0000000000..87f80297af --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use MCP client tools with an agent. +// It connects to the Microsoft Learn MCP server via HTTP and uses its tools. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +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"; + +// Connect to the Microsoft Learn MCP server via HTTP (Streamable HTTP transport). +Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); + +await using McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); + +// Retrieve the list of tools available on the MCP server. +IList mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +List agentTools = [.. mcpTools.Cast()]; + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant that can help with Microsoft documentation questions. Use the Microsoft Learn MCP tool to search for documentation.", + name: "DocsAgent", + tools: agentTools); + +Console.WriteLine($"Agent '{agent.Name}' created. Asking a question...\n"); + +const string Prompt = "How does one create an Azure storage account using az cli?"; +Console.WriteLine($"User: {Prompt}\n"); +Console.WriteLine($"Agent: {await agent.RunAsync(Prompt)}"); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md new file mode 100644 index 0000000000..72437e0802 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md @@ -0,0 +1,29 @@ +# Using MCP Client as Tools with the Responses API + +This sample shows how to use MCP (Model Context Protocol) client tools with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Connecting to an MCP server via HTTP client transport +- Retrieving MCP tools and passing them to a `ChatClientAgent` +- Using MCP tools for agent interactions without server-side agent creation + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- Node.js installed (for npx/MCP server) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` 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 new file mode 100644 index 0000000000..6064cf9334 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs similarity index 64% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs index d44d62df51..2fdc150be9 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Image Multi-Modality with an AI agent. +// This sample shows how to use image multi-modality with an agent. using Azure.AI.Projects; using Azure.Identity; @@ -10,17 +10,14 @@ 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"; -const string VisionInstructions = "You are a helpful agent that can analyze images"; -const string VisionName = "VisionAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model: deploymentName, instructions: VisionInstructions); +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful agent that can analyze images.", + name: "VisionAgent"); ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), @@ -31,8 +28,7 @@ await DataContent.LoadFromAsync("assets/walkway.jpg"), await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(message, session)) { - Console.WriteLine(update); + Console.Write(update); } -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +Console.WriteLine(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md new file mode 100644 index 0000000000..370bf896cf --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md @@ -0,0 +1,30 @@ +# Using Images with the Responses API + +This sample demonstrates how to use image multi-modality with an agent. + +## What this sample demonstrates + +- Loading images using `DataContent.LoadFromAsync` +- Sending images alongside text to the agent +- Streaming the agent's image analysis response +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and a vision-capable model deployment (e.g., `gpt-4o`) +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step10_UsingImages +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/assets/walkway.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/assets/walkway.jpg new file mode 100644 index 0000000000..13ef1e1840 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/assets/walkway.jpg differ 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 new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs similarity index 57% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs index 585725322e..3715ab194f 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use an Azure Foundry Agents AI agent as a function tool. +// This sample shows how to use one agent as a function tool for another agent. using System.ComponentModel; using Azure.AI.Projects; @@ -8,43 +8,29 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -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"; - -const string WeatherInstructions = "You answer questions about the weather."; -const string WeatherName = "WeatherAgent"; -const string MainInstructions = "You are a helpful assistant who responds in French."; -const string MainName = "MainAgent"; - [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +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"; + // 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create the weather agent with function tools. AITool weatherTool = AIFunctionFactory.Create(GetWeather); -AIAgent weatherAgent = await aiProjectClient.CreateAIAgentAsync( - name: WeatherName, - model: deploymentName, - instructions: WeatherInstructions, +AIAgent weatherAgent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You answer questions about the weather.", + name: "WeatherAgent", tools: [weatherTool]); -// Create the main agent, and provide the weather agent as a function tool. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: MainName, - model: deploymentName, - instructions: MainInstructions, +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant who responds in French.", + name: "MainAgent", tools: [weatherAgent.AsAIFunction()]); // Invoke the agent and output the text result. AgentSession session = await agent.CreateSessionAsync(); Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", session)); - -// Cleanup by agent name removes the agent versions created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -await aiProjectClient.Agents.DeleteAgentAsync(weatherAgent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md new file mode 100644 index 0000000000..7d361305b9 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md @@ -0,0 +1,30 @@ +# Agent as a Function Tool with the Responses API + +This sample demonstrates how to use one agent as a function tool for another agent. + +## What this sample demonstrates + +- Creating a specialized agent (weather) with function tools +- Exposing an agent as a function tool using `.AsAIFunction()` +- Composing agents where one agent delegates to another +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step11_AsFunctionTool +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj similarity index 81% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj index 9f29a8d7e6..b30baccd54 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj @@ -3,15 +3,13 @@ Exe net10.0 - + enable enable - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs similarity index 70% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs index 824e1507b3..e37bf89639 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows multiple middleware layers working together with Azure Foundry Agents: +// This sample shows multiple middleware layers working together with a ChatClientAgent: // agent run (PII filtering and guardrails), // function invocation (logging and result overrides), and human-in-the-loop // approval workflows for sensitive function calls. @@ -12,19 +12,6 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -// Get Azure AI Foundry configuration from environment variables -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; - -const string AssistantInstructions = "You are an AI assistant that helps people find information."; -const string AssistantName = "InformationAssistant"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; @@ -33,14 +20,20 @@ static string GetWeather([Description("The location to get the weather for.")] s static string GetDateTime() => DateTimeOffset.Now.ToString(); +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"; + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + AITool dateTimeTool = AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime)); AITool getWeatherTool = AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)); -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent originalAgent = await aiProjectClient.CreateAIAgentAsync( - name: AssistantName, - model: deploymentName, - instructions: AssistantInstructions, +AIAgent originalAgent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are an AI assistant that helps people find information.", + name: "InformationAssistant", tools: [getWeatherTool, dateTimeTool]); // Adding middleware to the agent level @@ -63,24 +56,17 @@ static string GetDateTime() Console.WriteLine($"Pii filtered response: {piiResponse}"); Console.WriteLine("\n\n=== Example 3: Agent function middleware ==="); - -// Agent function middleware support is limited to agents that wraps a upstream ChatClientAgent or derived from it. - AgentResponse functionCallResponse = await middlewareEnabledAgent.RunAsync("What's the current time and the weather in Seattle?", session); Console.WriteLine($"Function calling response: {functionCallResponse}"); // Special per-request middleware agent. Console.WriteLine("\n\n=== Example 4: Middleware with human in the loop function approval ==="); -AIAgent humanInTheLoopAgent = await aiProjectClient.CreateAIAgentAsync( +AIAgent humanInTheLoopAgent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a Human in the loop testing AI assistant that helps people find information.", name: "HumanInTheLoopAgent", - model: deploymentName, - instructions: "You are an Human in the loop testing AI assistant that helps people find information.", - - // Adding a function with approval required tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)))]); -// Using the ConsolePromptingApprovalMiddleware for a specific request to handle user approval during function calls. AgentResponse response = await humanInTheLoopAgent .AsBuilder() .Use(ConsolePromptingApprovalMiddleware, null) @@ -108,7 +94,6 @@ static string GetDateTime() if (context.Function.Name == nameof(GetWeather)) { - // Override the result of the GetWeather function result = "The weather is sunny with a high of 25°C."; } Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Post-Invoke"); @@ -118,18 +103,16 @@ static string GetDateTime() // This middleware redacts PII information from input and output messages. async Task PIIMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - // Redact PII information from input messages var filteredMessages = FilterMessages(messages); Console.WriteLine("Pii Middleware - Filtered Messages Pre-Run"); - var response = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken).ConfigureAwait(false); + var agentResponse = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken).ConfigureAwait(false); - // Redact PII information from output messages - response.Messages = FilterMessages(response.Messages); + agentResponse.Messages = FilterMessages(agentResponse.Messages); Console.WriteLine("Pii Middleware - Filtered Messages Post-Run"); - return response; + return agentResponse; static IList FilterMessages(IEnumerable messages) { @@ -138,11 +121,10 @@ static IList FilterMessages(IEnumerable messages) static string FilterPii(string content) { - // Regex patterns for PII detection (simplified for demonstration) Regex[] piiPatterns = [ - new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) - new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address - new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + MyRegex(), + EmailRegex(), + FullNameRegex() ]; foreach (var pattern in piiPatterns) @@ -157,20 +139,17 @@ static string FilterPii(string content) // This middleware enforces guardrails by redacting certain keywords from input and output messages. async Task GuardrailMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - // Redact keywords from input messages var filteredMessages = FilterMessages(messages); Console.WriteLine("Guardrail Middleware - Filtered messages Pre-Run"); - // Proceed with the agent run - var response = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken); + var agentResponse = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken); - // Redact keywords from output messages - response.Messages = FilterMessages(response.Messages); + agentResponse.Messages = FilterMessages(agentResponse.Messages); Console.WriteLine("Guardrail Middleware - Filtered messages Post-Run"); - return response; + return agentResponse; List FilterMessages(IEnumerable messages) { @@ -194,16 +173,13 @@ static string FilterContent(string content) // This middleware handles Human in the loop console interaction for any user approval required during function calling. async Task ConsolePromptingApprovalMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken); + AgentResponse agentResponse = await innerAgent.RunAsync(messages, session, options, cancellationToken); - // For simplicity, we are assuming here that only function approvals are pending. - List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + List approvalRequests = agentResponse.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { - // Ask the user to approve each function call request. - // Pass the user input responses back to the agent for further processing. - response.Messages = approvalRequests + agentResponse.Messages = approvalRequests .ConvertAll(functionApprovalRequest => { Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}"); @@ -211,13 +187,22 @@ async Task ConsolePromptingApprovalMiddleware(IEnumerable m.Contents).OfType().ToList(); + approvalRequests = agentResponse.Messages.SelectMany(m => m.Contents).OfType().ToList(); } - return response; + return agentResponse; } -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(middlewareEnabledAgent.Name); +internal partial class Program +{ + [GeneratedRegex(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled)] + private static partial Regex MyRegex(); + + [GeneratedRegex(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled)] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled)] + private static partial Regex FullNameRegex(); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md new file mode 100644 index 0000000000..26b12a22b8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md @@ -0,0 +1,31 @@ +# Middleware with the Responses API + +This sample demonstrates multiple middleware layers working together: PII filtering, guardrails, function invocation logging, and human-in-the-loop approval. + +## What this sample demonstrates + +- Agent-level run middleware (PII filtering, guardrail enforcement) +- Function-level middleware (logging, result overrides) +- Human-in-the-loop approval workflows for sensitive function calls +- Using `.AsBuilder().Use()` to compose middleware +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step12_Middleware +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj similarity index 91% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj index 4a34560946..1f5e37c1a3 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj @@ -10,13 +10,12 @@ - - + - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs new file mode 100644 index 0000000000..3af63090c5 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use plugins with an AI agent. Plugin classes can +// depend on other services that need to be injected. In this sample, the +// AgentPlugin class uses the WeatherProvider and CurrentTimeProvider classes +// to get weather and current time information. Both services are registered +// in the service collection and injected into the plugin. +// Plugin classes may have many methods, but only some are intended to be used +// as AI functions. The AsAITools method of the plugin class shows how to specify +// which methods should be exposed to the AI agent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using SampleApp; + +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"; + +const string AssistantInstructions = "You are a helpful assistant that helps people find information."; +const string AssistantName = "PluginAssistant"; + +// Create a service collection to hold the agent plugin and its dependencies. +ServiceCollection services = new(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); // The plugin depends on WeatherProvider and CurrentTimeProvider registered above. + +IServiceProvider serviceProvider = services.BuildServiceProvider(); + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a ChatClientAgent with the options-based constructor to pass services. +AIAgent agent = aiProjectClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = AssistantName, + ChatOptions = new() { ModelId = deploymentName, Instructions = AssistantInstructions, Tools = serviceProvider.GetRequiredService().AsAITools().ToList() } +}, + services: serviceProvider); + +// Invoke the agent and output the text result. +AgentSession session = await agent.CreateSessionAsync(); +Console.WriteLine(await agent.RunAsync("Tell me current time and weather in Seattle.", session)); + +namespace SampleApp +{ + /// + /// The agent plugin that provides weather and current time information. + /// + internal sealed class AgentPlugin + { + private readonly WeatherProvider _weatherProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The weather provider to get weather information. + public AgentPlugin(WeatherProvider weatherProvider) + { + this._weatherProvider = weatherProvider; + } + + /// + /// Gets the weather information for the specified location. + /// + /// + /// This method demonstrates how to use the dependency that was injected into the plugin class. + /// + /// The location to get the weather for. + /// The weather information for the specified location. + public string GetWeather(string location) + { + return this._weatherProvider.GetWeather(location); + } + + /// + /// Gets the current date and time for the specified location. + /// + /// + /// This method demonstrates how to resolve a dependency using the service provider passed to the method. + /// + /// The service provider to resolve the . + /// The location to get the current time for. + /// The current date and time as a . + public DateTimeOffset GetCurrentTime(IServiceProvider sp, string location) + { + CurrentTimeProvider currentTimeProvider = sp.GetRequiredService(); + return currentTimeProvider.GetCurrentTime(location); + } + + /// + /// Returns the functions provided by this plugin. + /// + /// + /// In real world scenarios, a class may have many methods and only a subset of them may be intended to be exposed as AI functions. + /// This method demonstrates how to explicitly specify which methods should be exposed to the AI agent. + /// + /// The functions provided by this plugin. + public IEnumerable AsAITools() + { + yield return AIFunctionFactory.Create(this.GetWeather); + yield return AIFunctionFactory.Create(this.GetCurrentTime); + } + } + + internal sealed class WeatherProvider + { + private readonly string _weatherSummary = "cloudy with a high of 15°C"; + + /// + /// The weather provider that returns weather information. + /// + /// + /// Gets the weather information for the specified location. + /// + /// + /// The weather information is hardcoded for demonstration purposes. + /// In a real application, this could call a weather API to get actual weather data. + /// + /// The location to get the weather for. + /// The weather information for the specified location. + public string GetWeather(string location) + { + return $"The weather in {location} is {this._weatherSummary}."; + } + } + + internal sealed class CurrentTimeProvider + { + private readonly TimeProvider _timeProvider = TimeProvider.System; + + /// + /// Provides the current date and time. + /// + /// + /// This class returns the current date and time using the system's clock. + /// + /// + /// Gets the current date and time. + /// + /// The location to get the current time for (not used in this implementation). + /// The current date and time as a . + public DateTimeOffset GetCurrentTime(string location) + { + return this._timeProvider.GetLocalNow(); + } + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md new file mode 100644 index 0000000000..8cc2f59116 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md @@ -0,0 +1,29 @@ +# Using Plugins with the Responses API + +This sample shows how to use plugins with a `ChatClientAgent` using the Responses API directly, with dependency injection for plugin services. + +## What this sample demonstrates + +- Creating plugin classes with injected dependencies +- Registering services and building a service provider +- Passing `services` to the `ChatClientAgent` via the options-based constructor +- Using `AIFunctionFactory` to expose plugin methods as AI tools + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj similarity index 89% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj index daf7e24494..e11688b6ba 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs similarity index 59% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs index 5a27daed12..8d7f598a4a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs @@ -1,61 +1,31 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Code Interpreter Tool with AI Agents. +// This sample shows how to use Code Interpreter Tool with AIProjectClient.AsAIAgent(...). using System.Text; using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Assistants; -using OpenAI.Responses; + +const string AgentInstructions = "You are a personal math tutor. When asked a math question, write and run code using the python tool to answer the question."; +const string AgentName = "CoderAgent-RAPI"; 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"; -const string AgentInstructions = "You are a personal math tutor. When asked a math question, write and run code using the python tool to answer the question."; -const string AgentNameMEAI = "CoderAgent-MEAI"; -const string AgentNameNative = "CoderAgent-NATIVE"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Option 1 - Using HostedCodeInterpreterTool + AgentOptions (MEAI + AgentFramework) -// Create the server side agent version -AIAgent agentOption1 = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: AgentNameMEAI, +AIAgent agent = aiProjectClient.AsAIAgent( + deploymentName, instructions: AgentInstructions, + name: AgentName, tools: [new HostedCodeInterpreterTool() { Inputs = [] }]); -// Option 2 - Using PromptAgentDefinition SDK native type -// Create the server side agent version -AIAgent agentOption2 = await aiProjectClient.CreateAIAgentAsync( - name: AgentNameNative, - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { - ResponseTool.CreateCodeInterpreterTool( - new CodeInterpreterToolContainer( - CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(fileIds: []) - ) - ), - } - }) -); - -// Either invoke option1 or option2 agent, should have same result -// Option 1 -AgentResponse response = await agentOption1.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); - -// Option 2 -// AgentResponse response = await agentOption2.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); +AgentResponse response = await agent.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); // Get the CodeInterpreterToolCallContent CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); @@ -87,7 +57,3 @@ """); } } - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agentOption1.Name); -await aiProjectClient.Agents.DeleteAgentAsync(agentOption2.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md new file mode 100644 index 0000000000..1a8cfc8aae --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md @@ -0,0 +1,28 @@ +# Code Interpreter with the Responses API + +This sample shows how to use the Code Interpreter tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Using `HostedCodeInterpreterTool` with `ChatClientAgent` +- Extracting code input and output from agent responses +- Handling code interpreter annotations and file citations + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj similarity index 99% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj index 041c72c43e..f739f56123 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj @@ -29,5 +29,5 @@ Always - + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs similarity index 61% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index 7f6382d085..22f03e27b3 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Computer Use Tool with AI Agents. +// This sample shows how to use Computer Use Tool with a ChatClientAgent. using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Responses; @@ -15,59 +15,32 @@ internal sealed class Program { private static async Task Main(string[] args) { + const string AgentInstructions = @" + You are a computer automation assistant. + + Be direct and efficient. When you reach the search results page, read and describe the actual search result titles and descriptions you can see. + "; + + const string AgentName = "ComputerAgent-RAPI"; + 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") ?? "computer-use-preview"; // 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. - // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - const string AgentInstructions = @" - You are a computer automation assistant. - - Be direct and efficient. When you reach the search results page, read and describe the actual search result titles and descriptions you can see. - "; - const string AgentNameMEAI = "ComputerAgent-MEAI"; - const string AgentNameNative = "ComputerAgent-NATIVE"; - - // Option 1 - Using ComputerUseTool + AgentOptions (MEAI + AgentFramework) - // Create AIAgent directly - AIAgent agentOption1 = await aiProjectClient.CreateAIAgentAsync( - name: AgentNameMEAI, - model: deploymentName, + // Create a AIAgent with ComputerUseTool. + AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, instructions: AgentInstructions, + name: AgentName, description: "Computer automation agent with screen interaction capabilities.", tools: [ - ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769).AsAITool(), + FoundryAITool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769), ]); - // Option 2 - Using PromptAgentDefinition SDK native type - // Create the server side agent version - AIAgent agentOption2 = await aiProjectClient.CreateAIAgentAsync( - name: AgentNameNative, - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { ResponseTool.CreateComputerTool( - environment: new ComputerToolEnvironment("windows"), - displayWidth: 1026, - displayHeight: 769) } - }) - ); - - // Either invoke option1 or option2 agent, should have same result - // Option 1 - await InvokeComputerUseAgentAsync(agentOption1); - - // Option 2 - //await InvokeComputerUseAgentAsync(agentOption2); - - // Cleanup by agent name removes the agent version created. - await aiProjectClient.Agents.DeleteAgentAsync(agentOption1.Name); - await aiProjectClient.Agents.DeleteAgentAsync(agentOption2.Name); + await InvokeComputerUseAgentAsync(agent); } private static async Task InvokeComputerUseAgentAsync(AIAgent agent) @@ -94,10 +67,8 @@ private static async Task InvokeComputerUseAgentAsync(AIAgent agent) // Initial request with screenshot - start with Bing search page Console.WriteLine("Starting computer automation session (initial screenshot: cua_browser_search.png)..."); - // IMPORTANT: Computer-use with the Azure Agents API differs from the vanilla OpenAI Responses API. - // The Azure Agents API rejects requests that include previous_response_id alongside - // computer_call_output items. To work around this, each call uses a fresh session (avoiding - // previous_response_id) and re-sends the full conversation context as input items instead. + // We use PreviousResponseId to chain calls, sending only the new computer_call_output items + // instead of re-sending the full context. AgentSession session = await agent.CreateSessionAsync(); AgentResponse response = await agent.RunAsync(message, session: session, options: runOptions); @@ -161,31 +132,15 @@ private static async Task InvokeComputerUseAgentAsync(AIAgent agent) Console.WriteLine("Sending action result back to agent..."); - // Build the follow-up messages with full conversation context. - // The Azure Agents API rejects previous_response_id when computer_call_output items are - // present, so we must re-send all prior output items (reasoning, computer_call, etc.) - // as input items alongside the computer_call_output to maintain conversation continuity. - List followUpMessages = []; - - // Re-send all response output items as an assistant message so the API has full context - List priorOutputContents = response.Messages - .SelectMany(m => m.Contents) - .ToList(); - followUpMessages.Add(new ChatMessage(ChatRole.Assistant, priorOutputContents)); - - // Add the computer_call_output as a user message + // Send only the computer_call_output — the session carries PreviousResponseId for context continuity. AIContent callOutput = new() { RawRepresentation = new ComputerCallOutputResponseItem( currentCallId, output: ComputerCallOutput.CreateScreenshotOutput(new BinaryData(screenInfo.ImageBytes), "image/png")) }; - followUpMessages.Add(new ChatMessage(ChatRole.User, [callOutput])); - // Create a fresh session so ConversationId does not carry over a previous_response_id. - // Without this, the Azure Agents API returns an error when computer_call_output is present. - session = await agent.CreateSessionAsync(); - response = await agent.RunAsync(followUpMessages, session: session, options: runOptions); + response = await agent.RunAsync([new ChatMessage(ChatRole.User, [callOutput])], session: session, options: runOptions); } } } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md new file mode 100644 index 0000000000..ecaa18e10f --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md @@ -0,0 +1,29 @@ +# Computer Use with the Responses API + +This sample shows how to use the Computer Use tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Using `FoundryAITool.CreateComputerTool()` with `ChatClientAgent` +- Processing computer call actions (click, type, key press) +- Managing the computer use interaction loop with screenshots +- Handling the Azure Agents API workaround for `previous_response_id` with `computer_call_output` + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="computer-use-preview" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj similarity index 89% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj index daf7e24494..e11688b6ba 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs similarity index 69% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs index 5371903a9f..c01a951df4 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs @@ -1,22 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use File Search Tool with AI Agents. +// This sample shows how to use File Search Tool with a ChatClientAgent. using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Assistants; using OpenAI.Files; -using OpenAI.Responses; 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"; const string AgentInstructions = "You are a helpful assistant that can search through uploaded files to answer questions."; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// We need the AIProjectClient to upload files and create vector stores. // 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. @@ -52,8 +50,11 @@ string vectorStoreId = vectorStoreResult.Value.Id; Console.WriteLine($"Created vector store, vector store ID: {vectorStoreId}"); -AIAgent agent = await CreateAgentWithMEAI(); -// AIAgent agent = await CreateAgentWithNativeSDK(); +// Create a AIAgent with HostedFileSearchTool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "FileSearchAgent-RAPI", + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }]); // Run the agent Console.WriteLine("\n--- Running File Search Agent ---"); @@ -73,39 +74,9 @@ } } -// Cleanup. +// Cleanup file resources. Console.WriteLine("\n--- Cleanup ---"); -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); await vectorStoresClient.DeleteVectorStoreAsync(vectorStoreId); await filesClient.DeleteFileAsync(uploadedFile.Id); File.Delete(searchFilePath); Console.WriteLine("Cleanup completed successfully."); - -// --- Agent Creation Options --- - -#pragma warning disable CS8321 // Local function is declared but never used -// Option 1 - Using HostedFileSearchTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAI() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "FileSearchAgent-MEAI", - instructions: AgentInstructions, - tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }]); -} - -// Option 2 - Using PromptAgentDefinition with ResponseTool.CreateFileSearchTool (Native SDK) -async Task CreateAgentWithNativeSDK() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "FileSearchAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { - ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreId]) - } - }) - ); -} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md new file mode 100644 index 0000000000..5ce7f11cda --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md @@ -0,0 +1,29 @@ +# File Search with the Responses API + +This sample shows how to use the File Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Uploading files and creating vector stores via `AIProjectClient` +- Using `HostedFileSearchTool` with `ChatClientAgent` +- Handling file citation annotations in agent responses +- Cleaning up file resources after use + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj similarity index 82% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj index 89b9d8ddc0..4602e9c9e0 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj @@ -6,15 +6,14 @@ enable enable - $(NoWarn);IDE0059 - + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs similarity index 55% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs index ebf66e6c2c..5cc2720dd9 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs @@ -6,16 +6,32 @@ using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI.Responses; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; -// Warning: DefaultAzureCredential is intended for simplicity in development. For production scenarios, consider using a more specific credential. 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"; const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AITool openApiTool = FoundryAITool.CreateOpenApiTool(CreateOpenAPIFunctionDefinition()); + +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "OpenAPIToolsAgent", + tools: [openApiTool]); + +// Run the agent with a question about countries +Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them.")); -// A simple OpenAPI specification for the REST Countries API -const string CountriesOpenApiSpec = """ +OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition() +{ + // A simple OpenAPI specification for the REST Countries API + const string CountriesOpenApiSpec = """ { "openapi": "3.1.0", "info": { @@ -68,49 +84,12 @@ } """; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Create the OpenAPI function definition -var openApiFunction = new OpenApiFunctionDefinition( - "get_countries", - BinaryData.FromString(CountriesOpenApiSpec), - new OpenAPIAnonymousAuthenticationDetails()) -{ - Description = "Retrieve information about countries by currency code" -}; - -AIAgent agent = await CreateAgentWithMEAI(); -// AIAgent agent = await CreateAgentWithNativeSDK(); - -// Run the agent with a question about countries -Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them.")); - -// Cleanup by deleting the agent -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - -// --- Agent Creation Options --- - -// Option 1 - Using AsAITool wrapping for OpenApiTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAI() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "OpenAPIToolsAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with AgentTool.CreateOpenApiTool (Native SDK) -async Task CreateAgentWithNativeSDK() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "OpenAPIToolsAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) } - }) - ); + // Create the OpenAPI function definition + return new( + "get_countries", + BinaryData.FromString(CountriesOpenApiSpec), + new OpenAPIAnonymousAuthenticationDetails()) + { + Description = "Retrieve information about countries by currency code" + }; } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md new file mode 100644 index 0000000000..52a11ec1dd --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md @@ -0,0 +1,29 @@ +# OpenAPI Tools with the Responses API + +This sample shows how to use OpenAPI tools with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Defining an OpenAPI specification inline +- Creating an `OpenAPIFunctionDefinition` for the REST Countries API +- Using `FoundryAITool.CreateOpenApiTool()` with `ChatClientAgent` +- Server-side execution of OpenAPI tool calls + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj similarity index 89% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj index daf7e24494..e11688b6ba 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs similarity index 54% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs index 98ea576226..4ab548403d 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs @@ -1,15 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Bing Custom Search Tool with AI Agents. +// This sample shows how to use Bing Custom Search Tool with a ChatClientAgent. using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI.Responses; +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"; 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."); @@ -18,19 +16,24 @@ You are a helpful agent that can use Bing Custom Search tools to assist users. Use the available Bing Custom Search tools to answer questions and perform tasks. """; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// Bing Custom Search tool parameters +BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ + new BingCustomSearchConfiguration(connectionId, instanceName) +]); + +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"; + // 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Bing Custom Search tool parameters shared by both options -BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ - new BingCustomSearchConfiguration(connectionId, instanceName) -]); - -AIAgent agent = await CreateAgentWithMEAIAsync(); -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); +// Create a AIAgent with Bing Custom Search tool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "BingCustomSearchAgent-RAPI", + tools: [FoundryAITool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)]); Console.WriteLine($"Created agent: {agent.Name}"); @@ -42,35 +45,3 @@ Use the available Bing Custom Search tools to answer questions and perform tasks { Console.WriteLine(message.Text); } - -// Cleanup by deleting the agent -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -Console.WriteLine($"\nDeleted agent: {agent.Name}"); - -// --- Agent Creation Options --- - -// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateBingCustomSearchTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAIAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "BingCustomSearchAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with AgentTool.CreateBingCustomSearchTool (Native SDK) -async Task CreateAgentWithNativeSDKAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "BingCustomSearchAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { - (ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters), - } - }) - ); -} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md new file mode 100644 index 0000000000..fc48fd8744 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md @@ -0,0 +1,36 @@ +# Bing Custom Search with the Responses API + +This sample shows how to use the Bing Custom Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `BingCustomSearchToolParameters` with connection ID and instance name +- Using `FoundryAITool.CreateBingCustomSearchTool()` with `ChatClientAgent` +- Processing search results from agent responses + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- Bing Custom Search resource configured with a connection ID + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID="your-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/your-bing-connection" +$env:AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME="your-instance-name" # The Bing Custom Search configuration name (from Azure portal) +``` + +### Finding the connection ID and instance name + +- **Connection ID** (`AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID`): The full ARM resource URI including the `/projects//connections/` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**. +- **Instance Name** (`AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME`): The **configuration name** from your Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name or the connection name — it's the name of the specific search configuration that defines which domains/sites to search against. + +## Run the sample + +```powershell +dotnet run +``` 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 new file mode 100644 index 0000000000..e11688b6ba --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs similarity index 53% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs index ad6a08abaa..aafca6b8bd 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs @@ -1,15 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use SharePoint Grounding Tool with AI Agents. +// This sample shows how to use SharePoint Grounding Tool with a ChatClientAgent. using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI.Responses; +using Microsoft.Agents.AI.AzureAI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; string sharepointConnectionId = Environment.GetEnvironmentVariable("SHAREPOINT_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("SHAREPOINT_PROJECT_CONNECTION_ID is not set."); const string AgentInstructions = """ @@ -17,18 +15,23 @@ You are a helpful agent that can use SharePoint tools to assist users. Use the available SharePoint tools to answer questions and perform tasks. """; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// Create SharePoint tool options with project connection +var sharepointOptions = new SharePointGroundingToolOptions(); +sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId)); + +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"; + // 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create SharePoint tool options with project connection -var sharepointOptions = new SharePointGroundingToolOptions(); -sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId)); - -AIAgent agent = await CreateAgentWithMEAIAsync(); -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); +// Create a AIAgent with SharePoint tool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "SharePointAgent-RAPI", + tools: [FoundryAITool.CreateSharepointTool(sharepointOptions)]); Console.WriteLine($"Created agent: {agent.Name}"); @@ -52,33 +55,3 @@ Use the available SharePoint tools to answer questions and perform tasks. } } } - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -Console.WriteLine($"\nDeleted agent: {agent.Name}"); - -// --- Agent Creation Options --- - -// Option 1 - Using AgentTool.CreateSharepointTool + AsAITool() (MEAI + AgentFramework) -async Task CreateAgentWithMEAIAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "SharePointAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition SDK native type -async Task CreateAgentWithNativeSDKAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "SharePointAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { AgentTool.CreateSharepointTool(sharepointOptions) } - }) - ); -} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md new file mode 100644 index 0000000000..bfaacee860 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md @@ -0,0 +1,30 @@ +# SharePoint Grounding with the Responses API + +This sample shows how to use the SharePoint Grounding tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `SharePointGroundingToolOptions` with project connections +- Using `FoundryAITool.CreateSharepointTool()` with `ChatClientAgent` +- Displaying grounding annotations from agent responses + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- SharePoint connection configured in your Microsoft Foundry project + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/SharepointTestTool" +``` + +## Run the sample + +```powershell +dotnet run +``` 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 new file mode 100644 index 0000000000..e11688b6ba --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs new file mode 100644 index 0000000000..7c49afa7ee --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Microsoft Fabric Tool with a ChatClientAgent. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; + +string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); + +const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection."; + +// Configure Microsoft Fabric tool options with project connection +var fabricToolOptions = new FabricDataAgentToolOptions(); +fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); + +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"; + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a AIAgent with Microsoft Fabric tool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "FabricAgent-RAPI", + tools: [FoundryAITool.CreateMicrosoftFabricTool(fabricToolOptions)]); + +Console.WriteLine($"Created agent: {agent.Name}"); + +// Run the agent with a sample query +AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?"); + +Console.WriteLine("\n=== Agent Response ==="); +foreach (var message in response.Messages) +{ + Console.WriteLine(message.Text); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md new file mode 100644 index 0000000000..4a4d31e151 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md @@ -0,0 +1,30 @@ +# Microsoft Fabric with the Responses API + +This sample shows how to use the Microsoft Fabric tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `FabricDataAgentToolOptions` with project connections +- Using `FoundryAITool.CreateMicrosoftFabricTool()` with `ChatClientAgent` +- Querying data available through a Fabric connection + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- Microsoft Fabric connection configured in your Microsoft Foundry project + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/FabricTestTool" +``` + +## Run the sample + +```powershell +dotnet run +``` 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 new file mode 100644 index 0000000000..e11688b6ba --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs new file mode 100644 index 0000000000..20d74b0401 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use the Web Search Tool with a ChatClientAgent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately."; +const string AgentName = "WebSearchAgent-RAPI"; + +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"; + +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a AIAgent with HostedWebSearchTool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: AgentName, + tools: [new HostedWebSearchTool()]); + +AgentResponse response = await agent.RunAsync("What's the weather today in Seattle?"); + +// Get the text response +Console.WriteLine($"Response: {response.Text}"); + +// Getting any annotations/citations generated by the web search tool +foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(c => c.Annotations ?? [])) +{ + Console.WriteLine($"Annotation: {annotation}"); + if (annotation.RawRepresentation is UriCitationMessageAnnotation urlCitation) + { + Console.WriteLine($$""" + Title: {{urlCitation.Title}} + URL: {{urlCitation.Uri}} + """); + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md new file mode 100644 index 0000000000..1a9d80106d --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md @@ -0,0 +1,28 @@ +# Web Search with the Responses API + +This sample shows how to use the Web Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Using `HostedWebSearchTool` with `ChatClientAgent` +- Processing web search citations and annotations +- Extracting URL citation details (title, URL) from responses + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` 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 new file mode 100644 index 0000000000..4602e9c9e0 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs similarity index 70% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs index 60452b7d19..9e1a902f29 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs @@ -9,6 +9,8 @@ using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; using OpenAI.Responses; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); @@ -22,27 +24,25 @@ Use the memory search tool to recall relevant information from previous interact When a user shares personal details or preferences, remember them for future conversations. """; -const string AgentNameMEAI = "MemorySearchAgent-MEAI"; -const string AgentNameNative = "MemorySearchAgent-NATIVE"; +const string AgentName = "MemorySearchAgent"; -// Scope identifies the user or context for memory isolation. -// Using a unique user identifier ensures memories are private to that user. string userScope = $"user_{Environment.MachineName}"; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -DefaultAzureCredential credential = new(); -AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); +MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelayInSecs = 0 }; +// 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. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create agent using the RAPI path with the MemorySearch tool +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: AgentName, + tools: [FoundryAITool.FromResponseTool(memorySearchTool)]); // Ensure the memory store exists and has memories to retrieve. await EnsureMemoryStoreAsync(); -// Create the Memory Search tool configuration -MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelayInSecs = 0 }; - -// Create agent using Option 1 (MEAI) or Option 2 (Native SDK) -AIAgent agent = await CreateAgentWithMEAI(); -// AIAgent agent = await CreateAgentWithNativeSDK(); - try { Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n"); @@ -73,41 +73,14 @@ Use the memory search tool to recall relevant information from previous interact } finally { - // Cleanup: Delete the agent and memory store. + // Cleanup: Delete the memory store (no server-side agent to clean up in RAPI path). Console.WriteLine("\nCleaning up..."); - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - Console.WriteLine("Agent deleted."); await aiProjectClient.MemoryStores.DeleteMemoryStoreAsync(memoryStoreName); Console.WriteLine("Memory store deleted."); } -#pragma warning disable CS8321 // Local function is declared but never used - -// Option 1 - Using MemorySearchTool wrapped as MEAI AITool -async Task CreateAgentWithMEAI() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: AgentNameMEAI, - instructions: AgentInstructions, - tools: [((ResponseTool)memorySearchTool).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with MemorySearchTool (Native SDK) -async Task CreateAgentWithNativeSDK() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: AgentNameNative, - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { memorySearchTool } - }) - ); -} - // Helpers — kept at the bottom so the main agent flow above stays clean. + async Task EnsureMemoryStoreAsync() { Console.WriteLine($"Creating memory store '{memoryStoreName}'..."); @@ -123,19 +96,37 @@ async Task EnsureMemoryStoreAsync() Console.WriteLine("Memory store created."); } + // Explicitly add memories from a simulated prior conversation. Console.WriteLine("Storing memories from a prior conversation..."); MemoryUpdateOptions memoryOptions = new(userScope) { UpdateDelay = 0 }; - memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I love programming in C#.")); + memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I prefer C#.")); MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync( memoryStoreName: memoryStoreName, - pollingInterval: 500, - options: memoryOptions); + options: memoryOptions, + pollingInterval: 500); if (updateResult.Status == MemoryStoreUpdateStatus.Failed) { throw new InvalidOperationException($"Memory update failed: {updateResult.ErrorDetails}"); } - Console.WriteLine($"Memory update completed (status: {updateResult.Status}).\n"); + Console.WriteLine($"Memory update completed (status: {updateResult.Status})."); + + // Quick verification that memories are searchable. + Console.WriteLine("Verifying stored memories..."); + MemorySearchOptions searchOptions = new(userScope) + { + Items = { ResponseItem.CreateUserMessageItem("What are Alice's preferences?") } + }; + MemoryStoreSearchResponse searchResult = await aiProjectClient.MemoryStores.SearchMemoriesAsync( + memoryStoreName: memoryStoreName, + options: searchOptions); + + foreach (var memory in searchResult.Memories) + { + Console.WriteLine($" - {memory.MemoryItem.Content}"); + } + + Console.WriteLine(); } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md new file mode 100644 index 0000000000..b8020553af --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md @@ -0,0 +1,31 @@ +# Memory Search with the Responses API + +This sample demonstrates how to use the Memory Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `MemorySearchPreviewTool` with a memory store and user scope +- Using memory search for cross-conversation recall +- Inspecting `MemorySearchToolCallResponseItem` results +- User profile persistence across conversations + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- A memory store created beforehand via Azure Portal or Python SDK + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MEMORY_STORE_ID="your-memory-store-name" +``` + +## Run the sample + +```powershell +dotnet run +``` 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 new file mode 100644 index 0000000000..e51f57c439 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs similarity index 50% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs index d41771ef37..c9f8c0060b 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs @@ -1,24 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. -// The MCP tools are resolved locally by connecting directly to the MCP server via HTTP, -// and then passed to the Foundry agent as client-side tools. -// This sample uses the Microsoft Learn MCP endpoint to search documentation. +// This sample demonstrates how to wrap MCP tools with a DelegatingAIFunction to add custom behavior (e.g., logging). +// Compare with Step09 which shows basic MCP tool usage without wrapping. +// The LoggingMcpTool pattern is useful for diagnostics, metering, or adding approval logic around tool calls. using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; - -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"; +using SampleApp; const string AgentInstructions = "You are a helpful assistant that can help with Microsoft documentation questions. Use the Microsoft Learn MCP tool to search for documentation."; -const string AgentName = "DocsAgent"; +const string AgentName = "DocsAgent-RAPI"; // Connect to the MCP server locally via HTTP (Streamable HTTP transport). -// The MCP server is hosted at Microsoft Learn and provides documentation search capabilities. Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); await using McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() @@ -34,53 +30,48 @@ // Wrap each MCP tool with a DelegatingAIFunction to log local invocations. List wrappedTools = mcpTools.Select(tool => (AITool)new LoggingMcpTool(tool)).ToList(); -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +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"; + // 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. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create the agent with the locally-resolved MCP tools. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: AgentName, +// Create a AIAgent with the locally-resolved MCP tools. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, instructions: AgentInstructions, + name: AgentName, tools: wrappedTools); Console.WriteLine($"Agent '{agent.Name}' created successfully."); -try -{ - // First query - const string Prompt1 = "How does one create an Azure storage account using az cli?"; - Console.WriteLine($"\nUser: {Prompt1}\n"); - AgentResponse response1 = await agent.RunAsync(Prompt1); - Console.WriteLine($"Agent: {response1}"); +// First query +const string Prompt1 = "How does one create an Azure storage account using az cli?"; +Console.WriteLine($"\nUser: {Prompt1}\n"); +AgentResponse response1 = await agent.RunAsync(Prompt1); +Console.WriteLine($"Agent: {response1}"); - Console.WriteLine("\n=======================================\n"); +Console.WriteLine("\n=======================================\n"); - // Second query - const string Prompt2 = "What is Microsoft Agent Framework?"; - Console.WriteLine($"User: {Prompt2}\n"); - AgentResponse response2 = await agent.RunAsync(Prompt2); - Console.WriteLine($"Agent: {response2}"); -} -finally -{ - // Cleanup by removing the agent when done - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - Console.WriteLine($"\nAgent '{agent.Name}' deleted."); -} +// Second query +const string Prompt2 = "What is Microsoft Agent Framework?"; +Console.WriteLine($"User: {Prompt2}\n"); +AgentResponse response2 = await agent.RunAsync(Prompt2); +Console.WriteLine($"Agent: {response2}"); -/// -/// Wraps an MCP tool to log when it is invoked locally, -/// confirming that the MCP call is happening client-side. -/// -internal sealed class LoggingMcpTool(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) +namespace SampleApp { - protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + /// + /// Wraps an MCP tool to log when it is invoked locally, + /// confirming that the MCP call is happening client-side. + /// + internal sealed class LoggingMcpTool(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) { - Console.WriteLine($" >> [LOCAL MCP] Invoking tool '{this.Name}' locally..."); - return base.InvokeCoreAsync(arguments, cancellationToken); + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + Console.WriteLine($" >> [LOCAL MCP] Invoking tool '{this.Name}' locally..."); + return base.InvokeCoreAsync(arguments, cancellationToken); + } } } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md new file mode 100644 index 0000000000..84456df2b7 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md @@ -0,0 +1,29 @@ +# Local MCP with the Responses API + +This sample demonstrates how to use a local MCP (Model Context Protocol) client with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Connecting to an MCP server via HTTP (Streamable HTTP transport) +- Resolving MCP tools locally and wrapping them with logging +- Using `DelegatingAIFunction` to add custom behavior to MCP tools +- Passing locally-resolved MCP tools to `ChatClientAgent` + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md new file mode 100644 index 0000000000..b5802053b3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -0,0 +1,81 @@ +# Getting started with Foundry Agents + +These samples demonstrate how to use Azure AI Foundry with Agent Framework. + +## Quick start + +The simplest way to create a Foundry agent is using the `FoundryAgent` type directly: + +```csharp +FoundryAgent agent = new( + new Uri(endpoint), + new AzureCliCredential(), + model: "gpt-4o-mini", + instructions: "You are good at telling jokes.", + name: "JokerAgent"); + +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); +``` + +Or using the `AIProjectClient.AsAIAgent(...)` extensions: + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +FoundryAgent agent = aiProjectClient.AsAIAgent( + model: deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +## Prerequisites + +- .NET 10 SDK or later +- Foundry project endpoint +- Azure CLI installed and authenticated + +Set: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +Some samples require extra tool-specific environment variables. See each sample for details. + +## Samples + +| Sample | Description | +| --- | --- | +| [FoundryAgent lifecycle](./Agent_Step00_FoundryAgentLifecycle/) | Create a FoundryAgent directly with endpoint and credentials | +| [Basics (Responses API)](./Agent_Step01_Basics/) | Create and run an agent using AsAIAgent extensions | +| [Multi-turn conversation](./Agent_Step02.1_MultiturnConversation/) | Multi-turn using sessions and response ID chaining | +| [Multi-turn with server conversations](./Agent_Step02.2_MultiturnWithServerConversations/) | Server-side conversations visible in Foundry UI | +| [Using function tools](./Agent_Step03_UsingFunctionTools/) | Function tools | +| [Function tools with approvals](./Agent_Step04_UsingFunctionToolsWithApprovals/) | Human-in-the-loop approval | +| [Structured output](./Agent_Step05_StructuredOutput/) | Structured output with JSON schema | +| [Persisted conversations](./Agent_Step06_PersistedConversations/) | Persisting and resuming conversations | +| [Observability](./Agent_Step07_Observability/) | OpenTelemetry observability | +| [Dependency injection](./Agent_Step08_DependencyInjection/) | DI with a hosted service | +| [Using MCP client as tools](./Agent_Step09_UsingMcpClientAsTools/) | MCP client tools | +| [Using images](./Agent_Step10_UsingImages/) | Image multi-modality | +| [Agent as function tool](./Agent_Step11_AsFunctionTool/) | Agent as a function tool for another | +| [Middleware](./Agent_Step12_Middleware/) | Multiple middleware layers | +| [Plugins](./Agent_Step13_Plugins/) | Plugins with dependency injection | +| [Code interpreter](./Agent_Step14_CodeInterpreter/) | Code interpreter tool | +| [Computer use](./Agent_Step15_ComputerUse/) | Computer use tool | +| [File search](./Agent_Step16_FileSearch/) | File search tool | +| [OpenAPI tools](./Agent_Step17_OpenAPITools/) | OpenAPI tools | +| [Bing custom search](./Agent_Step18_BingCustomSearch/) | Bing Custom Search tool | +| [SharePoint](./Agent_Step19_SharePoint/) | SharePoint grounding tool | +| [Microsoft Fabric](./Agent_Step20_MicrosoftFabric/) | Microsoft Fabric tool | +| [Web search](./Agent_Step21_WebSearch/) | Web search tool | +| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool | +| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport | + +## Running the samples + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\FoundryAgent_Step01 +``` \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs deleted file mode 100644 index 1e1e48d54b..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Azure AI Foundry's Red Teaming service to assess -// the safety and resilience of an AI model against adversarial attacks. -// -// It uses the RedTeam API from Azure.AI.Projects to run automated attack simulations -// with various attack strategies (encoding, obfuscation, jailbreaks) across multiple -// risk categories (Violence, HateUnfairness, Sexual, SelfHarm). -// -// For more details, see: -// https://learn.microsoft.com/azure/ai-foundry/concepts/ai-red-teaming-agent - -using Azure.AI.Projects; -using Azure.Identity; - -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"; - -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine("RED TEAMING EVALUATION SAMPLE"); -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine(); - -// Initialize Azure credentials and clients -// 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. -DefaultAzureCredential credential = new(); -AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); - -// Configure the target model for red teaming -AzureOpenAIModelConfiguration targetConfig = new(deploymentName); - -// Create the red team run configuration -RedTeam redTeamConfig = new(targetConfig) -{ - DisplayName = "FinancialAdvisor-RedTeam", - ApplicationScenario = "A financial advisor assistant that provides general financial advice and information.", - NumTurns = 3, - RiskCategories = - { - RiskCategory.Violence, - RiskCategory.HateUnfairness, - RiskCategory.Sexual, - RiskCategory.SelfHarm, - }, - AttackStrategies = - { - AttackStrategy.Easy, - AttackStrategy.Moderate, - AttackStrategy.Jailbreak, - }, -}; - -Console.WriteLine($"Target model: {deploymentName}"); -Console.WriteLine("Risk categories: Violence, HateUnfairness, Sexual, SelfHarm"); -Console.WriteLine("Attack strategies: Easy, Moderate, Jailbreak"); -Console.WriteLine($"Simulation turns: {redTeamConfig.NumTurns}"); -Console.WriteLine(); - -// Submit the red team run to the service -Console.WriteLine("Submitting red team run..."); -RedTeam redTeamRun = await aiProjectClient.RedTeams.CreateAsync(redTeamConfig, options: null); - -Console.WriteLine($"Red team run created: {redTeamRun.Name}"); -Console.WriteLine($"Status: {redTeamRun.Status}"); -Console.WriteLine(); - -// Poll for completion -Console.WriteLine("Waiting for red team run to complete (this may take several minutes)..."); -while (redTeamRun.Status != "Completed" && redTeamRun.Status != "Failed" && redTeamRun.Status != "Canceled") -{ - await Task.Delay(TimeSpan.FromSeconds(15)); - redTeamRun = await aiProjectClient.RedTeams.GetAsync(redTeamRun.Name); - Console.WriteLine($" Status: {redTeamRun.Status}"); -} - -Console.WriteLine(); - -if (redTeamRun.Status == "Completed") -{ - Console.WriteLine("Red team run completed successfully!"); - Console.WriteLine(); - Console.WriteLine("Results:"); - Console.WriteLine(new string('-', 80)); - Console.WriteLine($" Run name: {redTeamRun.Name}"); - Console.WriteLine($" Display name: {redTeamRun.DisplayName}"); - Console.WriteLine($" Status: {redTeamRun.Status}"); - - Console.WriteLine(); - Console.WriteLine("Review the detailed results in the Azure AI Foundry portal:"); - Console.WriteLine($" {endpoint}"); -} -else -{ - Console.WriteLine($"Red team run ended with status: {redTeamRun.Status}"); -} - -Console.WriteLine(); -Console.WriteLine(new string('=', 80)); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md deleted file mode 100644 index 24e4a62b35..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# Red Teaming with Azure AI Foundry (Classic) - -> [!IMPORTANT] -> This sample uses the **classic Azure AI Foundry** red teaming API (`/redTeams/runs`) via `Azure.AI.Projects`. Results are viewable in the classic Foundry portal experience. The **new Foundry** portal's red teaming feature uses a different evaluation-based API that is not yet available in the .NET SDK. - -This sample demonstrates how to use Azure AI Foundry's Red Teaming service to assess the safety and resilience of an AI model against adversarial attacks. - -## What this sample demonstrates - -- Configuring a red team run targeting an Azure OpenAI model deployment -- Using multiple `AttackStrategy` options (Easy, Moderate, Jailbreak) -- Evaluating across `RiskCategory` categories (Violence, HateUnfairness, Sexual, SelfHarm) -- Submitting a red team scan and polling for completion -- Reviewing results in the Azure AI Foundry portal - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure AI Foundry project (hub and project created) -- Azure OpenAI deployment (e.g., gpt-4o or gpt-4o-mini) -- Azure CLI installed and authenticated (for Azure credential authentication) - -### Regional Requirements - -Red teaming is only available in regions that support risk and safety evaluators: -- **East US 2**, **Sweden Central**, **US North Central**, **France Central**, **Switzerland West** - -### Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" # Replace with your Azure Foundry project endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming -dotnet run -``` - -## Expected behavior - -The sample will: - -1. Configure a `RedTeam` run targeting the specified model deployment -2. Define risk categories and attack strategies -3. Submit the scan to Azure AI Foundry's Red Teaming service -4. Poll for completion (this may take several minutes) -5. Display the run status and direct you to the Azure AI Foundry portal for detailed results - -## Understanding Red Teaming - -### Attack Strategies - -| Strategy | Description | -|----------|-------------| -| Easy | Simple encoding/obfuscation attacks (ROT13, Leetspeak, etc.) | -| Moderate | Moderate complexity attacks requiring an LLM for orchestration | -| Jailbreak | Crafted prompts designed to bypass AI safeguards (UPIA) | - -### Risk Categories - -| Category | Description | -|----------|-------------| -| Violence | Content related to violence | -| HateUnfairness | Hate speech or unfair content | -| Sexual | Sexual content | -| SelfHarm | Self-harm related content | - -### Interpreting Results - -- Results are available in the Azure AI Foundry portal (**classic view** — toggle at top-right) under the red teaming section -- Lower Attack Success Rate (ASR) is better — target ASR < 5% for production -- Review individual attack conversations to understand vulnerabilities - -### Current Limitations - -> [!NOTE] -> - The .NET Red Teaming API (`Azure.AI.Projects`) currently supports targeting **model deployments only** via `AzureOpenAIModelConfiguration`. The `AzureAIAgentTarget` type exists in the SDK but is consumed by the **Evaluation Taxonomy** API (`/evaluationtaxonomies`), not by the Red Teaming API (`/redTeams/runs`). -> - Agent-targeted red teaming with agent-specific risk categories (Prohibited actions, Sensitive data leakage, Task adherence) is documented in the [concept docs](https://learn.microsoft.com/azure/ai-foundry/concepts/ai-red-teaming-agent) but is not yet available via the public REST API or .NET SDK. -> - Results from this API appear in the **classic** Azure AI Foundry portal view. The new Foundry portal uses a separate evaluation-based system with `eval_*` identifiers. - -## Related Resources - -- [Azure AI Red Teaming Agent](https://learn.microsoft.com/azure/ai-foundry/concepts/ai-red-teaming-agent) -- [RedTeam .NET API Reference](https://learn.microsoft.com/dotnet/api/azure.ai.projects.redteam?view=azure-dotnet-preview) -- [Risk and Safety Evaluations](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-metrics-built-in#risk-and-safety-evaluators) - -## Next Steps - -After running red teaming: -1. Review attack results and strengthen agent guardrails -2. Explore the Self-Reflection sample (FoundryAgents_Evaluations_Step02_SelfReflection) for quality assessment -3. Set up continuous red teaming in your CI/CD pipeline diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj deleted file mode 100644 index 646cd75532..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs deleted file mode 100644 index 8f8c9fa4ee..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Microsoft.Extensions.AI.Evaluation.Quality to evaluate -// an Agent Framework agent's response quality with a self-reflection loop. -// -// It uses GroundednessEvaluator, RelevanceEvaluator, and CoherenceEvaluator to score responses, -// then iteratively asks the agent to improve based on evaluation feedback. -// -// Based on: Reflexion: Language Agents with Verbal Reinforcement Learning (NeurIPS 2023) -// Reference: https://arxiv.org/abs/2303.11366 -// -// For more details, see: -// https://learn.microsoft.com/dotnet/ai/evaluation/libraries - -using Azure.AI.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Evaluation; -using Microsoft.Extensions.AI.Evaluation.Quality; -using Microsoft.Extensions.AI.Evaluation.Safety; - -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; -using ChatRole = Microsoft.Extensions.AI.ChatRole; - -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"; -string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string evaluatorDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? deploymentName; - -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine("SELF-REFLECTION EVALUATION SAMPLE"); -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine(); - -// Initialize Azure credentials and client -// 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. -DefaultAzureCredential credential = new(); -AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); - -// Set up the LLM-based chat client for quality evaluators -IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential) - .GetChatClient(evaluatorDeploymentName) - .AsIChatClient(); - -// Configure evaluation: quality evaluators use the LLM, safety evaluators use Azure AI Foundry -ContentSafetyServiceConfiguration safetyConfig = new( - credential: credential, - endpoint: new Uri(endpoint)); - -ChatConfiguration chatConfiguration = safetyConfig.ToChatConfiguration( - originalChatConfiguration: new ChatConfiguration(chatClient)); - -// Create a test agent -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: "KnowledgeAgent", - model: deploymentName, - instructions: "You are a helpful assistant. Answer questions accurately based on the provided context."); -Console.WriteLine($"Created agent: {agent.Name}"); -Console.WriteLine(); - -// Example question and grounding context -const string Question = """ - What are the main benefits of using Azure AI Foundry for building AI applications? - """; - -const string Context = """ - Azure AI Foundry is a comprehensive platform for building, deploying, and managing AI applications. - Key benefits include: - 1. Unified development environment with support for multiple AI frameworks and models - 2. Built-in safety and security features including content filtering and red teaming tools - 3. Scalable infrastructure that handles deployment and monitoring automatically - 4. Integration with Azure services like Azure OpenAI, Cognitive Services, and Machine Learning - 5. Evaluation tools for assessing model quality, safety, and performance - 6. Support for RAG (Retrieval-Augmented Generation) patterns with vector search - 7. Enterprise-grade compliance and governance features - """; - -Console.WriteLine("Question:"); -Console.WriteLine(Question); -Console.WriteLine(); - -// Run evaluations -try -{ - await RunSelfReflectionWithGroundedness(agent, Question, Context, chatConfiguration); - await RunQualityEvaluation(agent, Question, Context, chatConfiguration); - await RunCombinedQualityAndSafetyEvaluation(agent, Question, chatConfiguration); -} -finally -{ - // Cleanup - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - Console.WriteLine(); - Console.WriteLine("Cleanup: Agent deleted."); -} - -// ============================================================================ -// Implementation Functions -// ============================================================================ - -static async Task RunSelfReflectionWithGroundedness( - AIAgent agent, string question, string context, ChatConfiguration chatConfiguration) -{ - Console.WriteLine("Running Self-Reflection with Groundedness Evaluation..."); - Console.WriteLine(); - - GroundednessEvaluator groundednessEvaluator = new(); - GroundednessEvaluatorContext groundingContext = new(context); - - const int MaxReflections = 3; - double bestScore = 0; - - string currentPrompt = $"Context: {context}\n\nQuestion: {question}"; - - for (int i = 0; i < MaxReflections; i++) - { - Console.WriteLine($"Iteration {i + 1}/{MaxReflections}:"); - Console.WriteLine(new string('-', 40)); - - // Create a new session for each reflection iteration so that - // conversation context does not carry over between runs. This keeps - // each evaluation independent and avoids biasing groundedness scores. - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse agentResponse = await agent.RunAsync(currentPrompt, session); - string responseText = agentResponse.Text; - - Console.WriteLine($"Response: {responseText[..Math.Min(150, responseText.Length)]}..."); - - List messages = - [ - new(ChatRole.User, currentPrompt), - ]; - ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, responseText)); - - EvaluationResult result = await groundednessEvaluator.EvaluateAsync( - messages, - chatResponse, - chatConfiguration, - additionalContext: [groundingContext]); - - NumericMetric groundedness = result.Get(GroundednessEvaluator.GroundednessMetricName); - double score = groundedness.Value ?? 0; - string rating = groundedness.Interpretation?.Rating.ToString() ?? "N/A"; - - Console.WriteLine($"Groundedness score: {score:F1}/5 (Rating: {rating})"); - Console.WriteLine(); - - if (score > bestScore) - { - bestScore = score; - } - - if (score >= 4.0 || i == MaxReflections - 1) - { - if (score >= 4.0) - { - Console.WriteLine("Good groundedness achieved!"); - } - - break; - } - - // Ask for improvement in the next iteration, including the previous response - // so the LLM knows what to improve on (each iteration uses a new session). - currentPrompt = $""" - Context: {context} - - Your previous answer scored {score}/5 on groundedness. - Your previous answer was: - {responseText} - - Please improve your answer to be more grounded in the provided context. - Only include information that is directly supported by the context. - - Question: {question} - """; - Console.WriteLine("Requesting improvement..."); - Console.WriteLine(); - } - - Console.WriteLine($"Best groundedness score: {bestScore:F1}/5"); - Console.WriteLine(new string('=', 80)); - Console.WriteLine(); -} - -static async Task RunQualityEvaluation( - AIAgent agent, string question, string context, ChatConfiguration chatConfiguration) -{ - Console.WriteLine("Running Quality Evaluation (Relevance, Coherence, Groundedness)..."); - Console.WriteLine(); - - IEvaluator[] evaluators = - [ - new RelevanceEvaluator(), - new CoherenceEvaluator(), - new GroundednessEvaluator(), - ]; - - CompositeEvaluator compositeEvaluator = new(evaluators); - GroundednessEvaluatorContext groundingContext = new(context); - - string prompt = $"Context: {context}\n\nQuestion: {question}"; - - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse agentResponse = await agent.RunAsync(prompt, session); - string responseText = agentResponse.Text; - - Console.WriteLine($"Response: {responseText[..Math.Min(150, responseText.Length)]}..."); - Console.WriteLine(); - - List messages = - [ - new(ChatRole.User, prompt), - ]; - ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, responseText)); - - EvaluationResult result = await compositeEvaluator.EvaluateAsync( - messages, - chatResponse, - chatConfiguration, - additionalContext: [groundingContext]); - - foreach (EvaluationMetric metric in result.Metrics.Values) - { - if (metric is NumericMetric n) - { - string rating = n.Interpretation?.Rating.ToString() ?? "N/A"; - Console.WriteLine($" {n.Name,-20} Score: {n.Value:F1}/5 Rating: {rating}"); - } - } - - Console.WriteLine(new string('=', 80)); - Console.WriteLine(); -} - -static async Task RunCombinedQualityAndSafetyEvaluation( - AIAgent agent, string question, ChatConfiguration chatConfiguration) -{ - Console.WriteLine("Running Combined Quality + Safety Evaluation..."); - Console.WriteLine(); - - IEvaluator[] evaluators = - [ - new RelevanceEvaluator(), - new CoherenceEvaluator(), - new ContentHarmEvaluator(), - new ProtectedMaterialEvaluator(), - ]; - - CompositeEvaluator compositeEvaluator = new(evaluators); - - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse agentResponse = await agent.RunAsync(question, session); - string responseText = agentResponse.Text; - - Console.WriteLine($"Response: {responseText[..Math.Min(150, responseText.Length)]}..."); - Console.WriteLine(); - - List messages = - [ - new(ChatRole.User, question), // No context in this evaluation — testing quality and safety on raw question - ]; - ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, responseText)); - - EvaluationResult result = await compositeEvaluator.EvaluateAsync( - messages, - chatResponse, - chatConfiguration); - - Console.WriteLine("Quality Metrics:"); - foreach (EvaluationMetric metric in result.Metrics.Values) - { - if (metric is NumericMetric n) - { - string rating = n.Interpretation?.Rating.ToString() ?? "N/A"; - bool failed = n.Interpretation?.Failed ?? false; - Console.WriteLine($" {n.Name,-25} Score: {n.Value:F1,-6} Rating: {rating,-15} Failed: {failed}"); - } - else if (metric is BooleanMetric b) - { - string rating = b.Interpretation?.Rating.ToString() ?? "N/A"; - bool failed = b.Interpretation?.Failed ?? false; - Console.WriteLine($" {b.Name,-25} Value: {b.Value,-6} Rating: {rating,-15} Failed: {failed}"); - } - } - - Console.WriteLine(new string('=', 80)); -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md deleted file mode 100644 index d71eeca6af..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Self-Reflection Evaluation with Groundedness Assessment - -This sample demonstrates the self-reflection pattern using Agent Framework with `Microsoft.Extensions.AI.Evaluation.Quality` evaluators. The agent iteratively improves its responses based on real groundedness evaluation scores. - -For details on the self-reflection approach, see [Reflexion: Language Agents with Verbal Reinforcement Learning](https://arxiv.org/abs/2303.11366) (NeurIPS 2023). - -## What this sample demonstrates - -- Self-reflection loop that improves responses using real `GroundednessEvaluator` scores -- Using `RelevanceEvaluator` and `CoherenceEvaluator` for multi-metric quality assessment -- Combining quality and safety evaluators with `CompositeEvaluator` -- Configuring `ContentSafetyServiceConfiguration` for safety evaluators alongside LLM-based quality evaluators -- Tracking improvement across iterations - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure AI Foundry project (hub and project created) -- Azure OpenAI deployment (e.g., gpt-4o or gpt-4o-mini) -- 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). - -### Azure Resources Required - -1. **Azure AI Hub and Project**: Create these in the Azure Portal - - Follow: https://learn.microsoft.com/azure/ai-foundry/how-to/create-projects -2. **Azure OpenAI Deployment**: Deploy a model (e.g., gpt-4o or gpt-4o-mini) - - Agent model: Used to generate responses - - Evaluator model: Quality evaluators use an LLM; best results with GPT-4o -3. **Azure CLI**: Install and authenticate with `az login` - -### Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.api.azureml.ms" # Azure Foundry project endpoint -$env:AZURE_OPENAI_ENDPOINT="https://your-openai.openai.azure.com/" # Azure OpenAI endpoint (for quality evaluators) -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Model deployment name -``` - -**Note**: For best evaluation results, use GPT-4o or GPT-4o-mini as the evaluator model. The groundedness evaluator has been tested and tuned for these models. - -## Run the sample - -Navigate to the sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection -dotnet run -``` - -## Expected behavior - -The sample runs three evaluation scenarios: - -### 1. Self-Reflection with Groundedness -- Asks a question with grounding context -- Evaluates response groundedness using `GroundednessEvaluator` -- If score is below 4/5, asks the agent to improve with feedback -- Repeats up to 3 iterations -- Tracks and reports the best score achieved - -### 2. Quality Evaluation -- Evaluates a single response with multiple quality evaluators: - - `RelevanceEvaluator` — is the response relevant to the question? - - `CoherenceEvaluator` — is the response logically coherent? - - `GroundednessEvaluator` — is the response grounded in the provided context? - -### 3. Combined Quality + Safety Evaluation -- Runs both quality and safety evaluators together: - - `RelevanceEvaluator`, `CoherenceEvaluator` (quality) - - `ContentHarmEvaluator` (safety — violence, hate, sexual, self-harm) - - `ProtectedMaterialEvaluator` (safety — copyrighted content detection) - -## Understanding the Evaluation - -### Groundedness Score (1-5 scale) - -The `GroundednessEvaluator` measures how well the agent's response is grounded in the provided context: - -- **5** = Excellent - Response is fully grounded in context -- **4** = Good - Mostly grounded with minor deviations -- **3** = Fair - Partially grounded but includes unsupported claims -- **2** = Poor - Significant amount of ungrounded content -- **1** = Very Poor - Response is largely unsupported by context - -### Self-Reflection Process - -1. **Initial Response**: Agent generates answer based on question + context -2. **Evaluation**: `GroundednessEvaluator` scores the response (1-5) -3. **Feedback**: If score < 4, agent receives the score and is asked to improve -4. **Iteration**: Process repeats until good score or max iterations - -## Best Practices - -1. **Provide Complete Context**: Ensure grounding context contains all information needed to answer the question -2. **Clear Instructions**: Give the agent clear instructions about staying grounded in context -3. **Use Quality Models**: GPT-4o recommended for evaluation tasks -4. **Multiple Evaluators**: Use combination of evaluators (groundedness + relevance + coherence) -5. **Batch Processing**: For production, process multiple questions in batch - -## Related Resources - -- [Reflexion Paper (NeurIPS 2023)](https://arxiv.org/abs/2303.11366) -- [Microsoft.Extensions.AI.Evaluation Libraries](https://learn.microsoft.com/dotnet/ai/evaluation/libraries) -- [GroundednessEvaluator API Reference](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.evaluation.quality.groundednessevaluator) -- [Azure AI Foundry Evaluation Service](https://learn.microsoft.com/azure/ai-foundry/how-to/develop/evaluate-sdk) - -## Next Steps - -After running self-reflection evaluation: -1. Implement similar patterns for other quality metrics (relevance, coherence, fluency) -2. Integrate into CI/CD pipeline for continuous quality assurance -3. Explore the Safety Evaluation sample (FoundryAgents_Evaluations_Step01_RedTeaming) for content safety assessment diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs deleted file mode 100644 index f4521d8898..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use AI agents with Azure Foundry Agents as the backend. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; - -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"; - -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); - -// Azure.AI.Agents SDK creates and manages agent by name and versions. -// You can create a server side agent version with the Azure.AI.Agents SDK client below. -AgentVersion createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); - -// Note: -// agentVersion.Id = ":", -// agentVersion.Version = , -// agentVersion.Name = - -// You can use an AIAgent with an already created server side agent version. -AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); - -// You can also create another AIAgent version by providing the same name with a different definition/instruction. -AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes."); - -// You can also get the AIAgent latest version by just providing its name. -AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName); -AgentVersion latestAgentVersion = jokerAgentLatest.GetService()!; - -// The AIAgent version can be accessed via the GetService method. -Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); - -// Once you have the AIAgent, you can invoke it like any other AIAgent. -Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.")); - -// Cleanup by agent name removes both agent versions created. -await aiProjectClient.Agents.DeleteAgentAsync(existingJokerAgent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md deleted file mode 100644 index ce5eca8277..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Creating and Managing AI Agents with Versioning - -This sample demonstrates how to create and manage AI agents with Azure Foundry Agents, including: -- Creating agents with different versions -- Retrieving agents by version or latest version -- Running multi-turn conversations with agents -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step01.1_Basics -``` - -## What this sample demonstrates - -1. **Creating agents with versions**: Shows how to create multiple versions of the same agent with different instructions -2. **Retrieving agents**: Demonstrates retrieving agents by specific version or getting the latest version -3. **Multi-turn conversations**: Shows how to use threads to maintain conversation context across multiple agent runs -4. **Agent cleanup**: Demonstrates proper resource cleanup by deleting agents diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs deleted file mode 100644 index 0bc17aff0a..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs +++ /dev/null @@ -1,39 +0,0 @@ -// 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. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; - -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"; - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); - -// Azure.AI.Agents SDK creates and manages agent by name and versions. -// You can create a server side agent version with the Azure.AI.Agents SDK client below. -AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); - -// You can use an AIAgent with an already created server side agent version. -AIAgent jokerAgent = aiProjectClient.AsAIAgent(agentVersion); - -// Invoke the agent with streaming support. -await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.")) -{ - Console.WriteLine(update); -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md deleted file mode 100644 index 40cb5e107d..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Running a Simple AI Agent with Streaming - -This sample demonstrates how to create and run a simple AI agent with Azure Foundry Agents, including both text and streaming responses. - -## What this sample demonstrates - -- Creating a simple AI agent with instructions -- Running an agent with text output -- Running an agent with streaming output -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step01.2_Running -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "JokerAgent" with instructions to tell jokes -2. Run the agent with a text prompt and display the response -3. Run the agent again with streaming to display the response as it's generated -4. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs deleted file mode 100644 index 7bf12094fc..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use a simple AI agent with a multi-turn conversation. - -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; - -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"; - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); - -// Retrieve an AIAgent for the created server side agent version. -ChatClientAgent jokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, options); - -// Invoke the agent with a multi-turn conversation, where the context is preserved in the session object. -// Create a conversation in the server -ProjectConversationsClient conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient(); -ProjectConversation conversation = await conversationsClient.CreateProjectConversationAsync(); - -// Providing the conversation Id is not strictly necessary, but by not providing it no information will show up in the Foundry Project UI as conversations. -// Sessions that don't have a conversation Id will work based on the `PreviousResponseId`. -AgentSession session = await jokerAgent.CreateSessionAsync(conversation.Id); - -Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", session)); -Console.WriteLine(await jokerAgent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)); - -// Invoke the agent with a multi-turn conversation and streaming, where the context is preserved in the session object. -session = await jokerAgent.CreateSessionAsync(conversation.Id); -await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", session)) -{ - Console.WriteLine(update); -} -await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)) -{ - Console.WriteLine(update); -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name); - -// Cleanup the conversation created. -await conversationsClient.DeleteConversationAsync(conversation.Id); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md deleted file mode 100644 index 86721bf960..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Multi-turn Conversation with AI Agents - -This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads and conversation IDs. - -## What this sample demonstrates - -- Creating an AI agent with instructions -- Creating a project conversation to track conversations in the Foundry UI -- Using threads with conversation IDs to maintain conversation context -- Running multi-turn conversations with text output -- Running multi-turn conversations with streaming output -- Managing agent and conversation lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step02_MultiturnConversation -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "JokerAgent" with instructions to tell jokes -2. Create a project conversation to enable visibility in the Azure Foundry UI -3. Create a thread linked to the conversation ID for context tracking -4. Run the agent with a text prompt and display the response -5. Send a follow-up message to the same thread, demonstrating context preservation -6. Create a new thread sharing the same conversation ID and run the agent with streaming -7. Send a follow-up streaming message to demonstrate multi-turn streaming -8. Clean up resources by deleting the agent and conversation - -## Conversation ID vs PreviousResponseId - -When working with multi-turn conversations, there are two approaches: - -- **With Conversation ID**: By passing a `conversation.Id` to `CreateSessionAsync()`, the conversation will be visible in the Azure Foundry Project UI. This is useful for tracking and debugging conversations. -- **Without Conversation ID**: Sessions created without a conversation ID still work correctly, maintaining context via `PreviousResponseId`. However, these conversations may not appear in the Foundry UI. - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs deleted file mode 100644 index cfd74000a6..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use an agent with function tools. -// It shows both non-streaming and streaming agent interactions using weather-related tools. - -using System.ComponentModel; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -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"; - -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -const string AssistantInstructions = "You are a helpful assistant that can get weather information."; -const string AssistantName = "WeatherAssistant"; - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent with function tools. -AITool tool = AIFunctionFactory.Create(GetWeather); - -// Create AIAgent directly -var newAgent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]); - -// Getting an already existing agent by name with tools. -/* - * IMPORTANT: Since agents that are stored in the server only know the definition of the function tools (JSON Schema), - * you need to provided all invocable function tools when retrieving the agent so it can invoke them automatically. - * If no invocable tools are provided, the function calling needs to handled manually. - */ -var existingAgent = await aiProjectClient.GetAIAgentAsync(name: AssistantName, tools: [tool]); - -// Non-streaming agent interaction with function tools. -AgentSession session = await existingAgent.CreateSessionAsync(); -Console.WriteLine(await existingAgent.RunAsync("What is the weather like in Amsterdam?", session)); - -// Streaming agent interaction with function tools. -session = await existingAgent.CreateSessionAsync(); -await foreach (AgentResponseUpdate update in existingAgent.RunStreamingAsync("What is the weather like in Amsterdam?", session)) -{ - Console.WriteLine(update); -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(existingAgent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md deleted file mode 100644 index fa9b5baf21..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Using Function Tools with AI Agents - -This sample demonstrates how to use function tools with AI agents, allowing agents to call custom functions to retrieve information. - -## What this sample demonstrates - -- Creating function tools using AIFunctionFactory -- Passing function tools to an AI agent -- Running agents with function tools (text output) -- Running agents with function tools (streaming output) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step03.1_UsingFunctionTools -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "WeatherAssistant" with a GetWeather function tool -2. Run the agent with a text prompt asking about weather -3. The agent will invoke the GetWeather function tool to retrieve weather information -4. Run the agent again with streaming to display the response as it's generated -5. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md deleted file mode 100644 index 42cbd6ba32..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Using Function Tools with Approvals (Human-in-the-Loop) - -This sample demonstrates how to use function tools that require human approval before execution, implementing a human-in-the-loop workflow. - -## What this sample demonstrates - -- Creating approval-required function tools using ApprovalRequiredAIFunction -- Handling user input requests for function approvals -- Implementing human-in-the-loop approval workflows -- Processing agent responses with pending approvals -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step04_UsingFunctionToolsWithApprovals -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "WeatherAssistant" with an approval-required GetWeather function tool -2. Run the agent with a prompt asking about weather -3. The agent will request approval before invoking the GetWeather function -4. The sample will prompt the user to approve or deny the function call (enter 'Y' to approve) -5. After approval, the function will be executed and the result returned to the agent -6. Clean up resources by deleting the agent - -**Note**: For hosted agents with remote users, combine this sample with the Persisted Conversations sample to persist chat history while waiting for user approval. - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj deleted file mode 100644 index daf7e24494..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md deleted file mode 100644 index 4c44230e18..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Structured Output with AI Agents - -This sample demonstrates how to configure AI agents to produce structured output in JSON format using JSON schemas. - -## What this sample demonstrates - -- Configuring agents with JSON schema response formats -- Using generic RunAsync method for structured output -- Deserializing structured responses into typed objects -- Running agents with streaming and structured output -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step05_StructuredOutput -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "StructuredOutputAssistant" configured to produce JSON output -2. Run the agent with a prompt to extract person information -3. Deserialize the JSON response into a PersonInfo object -4. Display the structured data (Name, Age, Occupation) -5. Run the agent again with streaming and deserialize the streamed JSON response -6. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj deleted file mode 100644 index daf7e24494..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md deleted file mode 100644 index 57a032e9ec..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Persisted Conversations with AI Agents - -This sample demonstrates how to serialize and persist agent conversation threads to storage, allowing conversations to be resumed later. - -## What this sample demonstrates - -- Serializing agent threads to JSON -- Persisting thread state to disk -- Loading and deserializing thread state from storage -- Resuming conversations with persisted threads -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step06_PersistedConversations -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "JokerAgent" with instructions to tell jokes -2. Create a thread and run the agent with an initial prompt -3. Serialize the thread state to JSON -4. Save the serialized thread to a temporary file -5. Load the thread from the file and deserialize it -6. Resume the conversation with the same thread using a follow-up prompt -7. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md deleted file mode 100644 index 459434bce2..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Observability with OpenTelemetry - -This sample demonstrates how to add observability to AI agents using OpenTelemetry for tracing and monitoring. - -## What this sample demonstrates - -- Setting up OpenTelemetry TracerProvider -- Configuring console exporter for telemetry output -- Configuring Azure Monitor exporter for Application Insights -- Adding OpenTelemetry middleware to agents -- Running agents with telemetry collection (text and streaming) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- (Optional) Application Insights connection string for Azure Monitor integration - -**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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:APPLICATIONINSIGHTS_CONNECTION_STRING="your-connection-string" # Optional, for Azure Monitor integration -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step07_Observability -``` - -## Expected behavior - -The sample will: - -1. Create a TracerProvider with console exporter (and optionally Azure Monitor exporter) -2. Create an agent named "JokerAgent" with OpenTelemetry middleware -3. Run the agent with a text prompt and display telemetry traces to console -4. Run the agent again with streaming and display telemetry traces -5. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs deleted file mode 100644 index b7a9874e7b..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use dependency injection to register an AIAgent and use it from a hosted service with a user input chat loop. - -using System.ClientModel; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -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"; - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// 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. -AIProjectClient aIProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Create a new agent if one doesn't exist already. -ChatClientAgent agent; -try -{ - agent = await aIProjectClient.GetAIAgentAsync(name: JokerName); -} -catch (ClientResultException ex) when (ex.Status == 404) -{ - agent = await aIProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions); -} - -// Create a host builder that we will register services with and then run. -HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); - -// Add the agents client to the service collection. -builder.Services.AddSingleton((sp) => aIProjectClient); - -// Add the AI agent to the service collection. -builder.Services.AddSingleton((sp) => agent); - -// Add a sample service that will use the agent to respond to user input. -builder.Services.AddHostedService(); - -// Build and run the host. -using IHost host = builder.Build(); -await host.RunAsync().ConfigureAwait(false); - -/// -/// A sample service that uses an AI agent to respond to user input. -/// -internal sealed class SampleService(AIProjectClient client, AIAgent agent, IHostApplicationLifetime appLifetime) : IHostedService -{ - private AgentSession? _session; - - public async Task StartAsync(CancellationToken cancellationToken) - { - // Create a session that will be used for the entirety of the service lifetime so that the user can ask follow up questions. - this._session = await agent.CreateSessionAsync(cancellationToken); - _ = this.RunAsync(appLifetime.ApplicationStopping); - } - - public async Task RunAsync(CancellationToken cancellationToken) - { - // Delay a little to allow the service to finish starting. - await Task.Delay(100, cancellationToken); - - while (!cancellationToken.IsCancellationRequested) - { - Console.WriteLine("\nAgent: Ask me to tell you a joke about a specific topic. To exit just press Ctrl+C or enter without any input.\n"); - Console.Write("> "); - string? input = Console.ReadLine(); - - // If the user enters no input, signal the application to shut down. - if (string.IsNullOrWhiteSpace(input)) - { - appLifetime.StopApplication(); - break; - } - - // Stream the output to the console as it is generated. - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, this._session, cancellationToken: cancellationToken)) - { - Console.Write(update); - } - - Console.WriteLine(); - } - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - Console.WriteLine("\nDeleting agent ..."); - await client.Agents.DeleteAgentAsync(agent.Name, cancellationToken).ConfigureAwait(false); - } -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md deleted file mode 100644 index 12760e736f..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Dependency Injection with AI Agents - -This sample demonstrates how to use dependency injection to register and manage AI agents within a hosted service application. - -## What this sample demonstrates - -- Setting up dependency injection with HostApplicationBuilder -- Registering AIProjectClient as a singleton service -- Registering AIAgent as a singleton service -- Using agents in hosted services -- Interactive chat loop with streaming responses -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step08_DependencyInjection -``` - -## Expected behavior - -The sample will: - -1. Create a host with dependency injection configured -2. Register AIProjectClient and AIAgent as services -3. Create an agent named "JokerAgent" with instructions to tell jokes -4. Start an interactive chat loop where you can ask the agent questions -5. The agent will respond with streaming output -6. Enter an empty line or press Ctrl+C to exit -7. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj deleted file mode 100644 index a6d96cb3db..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - 3afc9b74-af74-4d8e-ae96-fa1c511d11ac - - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs deleted file mode 100644 index e1968122a4..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to expose an AI agent as an MCP tool. - -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Client; - -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"; - -Console.WriteLine("Starting MCP Stdio for @modelcontextprotocol/server-github ... "); - -// Create an MCPClient for the GitHub server -await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new() -{ - Name = "MCPServer", - Command = "npx", - Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-github"], -})); - -// Retrieve the list of tools available on the GitHub server -IList mcpTools = await mcpClient.ListToolsAsync(); -string agentName = "AgentWithMCP"; -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -Console.WriteLine($"Creating the agent '{agentName}' ..."); - -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: agentName, - model: deploymentName, - instructions: "You answer questions related to GitHub repositories only.", - tools: [.. mcpTools.Cast()]); - -string prompt = "Summarize the last four commits to the microsoft/semantic-kernel repository?"; - -Console.WriteLine($"Invoking agent '{agent.Name}' with prompt: {prompt} ..."); - -// Invoke the agent and output the text result. -Console.WriteLine(await agent.RunAsync(prompt)); - -// Clean up the agent after use. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md deleted file mode 100644 index e4e3fe537a..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Using MCP Client Tools with AI Agents - -This sample demonstrates how to use Model Context Protocol (MCP) client tools with AI agents, allowing agents to access tools provided by MCP servers. This sample uses the GitHub MCP server to provide tools for querying GitHub repositories. - -## What this sample demonstrates - -- Creating MCP clients to connect to MCP servers (GitHub server) -- Retrieving tools from MCP servers -- Using MCP tools with AI agents -- Running agents with MCP-provided function tools -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- Node.js and npm installed (for running the GitHub MCP server) - -**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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step09_UsingMcpClientAsTools -``` - -## Expected behavior - -The sample will: - -1. Start the GitHub MCP server using `@modelcontextprotocol/server-github` -2. Create an MCP client to connect to the GitHub server -3. Retrieve the available tools from the GitHub MCP server -4. Create an agent named "AgentWithMCP" with the GitHub tools -5. Run the agent with a prompt to summarize the last four commits to the microsoft/semantic-kernel repository -6. The agent will use the GitHub MCP tools to query the repository information -7. Clean up resources by deleting the agent \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj deleted file mode 100644 index 53661ff199..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - - - Always - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md deleted file mode 100644 index 220104a291..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Using Images with AI Agents - -This sample demonstrates how to use image multi-modality with an AI agent. It shows how to create a vision-enabled agent that can analyze and describe images using Azure Foundry Agents. - -## What this sample demonstrates - -- Creating a vision-enabled AI agent with image analysis capabilities -- Sending both text and image content to an agent in a single message -- Using `UriContent` for URI-referenced images -- Processing multimodal input (text + image) with an AI agent -- Managing agent lifecycle (creation and deletion) - -## Key features - -- **Vision Agent**: Creates an agent specifically instructed to analyze images -- **Multimodal Input**: Combines text questions with image URI in a single message -- **Azure Foundry Agents Integration**: Uses Azure Foundry Agents with vision capabilities - -## Prerequisites - -Before running this sample, ensure you have: - -1. An Azure OpenAI project set up -2. A compatible model deployment (e.g., gpt-4o) -3. Azure CLI installed and authenticated - -## Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure Foundry Project endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" # Replace with your model deployment name (optional, defaults to gpt-4o) -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step10_UsingImages -``` - -## Expected behavior - -The sample will: - -1. Create a vision-enabled agent named "VisionAgent" -2. Send a message containing both text ("What do you see in this image?") and a URI-referenced image of a green walkway (nature boardwalk) -3. The agent will analyze the image and provide a description -4. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj deleted file mode 100644 index 54f37f1aa6..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - 3afc9b74-af74-4d8e-ae96-fa1c511d11ac - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md deleted file mode 100644 index 5da59b6edb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Using AI Agents as Function Tools (Nested Agents) - -This sample demonstrates how to expose an AI agent as a function tool, enabling nested agent scenarios where one agent can invoke another agent as a tool. - -## What this sample demonstrates - -- Creating an AI agent that can be used as a function tool -- Wrapping an agent as an AIFunction -- Using nested agents where one agent calls another -- Managing multiple agent instances -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step11_AsFunctionTool -``` - -## Expected behavior - -The sample will: - -1. Create a "JokerAgent" that tells jokes -2. Wrap the JokerAgent as a function tool -3. Create a "CoordinatorAgent" that has the JokerAgent as a function tool -4. Run the CoordinatorAgent with a prompt that triggers it to call the JokerAgent -5. The CoordinatorAgent will invoke the JokerAgent as a function tool -6. Clean up resources by deleting both agents - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md deleted file mode 100644 index 96d12d9828..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Agent Middleware - -This sample demonstrates how to add middleware to intercept agent runs and function calls to implement cross-cutting concerns like logging, validation, and guardrails. - -## What This Sample Shows - -1. Azure Foundry Agents integration via `AIProjectClient` and `DefaultAzureCredential` -2. Agent run middleware (logging and monitoring) -3. Function invocation middleware (logging and overriding tool results) -4. Per-request agent run middleware -5. Per-request function pipeline with approval -6. Combining agent-level and per-request middleware - -## Function Invocation Middleware - -Not all agents support function invocation middleware. - -Attempting to use function middleware on agents that do not wrap a ChatClientAgent or derives from it will throw an InvalidOperationException. - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Running the Sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step12_Middleware -``` - -## Expected Behavior - -When you run this sample, you will see the following demonstrations: - -1. **Example 1: Wording Guardrail** - The agent receives a request for harmful content. The guardrail middleware intercepts the request and prevents the agent from responding to harmful prompts, returning a safe response instead. - -2. **Example 2: PII Detection** - The agent receives a message containing personally identifiable information (name, phone number, email). The PII middleware detects and filters this sensitive information before processing. - -3. **Example 3: Agent Function Middleware** - The agent uses function tools (GetDateTime and GetWeather) to answer a question about the current time and weather in Seattle. The function middleware logs the function calls and can override results if needed. - -4. **Example 4: Human-in-the-Loop Function Approval** - The agent attempts to call a weather function, but the approval middleware intercepts the call and prompts the user to approve or deny the function invocation before it executes. The user can respond with "Y" to approve or any other input to deny. - -Each example demonstrates how middleware can be used to implement cross-cutting concerns and control agent behavior at different levels (agent-level and per-request). diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj deleted file mode 100644 index 4a34560946..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs deleted file mode 100644 index 244d83d632..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use plugins with an AI agent. Plugin classes can -// depend on other services that need to be injected. In this sample, the -// AgentPlugin class uses the WeatherProvider and CurrentTimeProvider classes -// to get weather and current time information. Both services are registered -// in the service collection and injected into the plugin. -// Plugin classes may have many methods, but only some are intended to be used -// as AI functions. The AsAITools method of the plugin class shows how to specify -// which methods should be exposed to the AI agent. - -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -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"; - -const string AssistantInstructions = "You are a helpful assistant that helps people find information."; -const string AssistantName = "PluginAssistant"; - -// Create a service collection to hold the agent plugin and its dependencies. -ServiceCollection services = new(); -services.AddSingleton(); -services.AddSingleton(); -services.AddSingleton(); // The plugin depends on WeatherProvider and CurrentTimeProvider registered above. - -IServiceProvider serviceProvider = services.BuildServiceProvider(); - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent with plugin tools -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: AssistantName, - model: deploymentName, - instructions: AssistantInstructions, - tools: serviceProvider.GetRequiredService().AsAITools().ToList(), - services: serviceProvider); - -// Invoke the agent and output the text result. -AgentSession session = await agent.CreateSessionAsync(); -Console.WriteLine(await agent.RunAsync("Tell me current time and weather in Seattle.", session)); - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - -/// -/// The agent plugin that provides weather and current time information. -/// -/// The weather provider to get weather information. -internal sealed class AgentPlugin(WeatherProvider weatherProvider) -{ - /// - /// Gets the weather information for the specified location. - /// - /// - /// This method demonstrates how to use the dependency that was injected into the plugin class. - /// - /// The location to get the weather for. - /// The weather information for the specified location. - public string GetWeather(string location) - { - return weatherProvider.GetWeather(location); - } - - /// - /// Gets the current date and time for the specified location. - /// - /// - /// This method demonstrates how to resolve a dependency using the service provider passed to the method. - /// - /// The service provider to resolve the . - /// The location to get the current time for. - /// The current date and time as a . - public DateTimeOffset GetCurrentTime(IServiceProvider sp, string location) - { - // Resolve the CurrentTimeProvider from the service provider - CurrentTimeProvider currentTimeProvider = sp.GetRequiredService(); - - return currentTimeProvider.GetCurrentTime(location); - } - - /// - /// Returns the functions provided by this plugin. - /// - /// - /// In real world scenarios, a class may have many methods and only a subset of them may be intended to be exposed as AI functions. - /// This method demonstrates how to explicitly specify which methods should be exposed to the AI agent. - /// - /// The functions provided by this plugin. - public IEnumerable AsAITools() - { - yield return AIFunctionFactory.Create(this.GetWeather); - yield return AIFunctionFactory.Create(this.GetCurrentTime); - } -} - -/// -/// The weather provider that returns weather information. -/// -internal sealed class WeatherProvider -{ - /// - /// Gets the weather information for the specified location. - /// - /// - /// The weather information is hardcoded for demonstration purposes. - /// In a real application, this could call a weather API to get actual weather data. - /// - /// The location to get the weather for. - /// The weather information for the specified location. - public string GetWeather(string location) - { - return $"The weather in {location} is cloudy with a high of 15°C."; - } -} - -/// -/// Provides the current date and time. -/// -/// -/// This class returns the current date and time using the system's clock. -/// -internal sealed class CurrentTimeProvider -{ - /// - /// Gets the current date and time. - /// - /// The location to get the current time for (not used in this implementation). - /// The current date and time as a . - public DateTimeOffset GetCurrentTime(string location) - { - return DateTimeOffset.Now; - } -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md deleted file mode 100644 index 5c52ffcd1c..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Using Plugins with AI Agents - -This sample demonstrates how to use plugins with AI agents, where plugins are services registered in dependency injection that expose methods as AI function tools. - -## What this sample demonstrates - -- Creating plugin services with methods to expose as tools -- Using AsAITools() to selectively expose plugin methods -- Registering plugins in dependency injection -- Using plugins with AI agents -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step13_Plugins -``` - -## Expected behavior - -The sample will: - -1. Create a plugin service with methods to expose as tools -2. Register the plugin in dependency injection -3. Create an agent named "PluginAgent" with the plugin methods as function tools -4. Run the agent with a prompt that triggers it to call plugin methods -5. The agent will invoke the plugin methods to retrieve information -6. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj deleted file mode 100644 index 4a34560946..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md deleted file mode 100644 index 34fa18c94c..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Using Code Interpreter with AI Agents - -This sample demonstrates how to use the code interpreter tool with AI agents. The code interpreter allows agents to write and execute Python code to solve problems, perform calculations, and analyze data. - -## What this sample demonstrates - -- Creating agents with code interpreter capabilities -- Using HostedCodeInterpreterTool (MEAI abstraction) -- Using native SDK code interpreter tools (ResponseTool.CreateCodeInterpreterTool) -- Extracting code inputs and results from agent responses -- Handling code interpreter annotations -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step14_CodeInterpreter -``` - -## Expected behavior - -The sample will: - -1. Create two agents with code interpreter capabilities: - - Option 1: Using HostedCodeInterpreterTool (MEAI abstraction) - - Option 2: Using native SDK code interpreter tools -2. Run the agent with a mathematical problem: "I need to solve the equation sin(x) + x^2 = 42" -3. The agent will use the code interpreter to write and execute Python code to solve the equation -4. Extract and display the code that was executed -5. Display the results from the code execution -6. Display any annotations generated by the code interpreter tool -7. Clean up resources by deleting both agents - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md deleted file mode 100644 index 092f2bd1cf..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Using Computer Use Tool with AI Agents - -This sample demonstrates how to use the computer use tool with AI agents. The computer use tool allows agents to interact with a computer environment by viewing the screen, controlling the mouse and keyboard, and performing various actions to help complete tasks. - -> [!NOTE] -> **Azure Agents API vs. vanilla OpenAI Responses API behavior:** -> The Azure Agents API rejects requests that include `previous_response_id` alongside -> `computer_call_output` items — unlike the vanilla OpenAI Responses API, which accepts them. -> This sample works around the limitation by creating a **fresh session for each follow-up call** -> (so no `previous_response_id` is carried over) and re-sending all prior response output items -> (reasoning, computer_call, etc.) as input items to preserve full conversation context. -> Additionally, the sample uses the **current** `CallId` from each computer call response -> (not the initial one) and clears the `ContinuationToken` after polling completes to prevent -> stale tokens from affecting subsequent requests. - -## What this sample demonstrates - -- Creating agents with computer use capabilities -- Using HostedComputerTool (MEAI abstraction) -- Using native SDK computer use tools (ResponseTool.CreateComputerTool) -- Extracting computer action information from agent responses -- Handling computer tool results (text output and screenshots) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="computer-use-preview" # Optional, defaults to computer-use-preview -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step15_ComputerUse -``` - -## Expected behavior - -The sample will: - -1. Create two agents with computer use capabilities: - - Option 1: Using HostedComputerTool (MEAI abstraction) - - Option 2: Using native SDK computer use tools -2. Run the agent with a task: "I need you to help me search for 'OpenAI news'. Please type 'OpenAI news' and submit the search. Once you see search results, the task is complete." -3. The agent will use the computer use tool to: - - Interpret the screenshots - - Issue action requests based on the task - - Analyze the search results for "OpenAI news" from the screenshots. -4. Extract and display the computer actions performed -5. Display the results from the computer tool execution -6. Display the final response from the agent -7. Clean up resources by deleting both agents diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md deleted file mode 100644 index db74868d3d..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Using File Search with AI Agents - -This sample demonstrates how to use the file search tool with AI agents. The file search tool allows agents to search through uploaded files stored in vector stores to answer user questions. - -## What this sample demonstrates - -- Uploading files and creating vector stores -- Creating agents with file search capabilities -- Using HostedFileSearchTool (MEAI abstraction) -- Using native SDK file search tools (ResponseTool.CreateFileSearchTool) -- Handling file citation annotations -- Managing agent and resource lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses `DefaultAzureCredential` for authentication. For local development, make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step16_FileSearch -``` - -## Expected behavior - -The sample will: - -1. Create a temporary text file with employee directory information -2. Upload the file to Azure Foundry -3. Create a vector store with the uploaded file -4. Create an agent with file search capabilities using one of: - - Option 1: Using HostedFileSearchTool (MEAI abstraction) - - Option 2: Using native SDK file search tools -5. Run a query against the agent to search through the uploaded file -6. Display file citation annotations from responses -7. Clean up resources (agent, vector store, and uploaded file) diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj deleted file mode 100644 index 77b76acfa0..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md deleted file mode 100644 index a859f6b963..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Using OpenAPI Tools with AI Agents - -This sample demonstrates how to use OpenAPI tools with AI agents. OpenAPI tools allow agents to call external REST APIs defined by OpenAPI specifications. - -## What this sample demonstrates - -- Creating agents with OpenAPI tool capabilities -- Using AgentTool.CreateOpenApiTool with an embedded OpenAPI specification -- Anonymous authentication for public APIs -- Running an agent that can call external REST APIs -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses `DefaultAzureCredential` for authentication, which supports multiple authentication methods including Azure CLI, managed identity, and more. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step17_OpenAPITools -``` - -## Expected behavior - -The sample will: - -1. Create an agent with an OpenAPI tool configured to call the REST Countries API -2. Ask the agent: "What countries use the Euro (EUR) as their currency?" -3. The agent will use the OpenAPI tool to call the REST Countries API -4. Display the response containing the list of countries that use EUR -5. Clean up resources by deleting the agent \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj deleted file mode 100644 index 730d284bd9..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md deleted file mode 100644 index ccc1873a04..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Using Bing Custom Search with AI Agents - -This sample demonstrates how to use the Bing Custom Search tool with AI agents to perform customized web searches. - -## What this sample demonstrates - -- Creating agents with Bing Custom Search capabilities -- Configuring custom search instances via connection ID and instance name -- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2) -- Running search queries through the agent -- Managing agent lifecycle (creation and deletion) - -## Agent creation options - -This sample provides two approaches for creating agents with Bing Custom Search: - -- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2. -- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types. - -Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`. - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- A Bing Custom Search resource configured in Azure and connected to your Foundry project - -**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" -$env:BING_CUSTOM_SEARCH_INSTANCE_NAME="your-configuration-name" -``` - -### Finding the connection ID and instance name - -- **Connection ID**: The full ARM resource path including the `/projects//connections/` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**. -- **Instance Name**: The **configuration name** from the Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name. - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step18_BingCustomSearch -``` - -## Expected behavior - -The sample will: - -1. Create an agent with Bing Custom Search tool capabilities -2. Run the agent with a search query about Microsoft AI -3. Display the search results returned by the agent -4. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj deleted file mode 100644 index 4d17fe06bb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md deleted file mode 100644 index ccbd699011..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Using SharePoint Grounding with AI Agents - -This sample demonstrates how to use the SharePoint grounding tool with AI agents. The SharePoint grounding tool enables agents to search and retrieve information from SharePoint sites. - -## What this sample demonstrates - -- Creating agents with SharePoint grounding capabilities -- Using AgentTool.CreateSharepointTool (MEAI abstraction) -- Using native SDK SharePoint tools (PromptAgentDefinition) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in) -- A SharePoint project connection configured in Azure Foundry - -**Note**: This demo uses `DefaultAzureCredential` for authentication. This credential will try multiple authentication mechanisms in order (such as environment variables, managed identity, Azure CLI login, and IDE sign-in) and use the first one that works. A common option for local development is to sign in with the Azure CLI using `az login` and ensure you 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) and the [DefaultAzureCredential documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential). - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # Required: SharePoint project connection ID -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step19_SharePoint -``` - -## Expected behavior - -The sample will: - -1. Create two agents with SharePoint grounding capabilities: - - Option 1: Using AgentTool.CreateSharepointTool (MEAI abstraction) - - Option 2: Using native SDK SharePoint tools -2. Run the agent with a query: "List the documents available in SharePoint" -3. The agent will use SharePoint grounding to search and retrieve relevant documents -4. Display the response and any grounding annotations -5. Clean up resources by deleting both agents diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj deleted file mode 100644 index 4d17fe06bb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs deleted file mode 100644 index e5ab205f68..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use Microsoft Fabric Tool with AI Agents. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); - -const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection."; - -// Get a client to create/retrieve/delete server side agents with Azure 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. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Configure Microsoft Fabric tool options with project connection -var fabricToolOptions = new FabricDataAgentToolOptions(); -fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); - -AIAgent agent = await CreateAgentWithMEAIAsync(); -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); - -Console.WriteLine($"Created agent: {agent.Name}"); - -// Run the agent with a sample query -AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?"); - -Console.WriteLine("\n=== Agent Response ==="); -foreach (var message in response.Messages) -{ - Console.WriteLine(message.Text); -} - -// Cleanup by deleting the agent -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -Console.WriteLine($"\nDeleted agent: {agent.Name}"); - -// --- Agent Creation Options --- - -// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateMicrosoftFabricTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAIAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "FabricAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with AgentTool.CreateMicrosoftFabricTool (Native SDK) -async Task CreateAgentWithNativeSDKAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "FabricAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = - { - AgentTool.CreateMicrosoftFabricTool(fabricToolOptions), - } - }) - ); -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md deleted file mode 100644 index a5faf79d9d..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Using Microsoft Fabric Tool with AI Agents - -This sample demonstrates how to use the Microsoft Fabric tool with AI Agents, allowing agents to query and interact with data in Microsoft Fabric workspaces. - -## What this sample demonstrates - -- Creating agents with Microsoft Fabric data access capabilities -- Using FabricDataAgentToolOptions to configure Fabric connections -- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2) -- Managing agent lifecycle (creation and deletion) - -## Agent creation options - -This sample provides two approaches for creating agents with Microsoft Fabric: - -- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2. -- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types. - -Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`. - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- A Microsoft Fabric workspace with a configured project connection in Azure Foundry - -**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The Fabric project connection ID from Azure Foundry -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step20_MicrosoftFabric -``` - -## Expected behavior - -The sample will: - -1. Create an agent with Microsoft Fabric tool capabilities -2. Configure the agent with a Fabric project connection -3. Run the agent with a query about available Fabric data -4. Display the agent's response -5. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj deleted file mode 100644 index 4d17fe06bb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs deleted file mode 100644 index c116a975e1..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use the Responses API Web Search Tool with AI Agents. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI.Responses; - -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"; - -const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately."; -const string AgentName = "WebSearchAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Option 1 - Using HostedWebSearchTool (MEAI + AgentFramework) -AIAgent agent = await CreateAgentWithMEAIAsync(); - -// Option 2 - Using PromptAgentDefinition with the Responses API native type -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); - -AgentResponse response = await agent.RunAsync("What's the weather today in Seattle?"); - -// Get the text response -Console.WriteLine($"Response: {response.Text}"); - -// Getting any annotations/citations generated by the web search tool -foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(c => c.Annotations ?? [])) -{ - Console.WriteLine($"Annotation: {annotation}"); - if (annotation.RawRepresentation is UriCitationMessageAnnotation urlCitation) - { - Console.WriteLine($$""" - Title: {{urlCitation.Title}} - URL: {{urlCitation.Uri}} - """); - } -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - -// Creates the agent using the HostedWebSearchTool MEAI abstraction that maps to the built-in Responses API web search tool. -async Task CreateAgentWithMEAIAsync() - => await aiProjectClient.CreateAIAgentAsync( - name: AgentName, - model: deploymentName, - instructions: AgentInstructions, - tools: [new HostedWebSearchTool()]); - -// Creates the agent using the PromptAgentDefinition with the Responses API native ResponseTool.CreateWebSearchTool(). -async Task CreateAgentWithNativeSDKAsync() - => await aiProjectClient.CreateAIAgentAsync( - AgentName, - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { ResponseTool.CreateWebSearchTool() } - })); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md deleted file mode 100644 index 8da390878c..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Using Web Search with AI Agents - -This sample demonstrates how to use the Responses API web search tool with AI agents. The web search tool allows agents to search the web for current information to answer questions accurately. - -## What this sample demonstrates - -- Creating agents with web search capabilities -- Using HostedWebSearchTool (MEAI abstraction) -- Using native SDK web search tools (ResponseTool.CreateWebSearchTool) -- Extracting text responses and URL citations from agent responses -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in) - -**Note**: This sample authenticates using `DefaultAzureCredential` from the Azure Identity library, which will try several credential sources (including Azure CLI, environment variables, managed identity, and IDE sign-in). Ensure at least one supported credential source is available. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/overview/azure/identity-readme). - -**Note**: The web search tool uses the built-in web search capability from the OpenAI Responses API. - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step21_WebSearch -``` - -## Expected behavior - -The sample will: - -1. Create an agent with web search capabilities using HostedWebSearchTool (MEAI abstraction) - - Alternative: Using native SDK web search tools (commented out in code) - - Alternative: Retrieving an existing agent by name (commented out in code) -2. Run the agent with a query: "What's the weather today in Seattle?" -3. The agent will use the web search tool to find current information -4. Display the text response from the agent -5. Display any URL citations from web search results -6. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj deleted file mode 100644 index d83a9d9202..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md deleted file mode 100644 index 9e6d79d579..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Using Memory Search with AI Agents - -This sample demonstrates how to use the Memory Search tool with AI agents. The Memory Search tool enables agents to recall information from previous conversations, supporting user profile persistence and chat summaries across sessions. - -## What this sample demonstrates - -- Creating an agent with Memory Search tool capabilities -- Configuring memory scope for user isolation -- Having conversations where the agent remembers past information -- Inspecting memory search results from agent responses -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- **A pre-created Memory Store** (see below) - -**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). - -### Creating a Memory Store - -Memory stores must be created before running this sample. The .NET SDK currently only supports **using** existing memory stores with agents. To create a memory store, use one of these methods: - -**Option 1: Azure Portal** -1. Navigate to your Azure AI Foundry project -2. Go to the Memory section -3. Create a new memory store with your desired settings - -**Option 2: Python SDK** -```python -from azure.ai.projects import AIProjectClient -from azure.ai.projects.models import MemoryStoreDefaultDefinition, MemoryStoreDefaultOptions -from azure.identity import DefaultAzureCredential - -project_client = AIProjectClient( - endpoint="https://your-endpoint.openai.azure.com/", - credential=DefaultAzureCredential() -) - -memory_store = await project_client.memory_stores.create( - name="my-memory-store", - description="Memory store for Agent Framework conversations", - definition=MemoryStoreDefaultDefinition( - chat_model=os.environ["AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME"], - embedding_model=os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"], - options=MemoryStoreDefaultOptions( - user_profile_enabled=True, - chat_summary_enabled=True - ) - ) -) -``` - -## Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:AZURE_AI_MEMORY_STORE_NAME="your-memory-store-name" # Required - name of pre-created memory store -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step22_MemorySearch -``` - -## Expected behavior - -The sample will: - -1. Create an agent with Memory Search tool configured -2. Send a message with personal information ("My name is Alice and I love programming in C#") -3. Wait for memory indexing -4. Ask the agent to recall the previously shared information -5. Display memory search results if available in the response -6. Clean up by deleting the agent (note: memory store persists) - -## Important notes - -- **Memory Store Lifecycle**: Memory stores are long-lived resources and are NOT deleted when the agent is deleted. Clean them up separately via Azure Portal or Python SDK. -- **Scope**: The `scope` parameter isolates memories per user/context. Use unique identifiers for different users. -- **Update Delay**: The `UpdateDelay` parameter controls how quickly new memories are indexed. diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md deleted file mode 100644 index 8651108987..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Using Local MCP Client with Azure Foundry Agents - -This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. Unlike the hosted MCP approach where Azure Foundry invokes the MCP server on the service side, this sample connects to the MCP server directly from the client via HTTP (Streamable HTTP transport) and passes the resolved tools to the agent. - -## What this sample demonstrates - -- Connecting to an MCP server locally using `HttpClientTransport` -- Discovering available tools from the MCP server client-side -- Passing locally-resolved MCP tools to a Foundry agent -- Using the Microsoft Learn MCP endpoint for documentation search -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure 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). - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step23_LocalMCP -``` - -## Expected behavior - -The sample will: - -1. Connect to the Microsoft Learn MCP server via HTTP and list available tools -2. Create an agent with the locally-resolved MCP tools -3. Ask two questions about Microsoft documentation -4. The agent will use the MCP tools (invoked locally) to search Microsoft Learn documentation -5. Display the agent's responses with information from the documentation -6. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/README.md b/dotnet/samples/02-agents/FoundryAgents/README.md deleted file mode 100644 index 426a8cdad5..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# Getting started with Foundry Agents - -The getting started with Foundry Agents samples demonstrate the fundamental concepts and functionalities -of Azure Foundry Agents and can be used with Azure Foundry as the AI provider. - -These samples showcase how to work with agents managed through Azure Foundry, including agent creation, -versioning, multi-turn conversations, and advanced features like code interpretation and computer use. - -## Classic vs New Foundry Agents - -> [!NOTE] -> Recently, Azure Foundry introduced a new and improved experience for creating and managing AI agents, which is the target of these samples. - -For more information about the previous classic agents and for what's new in Foundry Agents, see the [Foundry Agents migration documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry). - -For a sample demonstrating how to use classic Foundry Agents, see the following: [Agent with Azure AI Persistent](../AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md). - -## Agent Versioning and Static Definitions - -One of the key architectural changes in the new Foundry Agents compared to the classic experience is how agent definitions are handled. In the new architecture, agents have **versions** and their definitions are established at creation time. This means that the agent's configuration—including instructions, tools, and options—is fixed when the agent version is created. - -> [!IMPORTANT] -> Agent versions are static and strictly adhere to their original definition. Any attempt to provide or override tools, instructions, or options during an agent run or request will be ignored by the agent, as the API does not support runtime configuration changes. All agent behavior must be defined at agent creation time. - -This design ensures consistency and predictability in agent behavior across all interactions with a specific agent version. - -The Agent Framework intentionally ignores unsupported runtime parameters rather than throwing exceptions. This abstraction-first approach ensures that code written against the unified agent abstraction remains portable across providers (OpenAI, Azure OpenAI, Foundry Agents). It removes the need for provider-specific conditional logic. Teams can adopt Foundry Agents without rewriting existing orchestration code. Configurations that work with other providers will gracefully degrade, rather than fail, when the underlying API does not support them. - -## Getting started with Foundry Agents prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and project configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: These samples use Azure Foundry Agents. For more information, see [Azure AI Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/). - -**Note**: These samples use 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). - -## Samples - -|Sample|Description| -|---|---| -|[Basics](./FoundryAgents_Step01.1_Basics/)|This sample demonstrates how to create and manage AI agents with versioning| -|[Running a simple agent](./FoundryAgents_Step01.2_Running/)|This sample demonstrates how to create and run a basic Foundry agent| -|[Multi-turn conversation](./FoundryAgents_Step02_MultiturnConversation/)|This sample demonstrates how to implement a multi-turn conversation with a Foundry agent| -|[Using function tools](./FoundryAgents_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with a Foundry agent| -|[Using function tools with approvals](./FoundryAgents_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution| -|[Structured output](./FoundryAgents_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a Foundry agent| -|[Persisted conversations](./FoundryAgents_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later| -|[Observability](./FoundryAgents_Step07_Observability/)|This sample demonstrates how to add telemetry to a Foundry agent| -|[Dependency injection](./FoundryAgents_Step08_DependencyInjection/)|This sample demonstrates how to add and resolve a Foundry agent with a dependency injection container| -|[Using MCP client as tools](./FoundryAgents_Step09_UsingMcpClientAsTools/)|This sample demonstrates how to use MCP clients as tools with a Foundry agent| -|[Using images](./FoundryAgents_Step10_UsingImages/)|This sample demonstrates how to use image multi-modality with a Foundry agent| -|[Exposing as a function tool](./FoundryAgents_Step11_AsFunctionTool/)|This sample demonstrates how to expose a Foundry agent as a function tool| -|[Using middleware](./FoundryAgents_Step12_Middleware/)|This sample demonstrates how to use middleware with a Foundry agent| -|[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent| -|[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent| -|[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent| -|[File search](./FoundryAgents_Step16_FileSearch/)|This sample demonstrates how to use the file search tool with a Foundry agent| -|[OpenAPI tools](./FoundryAgents_Step17_OpenAPITools/)|This sample demonstrates how to use OpenAPI tools with a Foundry agent| -|[Bing Custom Search](./FoundryAgents_Step18_BingCustomSearch/)|This sample demonstrates how to use Bing Custom Search tool with a Foundry agent| -|[SharePoint grounding](./FoundryAgents_Step19_SharePoint/)|This sample demonstrates how to use the SharePoint grounding tool with a Foundry agent| -|[Microsoft Fabric](./FoundryAgents_Step20_MicrosoftFabric/)|This sample demonstrates how to use Microsoft Fabric tool with a Foundry agent| -|[Web search](./FoundryAgents_Step21_WebSearch/)|This sample demonstrates how to use the Responses API web search tool with a Foundry agent| -|[Memory search](./FoundryAgents_Step22_MemorySearch/)|This sample demonstrates how to use memory search tool with a Foundry agent| -|[Local MCP](./FoundryAgents_Step23_LocalMCP/)|This sample demonstrates how to use a local MCP client with a Foundry agent| - -## Evaluation Samples - -Evaluation is critical for building trustworthy and high-quality AI applications. The evaluation samples demonstrate how to assess agent safety, quality, and performance using Azure AI Foundry's evaluation capabilities. - -|Sample|Description| -|---|---| -|[Red Team Evaluation](./FoundryAgents_Evaluations_Step01_RedTeaming/)|This sample demonstrates how to use Azure AI Foundry's Red Teaming service to assess model safety against adversarial attacks| -|[Self-Reflection with Groundedness](./FoundryAgents_Evaluations_Step02_SelfReflection/)|This sample demonstrates the self-reflection pattern where agents iteratively improve responses based on groundedness evaluation| - -For details on safety evaluation, see the [Red Team Evaluation README](./FoundryAgents_Evaluations_Step01_RedTeaming/README.md). - -## Running the samples from the console - -To run the samples, navigate to the desired sample directory, e.g. - -```powershell -cd FoundryAgents_Step01.2_Running -``` - -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_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -If the variables are not set, you will be prompted for the values when running the samples. - -Execute the following command to build the sample: - -```powershell -dotnet build -``` - -Execute the following command to run the sample: - -```powershell -dotnet run --no-build -``` - -Or just build and run in one step: - -```powershell -dotnet run -``` - -## Running the samples from Visual Studio - -Open the solution in Visual Studio and set the desired sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. - -You will be prompted for any required environment variables if they are not already set. - 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 e91ed4d15a..bedf87b8c5 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -5,9 +5,11 @@ // 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; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; @@ -23,26 +25,22 @@ // Create an MCP tool definition that the agent can use. // In this case we allow the tool to always be called without approval. -var mcpTool = new HostedMcpServerTool( - serverName: "microsoft_learn", - serverAddress: "https://learn.microsoft.com/api/mcp") -{ - AllowedTools = ["microsoft_docs_search"], - ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire -}; +var mcpTool = ResponseTool.CreateMcpTool( + serverLabel: "microsoft_learn", + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)); // Create a server side agent with the mcp tool, and expose it as an AIAgent. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: model, - options: new() - { - Name = "MicrosoftLearnAgent", - ChatOptions = new() +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + "MicrosoftLearnAgent", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: model) { Instructions = "You answer questions by searching the Microsoft Learn content only.", - Tools = [mcpTool] - }, - }); + Tools = { mcpTool } + })); + +AIAgent agent = aiProjectClient.AsAIAgent(agentVersion); // You can then invoke the agent like any other AIAgent. AgentSession session = await agent.CreateSessionAsync(); @@ -56,26 +54,23 @@ // Create an MCP tool definition that the agent can use. // In this case we require approval before the tool can be called. -var mcpToolWithApproval = new HostedMcpServerTool( - serverName: "microsoft_learn", - serverAddress: "https://learn.microsoft.com/api/mcp") -{ - AllowedTools = ["microsoft_docs_search"], - ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire -}; +var mcpToolWithApproval = ResponseTool.CreateMcpTool( + serverLabel: "microsoft_learn", + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + allowedTools: new McpToolFilter() { ToolNames = { "microsoft_docs_search" } }, + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval)); // Create an agent with the MCP tool that requires approval. -AIAgent agentWithRequiredApproval = await aiProjectClient.CreateAIAgentAsync( - model: model, - options: new() - { - Name = "MicrosoftLearnAgentWithApproval", - ChatOptions = new() +AgentVersion agentVersionWithApproval = await aiProjectClient.Agents.CreateAgentVersionAsync( + "MicrosoftLearnAgentWithApproval", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: model) { Instructions = "You answer questions by searching the Microsoft Learn content only.", - Tools = [mcpToolWithApproval] - }, - }); + Tools = { mcpToolWithApproval } + })); + +AIAgent agentWithRequiredApproval = aiProjectClient.AsAIAgent(agentVersionWithApproval); // You can then invoke the agent like any other AIAgent. // For simplicity, we are assuming here that only mcp tool approvals are pending. diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index b901645f88..5ff0db416d 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -1,22 +1,21 @@ -# Getting started +# Getting started -The getting started samples demonstrate the fundamental concepts and functionalities -of the agent framework. +The getting started samples demonstrate the fundamental concepts and functionality of the agent framework. ## Samples -|Sample|Description| -|---|---| -|[Agents](./Agents/README.md)|Step by step instructions for getting started with agents| -|[Foundry Agents](./FoundryAgents/README.md)|Getting started with Azure Foundry Agents| -|[Agent Providers](./AgentProviders/README.md)|Getting started with creating agents using various providers| -|[Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md)|Adding Retrieval Augmented Generation (RAG) capabilities to your agents.| -|[Agents With Memory](./AgentWithMemory/README.md)|Adding Memory capabilities to your agents.| -|[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents| -|[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents| -|[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude| -|[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol| -|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills| -|[Declarative Agents](./DeclarativeAgents)|Loading and executing AI agents from YAML configuration files| │ -|[AG-UI](./AGUI/README.md)|Getting started with AG-UI (Agent UI Protocol) servers and clients| │ -|[Dev UI](./DevUI/README.md)|Interactive web interface for testing and debugging AI agents during development| \ No newline at end of file +| Sample | Description | +| --- | --- | +| [Agents](./Agents/README.md) | Step-by-step instructions for getting started with agents | +| [Agents with Foundry](./AgentsWithFoundry/README.md) | Foundry agent samples using `FoundryAgent` and `AIProjectClient.AsAIAgent(...)` | +| [Agent Providers](./AgentProviders/README.md) | Getting started with creating agents using various providers | +| [Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md) | Adding Retrieval Augmented Generation (RAG) capabilities to your agents | +| [Agents With Memory](./AgentWithMemory/README.md) | Adding memory capabilities to your agents | +| [Agent Open Telemetry](./AgentOpenTelemetry/README.md) | Getting started with OpenTelemetry for agents | +| [Agent With OpenAI exchange types](./AgentWithOpenAI/README.md) | Using OpenAI exchange types with agents | +| [Agent With Anthropic](./AgentWithAnthropic/README.md) | Getting started with agents using Anthropic Claude | +| [Model Context Protocol](./ModelContextProtocol/README.md) | Getting started with Model Context Protocol | +| [Agent Skills](./AgentSkills/README.md) | Getting started with Agent Skills | +| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files | +| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients | +| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development | diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index 589eca2bbc..5b1f59ac62 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. 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.Workflows; using Microsoft.Extensions.AI; @@ -68,15 +70,19 @@ private static async Task Main() /// The target language for translation /// The to create the agent with. /// The model to use for the agent - /// A ChatClientAgent configured for the specified language - private static async Task CreateTranslationAgentAsync( + /// A FoundryAgent configured for the specified language + private static async Task CreateTranslationAgentAsync( string targetLanguage, AIProjectClient aiProjectClient, string model) { - return await aiProjectClient.CreateAIAgentAsync( - name: $"{targetLanguage} Translator", - model: model, - instructions: $"You are a translation assistant that translates the provided text to {targetLanguage}."); + AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + $"{targetLanguage} Translator", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: model) + { + Instructions = $"You are a translation assistant that translates the provided text to {targetLanguage}.", + })); + return aiProjectClient.AsAIAgent(agentVersion); } } diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs index 5936aaf82f..7852a8730f 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs @@ -8,6 +8,7 @@ using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Shared.Foundry; @@ -49,7 +50,7 @@ public static async Task Main(string[] args) string workflowInput = GetWorkflowInput(args); - AIAgent agent = aiProjectClient.AsAIAgent(agentVersion); + FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/samples/03-workflows/README.md b/dotnet/samples/03-workflows/README.md index 2b8d375654..1ab52106ec 100644 --- a/dotnet/samples/03-workflows/README.md +++ b/dotnet/samples/03-workflows/README.md @@ -1,6 +1,6 @@ -# Workflow Getting Started Samples +# Workflow Getting Started Samples -The getting started with workflow samples demonstrate the fundamental concepts and functionalities of workflows in Agent Framework. +The workflow samples demonstrate the fundamental concepts and functionality of workflows in Agent Framework. ## Samples Overview @@ -20,15 +20,13 @@ Please begin with the [Start Here](./_StartHere) samples in order. These three s | [Mixed Workflow with Agents and Executors](./_StartHere/06_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling | | [Writer-Critic Workflow](./_StartHere/07_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops | -Once completed, please proceed to other samples listed below. - -> Note that you don't need to follow a strict order after the foundational samples. However, some samples build upon concepts from previous ones, so it's beneficial to be aware of the dependencies. +Once completed, please proceed to the other samples listed below. ### Agents | Sample | Concepts | |--------|----------| -| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure Foundry Agents within a workflow | +| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure 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 | @@ -58,25 +56,3 @@ Once completed, please proceed to other samples listed below. | [Edge Conditions](./ConditionalEdges/01_EdgeCondition) | Introduces conditional edges for dynamic routing based on executor outputs | | [Switch-Case Routing](./ConditionalEdges/02_SwitchCase) | Extends conditional edges with switch-case routing for multiple paths | | [Multi-Selection Routing](./ConditionalEdges/03_MultiSelection) | Demonstrates multi-selection routing where one executor can trigger multiple downstream executors | - -> These 3 samples build upon each other. It's recommended to explore them in sequence to fully grasp the concepts. - -### Declarative Workflows - -| Sample | Concepts | -|--------|----------| -| [Declarative](./Declarative) | Demonstrates execution of declartive workflows. | - -### Checkpointing - -| Sample | Concepts | -|--------|----------| -| [Checkpoint and Resume](./Checkpoint/CheckpointAndResume) | Introduces checkpoints for saving and restoring workflow state for time travel purposes | -| [Checkpoint and Rehydrate](./Checkpoint/CheckpointAndRehydrate) | Demonstrates hydrating a new workflow instance from a saved checkpoint | -| [Checkpoint with Human-in-the-Loop](./Checkpoint/CheckpointWithHumanInTheLoop) | Combines checkpointing with human-in-the-loop interactions | - -### Human-in-the-Loop - -| Sample | Concepts | -|--------|----------| -| [Basic Human-in-the-Loop](./HumanInTheLoop/HumanInTheLoopBasic) | Introduces human-in-the-loop interaction using input ports and external requests | diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 584b7db422..13c01be156 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -2,6 +2,7 @@ using A2A; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -19,8 +20,8 @@ internal static class HostAgentFactory // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); - AIAgent agent = await aiProjectClient - .GetAIAgentAsync(agentName, tools: tools); + AgentRecord agentRecord = await aiProjectClient.Agents.GetAgentAsync(agentName); + AIAgent agent = aiProjectClient.AsAIAgent(agentRecord, tools: tools); AgentCard agentCard = agentType.ToUpperInvariant() switch { @@ -59,7 +60,7 @@ private static AgentCard GetInvoiceAgentCard() PushNotifications = false, }; - var invoiceQuery = new AgentSkill() + var invoiceQuery = new A2A.AgentSkill() { Id = "id_invoice_agent", Name = "InvoiceQuery", @@ -91,7 +92,7 @@ private static AgentCard GetPolicyAgentCard() PushNotifications = false, }; - var policyQuery = new AgentSkill() + var policyQuery = new A2A.AgentSkill() { Id = "id_policy_agent", Name = "PolicyAgent", @@ -123,7 +124,7 @@ private static AgentCard GetLogisticsAgentCard() PushNotifications = false, }; - var logisticsQuery = new AgentSkill() + var logisticsQuery = new A2A.AgentSkill() { Id = "id_logistics_agent", Name = "LogisticsQuery", diff --git a/dotnet/samples/AGENTS.md b/dotnet/samples/AGENTS.md index 1578b39a26..f515f531eb 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 -│ ├── FoundryAgents/ # Azure AI Foundry agent samples +│ ├── AgentsWithFoundry/ # Azure AI Foundry samples (FoundryAgent + AsAIAgent extensions) │ └── ModelContextProtocol/ # MCP server/client patterns ├── 03-workflows/ # Workflow patterns │ ├── _StartHere/ # Introductory workflow samples diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs index 32bb08674b..ec788233ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs @@ -44,7 +44,7 @@ internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReferenc { this._agentClient = aiProjectClient; this._agentReference = Throw.IfNull(agentReference); - this._metadata = new ChatClientMetadata("azure.ai.agents", defaultModelId: defaultModelId); + this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); this._chatOptions = chatOptions; } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs index b129f4b1f2..479ab894ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs @@ -14,6 +14,7 @@ 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; @@ -42,7 +43,7 @@ public static partial class AzureAIProjectChatClientExtensions /// 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 ChatClientAgent AsAIAgent( + public static FoundryAgent AsAIAgent( this AIProjectClient aiProjectClient, AgentReference agentReference, IList? tools = null, @@ -53,7 +54,7 @@ public static ChatClientAgent AsAIAgent( Throw.IfNull(agentReference); ThrowIfInvalidAgentName(agentReference.Name); - return AsChatClientAgent( + var innerAgent = AsChatClientAgent( aiProjectClient, agentReference, new ChatClientAgentOptions() @@ -64,6 +65,8 @@ public static ChatClientAgent AsAIAgent( }, clientFactory, services); + + return new FoundryAgent(aiProjectClient, innerAgent); } /// @@ -79,7 +82,8 @@ public static ChatClientAgent AsAIAgent( /// 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. - public static async Task GetAIAgentAsync( + [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] + public static async Task GetAIAgentAsync( this AIProjectClient aiProjectClient, string name, IList? tools = null, @@ -110,7 +114,7 @@ public static async Task GetAIAgentAsync( /// 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 ChatClientAgent AsAIAgent( + public static FoundryAgent AsAIAgent( this AIProjectClient aiProjectClient, AgentRecord agentRecord, IList? tools = null, @@ -122,13 +126,15 @@ public static ChatClientAgent AsAIAgent( var allowDeclarativeMode = tools is not { Count: > 0 }; - return AsChatClientAgent( + var innerAgent = AsChatClientAgent( aiProjectClient, agentRecord, tools, clientFactory, !allowDeclarativeMode, services); + + return new FoundryAgent(aiProjectClient, innerAgent); } /// @@ -141,7 +147,7 @@ public static ChatClientAgent AsAIAgent( /// 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 ChatClientAgent AsAIAgent( + public static FoundryAgent AsAIAgent( this AIProjectClient aiProjectClient, AgentVersion agentVersion, IList? tools = null, @@ -153,13 +159,15 @@ public static ChatClientAgent AsAIAgent( var allowDeclarativeMode = tools is not { Count: > 0 }; - return AsChatClientAgent( + var innerAgent = AsChatClientAgent( aiProjectClient, agentVersion, tools, clientFactory, !allowDeclarativeMode, services); + + return new FoundryAgent(aiProjectClient, innerAgent); } /// @@ -172,7 +180,8 @@ public static ChatClientAgent AsAIAgent( /// 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 . - public static async Task GetAIAgentAsync( + [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] + public static async Task GetAIAgentAsync( this AIProjectClient aiProjectClient, ChatClientAgentOptions options, Func? clientFactory = null, @@ -194,12 +203,9 @@ public static async Task GetAIAgentAsync( var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: !options.UseProvidedChatClientAsIs); - return AsChatClientAgent( + return new FoundryAgent( aiProjectClient, - agentVersion, - agentOptions, - clientFactory, - services); + AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); } /// @@ -218,7 +224,8 @@ public static async Task GetAIAgentAsync( /// Thrown when , , or is . /// Thrown when or is empty or whitespace. /// When using prompt agent definitions with tools the parameter needs to be provided. - public static Task CreateAIAgentAsync( + [Obsolete("Use native AIProjectClient.Agents APIs instead.")] + public static Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string name, string model, @@ -256,7 +263,8 @@ public static Task CreateAIAgentAsync( /// 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. - public static async Task CreateAIAgentAsync( + [Obsolete("Use native AIProjectClient.Agents APIs instead.")] + public static async Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string model, ChatClientAgentOptions options, @@ -267,7 +275,6 @@ public static async Task CreateAIAgentAsync( Throw.IfNull(aiProjectClient); Throw.IfNull(options); Throw.IfNullOrWhitespace(model); - const bool RequireInvocableTools = true; if (string.IsNullOrWhiteSpace(options.Name)) { @@ -276,43 +283,13 @@ public static async Task CreateAIAgentAsync( ThrowIfInvalidAgentName(options.Name); - 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) } - }; + AgentVersion agentVersion = await CreateAgentVersionFromOptionsAsync(aiProjectClient, model, options, cancellationToken).ConfigureAwait(false); - // Map reasoning options from the abstraction-level ChatOptions.Reasoning, - // falling back to extracting from the raw representation factory for breaking glass scenarios. - 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; - } + var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); - AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, options.Name, creationOptions, cancellationToken).ConfigureAwait(false); - - var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools); - - return AsChatClientAgent( + return new FoundryAgent( aiProjectClient, - agentVersion, - agentOptions, - clientFactory, - services); + AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); } /// @@ -330,7 +307,8 @@ public static async Task CreateAIAgentAsync( /// 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. /// - public static Task CreateAIAgentAsync( + [Obsolete("Use native AIProjectClient.Agents APIs instead.")] + public static Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string name, AgentVersionCreationOptions creationOptions, @@ -351,6 +329,75 @@ public static Task CreateAIAgentAsync( 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"); @@ -358,7 +405,7 @@ public static Task CreateAIAgentAsync( /// /// Asynchronously retrieves an agent record by name using the protocol method to inject user-agent headers. /// - private static async Task GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) + 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(); @@ -369,7 +416,7 @@ private static async Task GetAgentRecordByNameAsync(AIProjectClient /// /// Asynchronously creates an agent version using the protocol method to inject user-agent headers. /// - private static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) + 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); @@ -379,7 +426,7 @@ private static async Task CreateAgentVersionWithProtocolAsync(AIPr return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'."); } - private static async Task CreateAIAgentAsync( + private static async Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string name, IList? tools, @@ -397,17 +444,61 @@ private static async Task CreateAIAgentAsync( AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false); - return AsChatClientAgent( - aiProjectClient, - agentVersion, - tools, - clientFactory, - !allowDeclarativeMode, - services); + return new FoundryAgent(aiProjectClient, AsChatClientAgent(aiProjectClient, agentVersion, tools, clientFactory, !allowDeclarativeMode, services)); } - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( + /// + /// 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, @@ -424,6 +515,37 @@ private static ChatClientAgent AsChatClientAgent( 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, @@ -503,7 +625,7 @@ private static ChatClientAgent AsChatClientAgent( /// 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) + internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) { var agentDefinition = agentVersion.Definition; @@ -591,7 +713,7 @@ private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion /// Specifies whether the returned options must include invocable tools. Set to to require /// invocable tools; otherwise, . /// A instance configured according to the specified parameters. - private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) + internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) { var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools); if (options is not null) @@ -768,7 +890,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync(IEnu private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); #endif - private static string ThrowIfInvalidAgentName(string? name) + internal static string ThrowIfInvalidAgentName(string? name) { Throw.IfNullOrWhitespace(name); if (!AgentNameValidationRegex().IsMatch(name)) diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs new file mode 100644 index 0000000000..48bb20d766 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.AzureAI; + +#pragma warning disable OPENAI001 +internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient +{ + private readonly ChatClientMetadata _metadata; + private readonly AIProjectClient _aiProjectClient; + + internal AzureAIProjectResponsesChatClient(AIProjectClient aiProjectClient, string defaultModelId) + : base(Throw.IfNull(aiProjectClient) + .GetProjectOpenAIClient() + .GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(defaultModelId)) + .AsIChatClient()) + { + this._aiProjectClient = aiProjectClient; + this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); + } + + public override object? GetService(Type serviceType, object? serviceKey = null) + { + return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) + ? this._metadata + : (serviceKey is null && serviceType == typeof(AIProjectClient)) + ? this._aiProjectClient + : base.GetService(serviceType, serviceKey); + } +} +#pragma warning restore OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs new file mode 100644 index 0000000000..80ed48e1df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 + +namespace Microsoft.Agents.AI.AzureAI; + +/// +/// Provides factory methods for creating instances from Microsoft Foundry and OpenAI response tools. +/// +/// +/// +/// This class wraps (Azure.AI.Projects.OpenAI) and (OpenAI SDK) factory methods, +/// returning directly — eliminating the need for manual casting and .AsAITool() calls. +/// +/// +/// Instead of writing: +/// ((ResponseTool)AgentTool.CreateOpenApiTool(definition)).AsAITool() +/// You can write: +/// FoundryAITool.CreateOpenApiTool(definition) +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class FoundryAITool +{ + /// + /// Converts an existing into an . + /// + /// The response tool to convert. + /// An wrapping the provided response tool. + public static AITool FromResponseTool(ResponseTool responseTool) => responseTool.AsAITool(); + + // --- Azure.AI.Projects.OpenAI AgentTool factories --- + + /// + /// Creates an for OpenAPI tool invocations. + /// + /// The OpenAPI function definition specifying the API endpoint, schema, and authentication. + /// An that calls the specified OpenAPI endpoint. + public static AITool CreateOpenApiTool(OpenApiFunctionDefinition definition) + => ((ResponseTool)AgentTool.CreateOpenApiTool(definition)).AsAITool(); + + /// + /// Creates an for Bing Grounding search. + /// + /// The Bing Grounding search configuration options. + /// An for Bing Grounding search. + public static AITool CreateBingGroundingTool(BingGroundingSearchToolOptions options) + => ((ResponseTool)AgentTool.CreateBingGroundingTool(options)).AsAITool(); + + /// + /// Creates an for Bing Custom Search. + /// + /// The Bing Custom Search configuration parameters. + /// An for Bing Custom Search. + public static AITool CreateBingCustomSearchTool(BingCustomSearchToolOptions parameters) + => ((ResponseTool)AgentTool.CreateBingCustomSearchTool(parameters)).AsAITool(); + + /// + /// Creates an for Microsoft Fabric data agent. + /// + /// The Fabric data agent configuration options. + /// An for Microsoft Fabric. + public static AITool CreateMicrosoftFabricTool(FabricDataAgentToolOptions options) + => ((ResponseTool)AgentTool.CreateMicrosoftFabricTool(options)).AsAITool(); + + /// + /// Creates an for SharePoint grounding. + /// + /// The SharePoint grounding configuration options. + /// An for SharePoint grounding. + public static AITool CreateSharepointTool(SharePointGroundingToolOptions options) + => ((ResponseTool)AgentTool.CreateSharepointTool(options)).AsAITool(); + + /// + /// Creates an for Azure AI Search. + /// + /// Optional Azure AI Search configuration options. + /// An for Azure AI Search. + public static AITool CreateAzureAISearchTool(AzureAISearchToolOptions? options = null) + => ((ResponseTool)AgentTool.CreateAzureAISearchTool(options)).AsAITool(); + + /// + /// Creates an for browser automation. + /// + /// The browser automation configuration parameters. + /// An for browser automation. + public static AITool CreateBrowserAutomationTool(BrowserAutomationToolOptions parameters) + => ((ResponseTool)AgentTool.CreateBrowserAutomationTool(parameters)).AsAITool(); + + /// + /// Creates an for structured output capture. + /// + /// The structured output definition. + /// An for structured output capture. + public static AITool CreateStructuredOutputsTool(StructuredOutputDefinition outputs) + => ((ResponseTool)AgentTool.CreateStructuredOutputsTool(outputs)).AsAITool(); + + /// + /// Creates an for Agent-to-Agent (A2A) communication. + /// + /// The base URI for the A2A agent. + /// Optional path to the agent card. + /// An for A2A communication. + public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null) + => AgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool(); + + // --- OpenAI SDK ResponseTool factories --- + + /// + /// Creates an for computer use (screen interaction). + /// + /// The computer tool environment type. + /// The display width in pixels. + /// The display height in pixels. + /// An for computer use. + [Experimental("OPENAICUA001")] + public static AITool CreateComputerTool(ComputerToolEnvironment environment, int displayWidth, int displayHeight) + => ResponseTool.CreateComputerTool(environment, displayWidth, displayHeight).AsAITool(); + + /// + /// Creates an for function tool invocations. + /// + /// The name of the function. + /// The function parameters schema as JSON. + /// Whether strict mode is enabled for parameter validation. + /// Optional description of the function. + /// An for function invocations. + public static AITool CreateFunctionTool(string functionName, BinaryData functionParameters, bool? strictModeEnabled, string? functionDescription = null) + => ResponseTool.CreateFunctionTool(functionName, functionParameters, strictModeEnabled, functionDescription).AsAITool(); + + /// + /// Creates an for file search over vector stores. + /// + /// The IDs of vector stores to search. + /// Optional maximum number of results to return. + /// Optional ranking options for search results. + /// Optional filters for search results. + /// An for file search. + public static AITool CreateFileSearchTool(IEnumerable vectorStoreIds, int? maxResultCount = null, FileSearchToolRankingOptions? rankingOptions = null, BinaryData? filters = null) + => ResponseTool.CreateFileSearchTool(vectorStoreIds, maxResultCount, rankingOptions, filters).AsAITool(); + + /// + /// Creates an for web search. + /// + /// Optional user location for search context. + /// Optional search context size. + /// Optional search filters. + /// An for web search. + public static AITool CreateWebSearchTool(WebSearchToolLocation? userLocation = null, WebSearchToolContextSize? searchContextSize = null, WebSearchToolFilters? filters = null) + => ResponseTool.CreateWebSearchTool(userLocation, searchContextSize, filters).AsAITool(); + + /// + /// Creates an for MCP (Model Context Protocol) server tools. + /// + /// The label for the MCP server. + /// The URI of the MCP server. + /// Optional authorization token. + /// Optional server description. + /// Optional custom headers. + /// Optional filter for allowed tools. + /// Optional tool call approval policy. + /// An for MCP server tools. + public static AITool CreateMcpTool(string serverLabel, Uri serverUri, string? authorizationToken = null, string? serverDescription = null, IDictionary? headers = null, McpToolFilter? allowedTools = null, McpToolCallApprovalPolicy? toolCallApprovalPolicy = null) + => ResponseTool.CreateMcpTool(serverLabel, serverUri, authorizationToken, serverDescription, headers, allowedTools, toolCallApprovalPolicy).AsAITool(); + + /// + /// Creates an for MCP (Model Context Protocol) server tools using a connector ID. + /// + /// The label for the MCP server. + /// The connector ID for the MCP server. + /// Optional authorization token. + /// Optional server description. + /// Optional custom headers. + /// Optional filter for allowed tools. + /// Optional tool call approval policy. + /// An for MCP server tools. + public static AITool CreateMcpTool(string serverLabel, McpToolConnectorId connectorId, string? authorizationToken = null, string? serverDescription = null, IDictionary? headers = null, McpToolFilter? allowedTools = null, McpToolCallApprovalPolicy? toolCallApprovalPolicy = null) + => ResponseTool.CreateMcpTool(serverLabel, connectorId, authorizationToken, serverDescription, headers, allowedTools, toolCallApprovalPolicy).AsAITool(); + + /// + /// Creates an for code interpreter. + /// + /// The container configuration for the code interpreter. + /// An for code interpreter. + public static AITool CreateCodeInterpreterTool(CodeInterpreterToolContainer container) + => ResponseTool.CreateCodeInterpreterTool(container).AsAITool(); + + /// + /// Creates an for image generation. + /// + /// The model to use for image generation. + /// Optional image quality setting. + /// Optional image size setting. + /// Optional output file format. + /// Optional output compression factor. + /// Optional moderation level. + /// Optional background setting. + /// Optional input fidelity setting. + /// Optional input image mask. + /// Optional partial image count. + /// An for image generation. + public static AITool CreateImageGenerationTool(string model, ImageGenerationToolQuality? quality = null, ImageGenerationToolSize? size = null, ImageGenerationToolOutputFileFormat? outputFileFormat = null, int? outputCompressionFactor = null, ImageGenerationToolModerationLevel? moderationLevel = null, ImageGenerationToolBackground? background = null, ImageGenerationToolInputFidelity? inputFidelity = null, ImageGenerationToolInputImageMask? inputImageMask = null, int? partialImageCount = null) + => ResponseTool.CreateImageGenerationTool(model, quality, size, outputFileFormat, outputCompressionFactor, moderationLevel, background, inputFidelity, inputImageMask, partialImageCount).AsAITool(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs new file mode 100644 index 0000000000..66fa93e39f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.Diagnostics.CodeAnalysis; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.AzureAI; + +/// +/// Provides an that uses Microsoft Foundry for AI agent capabilities. +/// +/// +/// +/// connects to a pre-configured server-side agent in Microsoft Foundry, +/// wrapping it as an for use with Agent Framework. Unlike the direct +/// AIProjectClient.AsAIAgent(model, instructions) approach (which creates a local agent +/// backed by the Responses API without any server-side agent definition), +/// works with agents that are managed and versioned in the Foundry service. +/// +/// +/// This class provides convenient access to Foundry-specific features such as server-side +/// conversation management via . +/// +/// +/// Instances can be created directly via public constructors or through +/// AsAIAgent extension methods on . +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryAgent : DelegatingAIAgent +{ + private readonly AIProjectClient _aiProjectClient; + + /// + /// Initializes a new instance of the class using the direct Responses API path. + /// + /// The Microsoft Foundry project endpoint. + /// The authentication credential. + /// The model deployment name. + /// The instructions that guide the agent's behavior. + /// Optional configuration options for the . + /// Optional name for the agent. + /// Optional description for the agent. + /// Optional tools to use when interacting with the agent. + /// Provides a way to customize the creation of the underlying . + /// Optional logger factory for creating loggers used by the agent. + /// Optional service provider for resolving dependencies required by AI functions. + public FoundryAgent( + Uri projectEndpoint, + AuthenticationTokenProvider credential, + string model, + string instructions, + AIProjectClientOptions? clientOptions = null, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + : base(CreateInnerAgent( + CreateProjectClient(projectEndpoint, credential, clientOptions), + model, instructions, name, description, tools, clientFactory, loggerFactory, services, + out var aiProjectClient)) + { + this._aiProjectClient = aiProjectClient; + } + + /// + /// Initializes a new instance of the class from an agent-specific endpoint. + /// + /// The agent-specific endpoint URI (must contain the agent name in the path). + /// The authentication credential. + /// Optional configuration options for the . + /// Optional tools to use when interacting with the agent. + /// Provides a way to customize the creation of the underlying . + /// Optional service provider for resolving dependencies required by AI functions. + public FoundryAgent( + Uri agentEndpoint, + AuthenticationTokenProvider credential, + AIProjectClientOptions? clientOptions = null, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + : base(CreateInnerAgentFromEndpoint( + CreateProjectClient(agentEndpoint, credential, clientOptions), + agentEndpoint, tools, clientFactory, services, + out var aiProjectClient)) + { + this._aiProjectClient = aiProjectClient; + } + + /// + /// Internal constructor used by AsAIAgent extension methods that already have an and a configured . + /// + internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent) + : base(Throw.IfNull(innerAgent)) + { + this._aiProjectClient = Throw.IfNull(aiProjectClient); + } + + #region Convenience methods + + /// + /// Creates a server-side conversation session that appears in the Foundry Project UI. + /// + /// A token to monitor for cancellation requests. + /// A linked to the newly created server-side conversation. + public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default) + { + var conversationsClient = this._aiProjectClient + .GetProjectOpenAIClient() + .GetProjectConversationsClient(); + + var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value; + + return (ChatClientAgentSession)await ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false); + } + + #endregion + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null && serviceType == typeof(AIProjectClient)) + { + return this._aiProjectClient; + } + + return base.GetService(serviceType, serviceKey); + } + + #region Private helpers + + private static ChatClientAgent CreateInnerAgent( + AIProjectClient aiProjectClient, + string model, string instructions, + string? name, string? description, + IList? tools, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services, + out AIProjectClient outClient) + { + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + outClient = aiProjectClient; + + ChatClientAgentOptions options = new() + { + Name = name, + Description = description, + ChatOptions = new ChatOptions + { + ModelId = model, + Instructions = instructions, + Tools = tools, + }, + }; + + return AzureAIProjectChatClientExtensions.CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + private static ChatClientAgent CreateInnerAgentFromEndpoint( + AIProjectClient aiProjectClient, + Uri agentEndpoint, + IList? tools, + Func? clientFactory, + IServiceProvider? services, + out AIProjectClient outClient) + { + outClient = aiProjectClient; + + AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/'); + + ChatClientAgentOptions agentOptions = new() + { + Name = agentReference.Name, + ChatOptions = new() { Tools = tools }, + }; + + 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); + } + + private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) + { + Throw.IfNull(endpoint); + Throw.IfNull(credential); + + clientOptions ??= new AIProjectClientOptions(); + clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall); + return new AIProjectClient(endpoint, credential, clientOptions); + } + + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs index 722d316330..2705611b57 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs @@ -7,6 +7,9 @@ namespace Microsoft.Agents.AI; internal static class RequestOptionsExtensions { + /// Gets the singleton that adds a MEAI user-agent header. + internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance; + /// Creates a configured for use with Foundry Agents. public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) { diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index 6f9f37518e..93b343b2de 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// -/// Provides an Azure AI Foundry Memory backed that persists conversation messages as memories +/// Provides a Microsoft Foundry Memory backed that persists conversation messages as memories /// and retrieves related memories to augment the agent invocation context. /// /// @@ -49,7 +49,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider /// Initializes a new instance of the class. /// /// The Azure AI Project client configured for your Foundry project. - /// The name of the memory store in Azure AI Foundry. + /// The name of the memory store in Microsoft Foundry. /// A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval. /// Provider options. /// Optional logger factory. @@ -87,17 +87,7 @@ public FoundryMemoryProvider( public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; private static Func ValidateStateInitializer(Func stateInitializer) => - session => - { - State state = stateInitializer(session); - - if (state is null) - { - throw new InvalidOperationException("State initializer must return a non-null state."); - } - - return state; - }; + session => stateInitializer(session) ?? throw new InvalidOperationException("State initializer must return a non-null state."); /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) @@ -332,7 +322,7 @@ public async Task EnsureMemoryStoreCreatedAsync( /// Waits for all pending memory update operations to complete. /// /// - /// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update + /// Memory extraction in Microsoft Foundry is asynchronous. This method polls the latest pending update /// and returns when it has completed, failed, or been superseded. Since updates are processed in order, /// completion of the latest update implies all prior updates have also been processed. /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs index 717df1d12b..6646c482a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// Allows scoping of memories for the . /// /// -/// Azure AI Foundry memories are scoped by a single string identifier that you control. +/// Microsoft Foundry memories are scoped by a single string identifier that you control. /// Common patterns include using a user ID, team ID, or other unique identifier /// to partition memories across different contexts. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs index bfc7bd36ff..6db870f8ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs @@ -175,7 +175,7 @@ private async Task GetAgentAsync(AgentVersion agentVersion, Cancellatio AIProjectClient client = this.GetAgentClient(); - agent = client.AsAIAgent(agentVersion, tools: null, clientFactory: null, services: null); + agent = client.AsAIAgent(agentVersion); FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); if (functionInvokingClient is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 501c7df230..22ee1b48eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -145,7 +145,7 @@ private static Workflow BuildConcurrentCore( return builder.Build(); } - /// Creates a new using as the starting agent in the workflow. + /// Creates a new using as the starting agent in the workflow. /// The agent that will receive inputs provided to the workflow. /// The builder for creating a workflow based on handoffs. /// @@ -154,7 +154,7 @@ private static Workflow BuildConcurrentCore( /// The must be capable of understanding those provided. If the agent /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. /// - public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) + public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) { Throw.IfNull(initialAgent); return new(initialAgent); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index ccb993b188..8e5cee7ed5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Workflows.Specialized; @@ -8,10 +9,21 @@ namespace Microsoft.Agents.AI.Workflows; +/// +[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] +public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + +/// +public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + /// /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// -public sealed class HandoffsWorkflowBuilder +public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore { /// /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, @@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder private bool _emitAgentResponseEvents; private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; + private bool _returnToPrevious; /// /// Initializes a new instance of the class with no handoff relationships. /// /// The first agent to be invoked (prior to any handoff). - internal HandoffsWorkflowBuilder(AIAgent initialAgent) + internal HandoffWorkflowBuilderCore(AIAgent initialAgent) { this._initialAgent = initialAgent; this._allAgents.Add(initialAgent); @@ -63,10 +76,10 @@ in your conversation with the user. /// constant. /// /// The instructions to provide, or to restore the default instructions. - public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) + public TBuilder WithHandoffInstructions(string? instructions) { this.HandoffInstructions = instructions ?? DefaultHandoffInstructions; - return this; + return (TBuilder)this; } /// @@ -75,10 +88,10 @@ public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) /// /// /// - public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) + public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) { this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; - return this; + return (TBuilder)this; } /// @@ -86,10 +99,10 @@ public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentRespo /// /// /// - public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) { this._emitAgentResponseEvents = emitAgentResponseEvents; - return this; + return (TBuilder)this; } /// @@ -97,10 +110,21 @@ public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEve /// s flowing through the handoff workflow. Defaults to . /// /// The filtering behavior to apply. - public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) + public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) { this._toolCallFilteringBehavior = behavior; - return this; + return (TBuilder)this; + } + + /// + /// Configures the workflow so that subsequent user turns route directly back to the specialist agent + /// that handled the previous turn, rather than always routing through the initial (coordinator) agent. + /// + /// The updated instance. + public TBuilder EnableReturnToPrevious() + { + this._returnToPrevious = true; + return (TBuilder)this; } /// @@ -110,7 +134,7 @@ public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilt /// The target agents to add as handoff targets for the source agent. /// The updated instance. /// The handoff reason for each target in is derived from that agent's description or name. - public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) + public TBuilder WithHandoffs(AIAgent from, IEnumerable to) { Throw.IfNull(from); Throw.IfNull(to); @@ -125,7 +149,7 @@ public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable t this.WithHandoff(from, target); } - return this; + return (TBuilder)this; } /// @@ -138,7 +162,7 @@ public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable t /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -153,7 +177,7 @@ public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent t this.WithHandoff(source, to, handoffReason); } - return this; + return (TBuilder)this; } /// @@ -166,7 +190,7 @@ public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent t /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -196,7 +220,7 @@ public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? han Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered."); } - return this; + return (TBuilder)this; } /// @@ -206,8 +230,8 @@ public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? han /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(); - HandoffsEndExecutor end = new(); + HandoffsStartExecutor start = new(this._returnToPrevious); + HandoffsEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); HandoffAgentExecutorOptions options = new(this.HandoffInstructions, @@ -215,11 +239,31 @@ public Workflow Build() this._emitAgentResponseUpdateEvents, this._toolCallFilteringBehavior); - // Create an AgentExecutor for each again. + // Create an AgentExecutor for each agent. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); - // Connect the start executor to the initial agent. - builder.AddEdge(start, executors[this._initialAgent.Id]); + // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). + if (this._returnToPrevious) + { + string initialAgentId = this._initialAgent.Id; + builder.AddSwitch(start, sb => + { + foreach (var agent in this._allAgents) + { + if (agent.Id != initialAgentId) + { + string agentId = agent.Id; + sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + } + } + + sb.WithDefault(executors[initialAgentId]); + }); + } + else + { + builder.AddEdge(start, executors[this._initialAgent.Id]); + } // Initialize each executor with its handoff targets to the other executors. foreach (var agent in this._allAgents) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 21519e07ee..98205a40c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -42,7 +42,7 @@ public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior) internal static bool IsHandoffFunctionName(string name) { - return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); + return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); } public IEnumerable FilterMessages(List messages) @@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor( private readonly AIAgent _agent = agent; private readonly HashSet _handoffFunctionNames = []; + private readonly Dictionary _handoffFunctionToAgentId = []; private ChatClientAgentRunOptions? _agentOptions; public void Initialize( @@ -199,9 +200,10 @@ public void Initialize( foreach (HandoffTarget handoff in handoffs) { index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); this._handoffFunctionNames.Add(handoffFunc.Name); + this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; this._agentOptions.ChatOptions.Tools.Add(handoffFunc); @@ -267,7 +269,11 @@ await AddUpdateAsync( roleChanges.ResetUserToAssistantForChangedRoles(); - return new(message.TurnToken, requestedHandoff, allMessages); + string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) + ? targetAgentId + : this._agent.Id; + + return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index cc4d87d21a..56e2fef9df 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, string? InvokedHandoff, - List Messages); + List Messages, + string? CurrentAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs index 69f81376be..4a43c00a72 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -1,20 +1,35 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((handoff, context, cancellationToken) => - context.YieldOutputAsync(handoff.Messages, cancellationToken))) + this.HandleAsync(handoff, context, cancellationToken))) .YieldsOutput>(); + private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken) + { + if (returnToPrevious) + { + await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, + handoff.CurrentAgentId, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken) + .ConfigureAwait(false); + } + + await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false); + } + public ValueTask ResetAsync() => default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs index 9039e86f5b..87c3b4566b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -7,8 +7,14 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; +internal static class HandoffConstants +{ + internal const string CurrentAgentTrackerKey = "LastAgentId"; + internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; +} + /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -22,7 +28,25 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui base.ConfigureProtocol(protocolBuilder).SendsMessage(); protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken); + { + if (returnToPrevious) + { + return context.InvokeWithStateAsync( + async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + { + HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); + + return currentAgentId; + }, + HandoffConstants.CurrentAgentTrackerKey, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken); + } + + HandoffState handoff = new(new(emitEvents), null, messages); + return context.SendMessageAsync(handoff, cancellationToken); + } public new ValueTask ResetAsync() => base.ResetAsync(); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 6722bd8738..05caff4d83 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -788,6 +788,13 @@ private async Task chatOptions.ConversationId = typedSession.ConversationId; } + // When per-service-call persistence is active, set a sentinel conversation ID so that + // FunctionInvokingChatClient treats locally-persisted history the same as service-managed + // history. This prevents it from adding duplicate FunctionCallContent messages into the + // request when processing approval responses — the loaded history already contains them. + // ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client. + chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions); + // Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible. List messagesList = inputMessagesForChatClient as List ?? inputMessagesForChatClient.ToList(); @@ -929,6 +936,26 @@ private bool PersistsChatHistoryPerServiceCall } } + /// + /// Sets the sentinel on + /// when per-service-call persistence is active and no real + /// conversation ID is present. + /// + /// + /// The (possibly new) with the sentinel set, or the original + /// if no sentinel is needed. + /// + private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions) + { + if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId)) + { + chatOptions ??= new ChatOptions(); + chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId; + } + + return chatOptions; + } + /// /// Gets a value indicating whether the agent has a /// decorator in mark-only mode, which marks messages for later persistence at the end of the run. diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs index 0085afbdd5..e733b778eb 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs @@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient /// internal const string PersistedMarkerKey = "_chatHistoryPersisted"; + /// + /// A sentinel value set on by + /// when per-service-call persistence is active and no real conversation ID exists. + /// + /// + /// + /// This signals to that the chat history is being managed + /// externally (by this decorator), which prevents it from adding duplicate + /// messages into the request during approval-response processing. Without this sentinel, + /// would reconstruct function-call messages from approval + /// responses and append them to the original messages — but the loaded history already contains + /// those same function calls, causing duplicate tool-call entries that the model rejects. + /// + /// + /// This decorator strips the sentinel before forwarding requests to the inner client, so the + /// underlying model never sees it. + /// + /// + internal const string LocalHistoryConversationId = "_agent_local_history"; + /// /// Initializes a new instance of the class. /// @@ -87,6 +107,7 @@ public override async Task GetResponseAsync( CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); ChatResponse response; try @@ -130,6 +151,7 @@ public override async IAsyncEnumerable GetStreamingResponseA [EnumeratorCancellation] CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); List responseUpdates = []; @@ -310,4 +332,20 @@ private static void MarkAsPersisted(IEnumerable messages) } } } + + /// + /// If the carry the sentinel, + /// returns a clone with the conversation ID cleared so the inner client never sees it. + /// Otherwise returns the original unchanged. + /// + private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options) + { + if (options?.ConversationId == LocalHistoryConversationId) + { + options = options.Clone(); + options.ConversationId = null; + } + + return options; + } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs new file mode 100644 index 0000000000..5f0a66808d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for all agent skills. +/// +/// +/// +/// A skill represents a domain-specific capability with instructions, resources, and scripts. +/// Concrete implementations include (filesystem-backed). +/// +/// +/// Skill metadata follows the Agent Skills specification. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkill +{ + /// + /// Gets the frontmatter metadata for this skill. + /// + /// + /// Contains the L1 discovery metadata (name, description, license, compatibility, etc.) + /// as defined by the Agent Skills specification. + /// + public abstract AgentSkillFrontmatter Frontmatter { get; } + + /// + /// Gets the full skill content. + /// + /// + /// For file-based skills this is the raw SKILL.md file content. + /// + public abstract string Content { get; } + + /// + /// Gets the resources associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific resources. + /// + public virtual IReadOnlyList? Resources => null; + + /// + /// Gets the scripts associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific scripts. + /// + public virtual IReadOnlyList? Scripts => null; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs new file mode 100644 index 0000000000..df087ff2bb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the YAML frontmatter metadata parsed from a SKILL.md file. +/// +/// +/// +/// Frontmatter is the L1 (discovery) layer of the +/// Agent Skills specification. +/// It contains the minimal metadata needed to advertise a skill in the system prompt +/// without loading the full skill content. +/// +/// +/// The constructor validates the name and description against specification rules +/// and throws if either value is invalid. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillFrontmatter +{ + /// + /// Maximum allowed length for the skill name. + /// + internal const int MaxNameLength = 64; + + /// + /// Maximum allowed length for the skill description. + /// + internal const int MaxDescriptionLength = 1024; + + /// + /// Maximum allowed length for the compatibility field. + /// + internal const int MaxCompatibilityLength = 500; + + // Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter): + // lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens. + private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + + private string? _compatibility; + + /// + /// Initializes a new instance of the class. + /// + /// Skill name in kebab-case. + /// Skill description for discovery. + /// Optional compatibility information (max 500 chars). + /// + /// Thrown when , , or violates the + /// Agent Skills specification rules. + /// + public AgentSkillFrontmatter(string name, string description, string? compatibility = null) + { + if (!ValidateName(name, out string? reason) || + !ValidateDescription(description, out reason) || + !ValidateCompatibility(compatibility, out reason)) + { + throw new ArgumentException(reason); + } + + this.Name = name; + this.Description = description; + this._compatibility = compatibility; + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } + + /// + /// Gets or sets an optional license name or reference. + /// + public string? License { get; set; } + + /// + /// Gets or sets optional compatibility information (max 500 chars). + /// + /// + /// Thrown when the value exceeds characters. + /// + public string? Compatibility + { + get => this._compatibility; + set + { + if (!ValidateCompatibility(value, out string? reason)) + { + throw new ArgumentException(reason); + } + + this._compatibility = value; + } + } + + /// + /// Gets or sets optional space-delimited list of pre-approved tools. + /// + public string? AllowedTools { get; set; } + + /// + /// Gets or sets the arbitrary key-value metadata for this skill. + /// + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// + /// Validates a skill name against specification rules. + /// + /// The skill name to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the name is valid; otherwise, . + public static bool ValidateName( + string? name, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(name)) + { + reason = "Skill name is required."; + return false; + } + + if (name.Length > MaxNameLength) + { + reason = $"Skill name must be {MaxNameLength} characters or fewer."; + return false; + } + + if (!s_validNameRegex.IsMatch(name)) + { + reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates a skill description against specification rules. + /// + /// The skill description to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the description is valid; otherwise, . + public static bool ValidateDescription( + string? description, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(description)) + { + reason = "Skill description is required."; + return false; + } + + if (description.Length > MaxDescriptionLength) + { + reason = $"Skill description must be {MaxDescriptionLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates an optional skill compatibility value against specification rules. + /// + /// The optional compatibility value to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the value is valid; otherwise, . + public static bool ValidateCompatibility( + string? compatibility, + [NotNullWhen(false)] out string? reason) + { + if (compatibility?.Length > MaxCompatibilityLength) + { + reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs new file mode 100644 index 0000000000..b3cfc3f117 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (e.g., relative path or identifier). + /// An optional description of the resource. + protected AgentSkillResource(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the resource name. + /// + public string Name { get; } + + /// + /// Gets the optional resource description. + /// + public string? Description { get; } + + /// + /// Reads the resource content asynchronously. + /// + /// Optional service provider for dependency injection. + /// Cancellation token. + /// The resource content. + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs new file mode 100644 index 0000000000..ad647d2eb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill scripts. A script represents an executable action associated with a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillScript +{ + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// An optional description of the script. + protected AgentSkillScript(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the script name. + /// + public string Name { get; } + + /// + /// Gets the optional script description. + /// + public string? Description { get; } + + /// + /// Runs the script with the given arguments. + /// + /// The skill that owns this script. + /// Arguments for script execution. + /// Cancellation token. + /// The script execution result. + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs new file mode 100644 index 0000000000..f2f87851c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that exposes agent skills from one or more instances. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt. +/// Load — the full skill body is returned via the load_skill tool. +/// Read resources — supplementary content is read on demand via the read_skill_resource tool. +/// Run scripts — scripts are executed via the run_skill_script tool (when scripts exist). +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class AgentSkillsProvider : AIContextProvider +{ + /// + /// Placeholder token for the generated skills list in the prompt template. + /// + private const string SkillsPlaceholder = "{skills}"; + + /// + /// Placeholder token for the script instructions in the prompt template. + /// + private const string ScriptInstructionsPlaceholder = "{script_instructions}"; + + /// + /// Placeholder token for the resource instructions in the prompt template. + /// + private const string ResourceInstructionsPlaceholder = "{resource_instructions}"; + + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {skills} + + + When a task aligns with a skill's domain, follow these steps in exact order: + - Use `load_skill` to retrieve the skill's instructions. + - Follow the provided guidance. + {resource_instructions} + {script_instructions} + Only load what is needed, when it is needed. + """; + + private readonly AgentSkillsSource _source; + private readonly AgentSkillsProviderOptions? _options; + private readonly ILogger _logger; + private Task? _contextTask; + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from a single directory. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Path to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from multiple directories. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Paths to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + new DeduplicatingAgentSkillsSource( + new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), + loggerFactory), + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// from a custom . Unlike other constructors, this one does not + /// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline. + /// + /// The skill source providing skills. + /// Optional configuration. + /// Optional logger factory. + public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + this._source = Throw.IfNull(source); + this._options = options; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + if (options?.SkillsInstructionPrompt is string prompt) + { + ValidatePromptTemplate(prompt, nameof(options)); + } + } + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._options?.DisableCaching == true) + { + return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + if (skills is not { Count: > 0 }) + { + return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 }); + bool hasResources = skills.Any(s => s.Resources is { Count: > 0 }); + + return new AIContext + { + Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources), + Tools = this.BuildTools(skills, hasScripts, hasResources), + }; + } + + private async Task GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing) + { + return await existing.ConfigureAwait(false); + } + + try + { + var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + return result; + } + catch (Exception ex) + { + this._contextTask = null; + tcs.TrySetException(ex); + throw; + } + } + + private IList BuildTools(IList skills, bool hasScripts, bool hasResources) + { + IList tools = + [ + AIFunctionFactory.Create( + (string skillName) => this.LoadSkill(skills, skillName), + name: "load_skill", + description: "Loads the full content of a specific skill"), + ]; + + if (hasResources) + { + tools.Add(AIFunctionFactory.Create( + (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) => + this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken), + name: "read_skill_resource", + description: "Reads a resource associated with a skill, such as references, assets, or dynamic data.")); + } + + if (!hasScripts) + { + return tools; + } + + AIFunction scriptFunction = AIFunctionFactory.Create( + (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), + name: "run_skill_script", + description: "Runs a script associated with a skill."); + + if (this._options?.ScriptApproval == true) + { + return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)]; + } + + return [.. tools, scriptFunction]; + } + + private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions, bool includeResourceInstructions) + { + string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; + + var sb = new StringBuilder(); + foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + string resourceInstruction = includeResourceInstructions + ? """ + - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed + (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). + """ + : string.Empty; + + string scriptInstruction = includeScriptInstructions + ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed." + : string.Empty; + + return new StringBuilder(promptTemplate) + .Replace(SkillsPlaceholder, sb.ToString().TrimEnd()) + .Replace(ResourceInstructionsPlaceholder, resourceInstruction) + .Replace(ScriptInstructionsPlaceholder, scriptInstruction) + .ToString(); + } + + private string LoadSkill(IList skills, string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Content; + } + + private async Task ReadSkillResourceAsync(IList skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName); + if (resource is null) + { + return $"Error: Resource '{resourceName}' not found in skill '{skillName}'."; + } + + try + { + return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(scriptName)) + { + return "Error: Script name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName); + if (script is null) + { + return $"Error: Script '{scriptName}' not found in skill '{skillName}'."; + } + + try + { + return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogScriptExecutionError(this._logger, skillName, scriptName, ex); + return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'."; + } + } + + /// + /// Validates that a custom prompt template contains the required placeholder tokens. + /// + private static void ValidatePromptTemplate(string template, string paramName) + { + if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.", + paramName); + } + + if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.", + paramName); + } + + if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.", + paramName); + } + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); + + [LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")] + private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs new file mode 100644 index 0000000000..17d7f2d6f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Fluent builder for constructing an backed by a composite source. +/// +/// +/// +/// var provider = new AgentSkillsProviderBuilder() +/// .UseFileSkills("/path/to/skills") +/// .Build(); +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderBuilder +{ + private readonly List> _sourceFactories = []; + private AgentSkillsProviderOptions? _options; + private ILoggerFactory? _loggerFactory; + private AgentFileSkillScriptRunner? _scriptRunner; + private Func? _filter; + + /// + /// Adds a file-based skill source that discovers skills from a filesystem directory. + /// + /// Path to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + return this.UseFileSkills([skillPath], options, scriptRunner); + } + + /// + /// Adds a file-based skill source that discovers skills from multiple filesystem directories. + /// + /// Paths to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + this._sourceFactories.Add((builderScriptRunner, loggerFactory) => + { + var resolvedRunner = scriptRunner + ?? builderScriptRunner + ?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); + return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory); + }); + return this; + } + + /// + /// Adds a custom skill source. + /// + /// The custom skill source. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) + { + _ = Throw.IfNull(source); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Sets a custom system prompt template. + /// + /// The prompt template with {skills} placeholder for the skills list, + /// {resource_instructions} for optional resource instructions, + /// and {script_instructions} for optional script instructions. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) + { + this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate; + return this; + } + + /// + /// Enables or disables the script approval gate. + /// + /// Whether script execution requires approval. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) + { + this.GetOrCreateOptions().ScriptApproval = enabled; + return this; + } + + /// + /// Sets the runner for file-based skill scripts. + /// + /// The delegate that runs file-based scripts. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner) + { + this._scriptRunner = Throw.IfNull(runner); + return this; + } + + /// + /// Sets the logger factory. + /// + /// The logger factory. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) + { + this._loggerFactory = loggerFactory; + return this; + } + + /// + /// Sets a filter predicate that controls which skills are included. + /// + /// + /// Skills for which the predicate returns are kept; + /// others are excluded. Only one filter is supported; calling this method + /// again replaces any previously set filter. + /// + /// A predicate that determines which skills to include. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFilter(Func predicate) + { + _ = Throw.IfNull(predicate); + this._filter = predicate; + return this; + } + + /// + /// Configures the using the provided delegate. + /// + /// A delegate to configure the options. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseOptions(Action configure) + { + _ = Throw.IfNull(configure); + configure(this.GetOrCreateOptions()); + return this; + } + + /// + /// Builds the . + /// + /// A configured . + public AgentSkillsProvider Build() + { + var resolvedSources = new List(this._sourceFactories.Count); + foreach (var factory in this._sourceFactories) + { + resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory)); + } + + AgentSkillsSource source; + if (resolvedSources.Count == 1) + { + source = resolvedSources[0]; + } + else + { + source = new AggregatingAgentSkillsSource(resolvedSources); + } + + // Apply user-specified filter, then dedup. + if (this._filter != null) + { + source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory); + } + + source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory); + + return new AgentSkillsProvider(source, this._options, this._loggerFactory); + } + + private AgentSkillsProviderOptions GetOrCreateOptions() + { + return this._options ??= new AgentSkillsProviderOptions(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..2f89ebfda6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// The template must contain {skills} as the placeholder for the generated skills list, + /// {resource_instructions} for resource instructions, + /// and {script_instructions} for script instructions. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } + + /// + /// Gets or sets a value indicating whether script execution requires approval. + /// When , script execution is blocked until approved. + /// Defaults to . + /// + public bool ScriptApproval { get; set; } + + /// + /// Gets or sets a value indicating whether caching of tools and instructions is disabled. + /// When (the default), the provider caches the tools and instructions + /// after the first build and returns the cached instance on subsequent calls. + /// Set to to rebuild tools and instructions on every invocation. + /// + public bool DisableCaching { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs new file mode 100644 index 0000000000..6a72d0c01a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill sources. A skill source provides skills from a specific origin +/// (filesystem, remote server, database, in-memory, etc.). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillsSource +{ + /// + /// Gets the skills provided by this source. + /// + /// Cancellation token. + /// A collection of skills from this source. + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs new file mode 100644 index 0000000000..7dc468742f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that aggregates multiple child sources, preserving their registration order. +/// +/// +/// Skills from each child source are returned in the order the sources were registered, +/// with each source's skills appended sequentially. No deduplication or filtering is applied. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource +{ + private readonly IEnumerable _sources; + + /// + /// Initializes a new instance of the class. + /// + /// The child sources to aggregate. + public AggregatingAgentSkillsSource(IEnumerable sources) + { + this._sources = Throw.IfNull(sources); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = new List(); + foreach (var source in this._sources) + { + var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs new file mode 100644 index 0000000000..bf943daae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source to deduplicate. + /// Optional logger factory. + public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var deduplicated = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in allSkills) + { + if (seen.Add(skill.Frontmatter.Name)) + { + deduplicated.Add(skill); + } + else + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name); + } + } + + return deduplicated; + } + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs new file mode 100644 index 0000000000..920ad0428b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for skill sources that delegate operations to an inner source +/// while allowing for extensibility and customization. +/// +/// +/// implements the decorator pattern for , +/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication, +/// filtering, etc.) while delegating core operations to an underlying source. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource +{ + /// + /// Initializes a new instance of the class with the specified inner source. + /// + /// The underlying skill source that will handle the core operations. + protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource) + { + this.InnerSource = Throw.IfNull(innerSource); + } + + /// + /// Gets the inner skill source that receives delegated operations. + /// + protected AgentSkillsSource InnerSource { get; } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + => this.InnerSource.GetSkillsAsync(cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs new file mode 100644 index 0000000000..2bd26acce2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that filters skills using a caller-supplied predicate. +/// +/// +/// Skills for which the predicate returns are included in the result; +/// skills for which it returns are excluded and logged at debug level. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source whose skills will be filtered. + /// + /// A predicate that determines which skills to include. Skills for which the predicate + /// returns are kept; others are excluded. + /// + /// Optional logger factory. + public FilteringAgentSkillsSource( + AgentSkillsSource innerSource, + Func predicate, + ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._predicate = Throw.IfNull(predicate); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var filtered = new List(); + foreach (var skill in allSkills) + { + if (this._predicate(skill)) + { + filtered.Add(skill); + } + else + { + LogSkillFiltered(this._logger, skill.Frontmatter.Name); + } + } + + return filtered; + } + + [LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")] + private static partial void LogSkillFiltered(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs new file mode 100644 index 0000000000..4bb62e99a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An discovered from a filesystem directory backed by a SKILL.md file. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkill : AgentSkill +{ + private readonly IReadOnlyList _resources; + private readonly IReadOnlyList _scripts; + + /// + /// Initializes a new instance of the class. + /// + /// The parsed frontmatter metadata for this skill. + /// The full raw SKILL.md file content including YAML frontmatter. + /// Absolute path to the directory containing this skill. + /// Resources discovered for this skill. + /// Scripts discovered for this skill. + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, + string content, + string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Content = Throw.IfNull(content); + this.Path = Throw.IfNullOrWhitespace(path); + this._resources = resources ?? []; + this._scripts = scripts ?? []; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string Path { get; } + + /// + public override IReadOnlyList Resources => this._resources; + + /// + public override IReadOnlyList Scripts => this._scripts; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs new file mode 100644 index 0000000000..9ba5b7e24a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory. +/// +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (relative path within the skill directory). + /// The absolute file path to the resource. + public AgentFileSkillResource(string name, string fullPath) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + } + + /// + /// Gets the absolute file path to the resource. + /// + public string FullPath { get; } + + /// + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { +#if NET8_0_OR_GREATER + return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var reader = new StreamReader(this.FullPath, Encoding.UTF8); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs new file mode 100644 index 0000000000..116847126f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner? _runner; + + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// The absolute file path to the script. + /// Optional external runner for running the script. An is thrown from if no runner is provided. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + this._runner = runner; + } + + /// + /// Gets the absolute file path to the script. + /// + public string FullPath { get; } + + /// + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + if (skill is not AgentFileSkill fileSkill) + { + throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); + } + + if (this._runner is null) + { + throw new InvalidOperationException( + $"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " + + $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); + } + + return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs new file mode 100644 index 0000000000..c19d19e056 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Delegate for running file-based skill scripts. +/// +/// +/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// +/// The skill that owns the script. +/// The file-based script to run. +/// Optional arguments for the script, provided by the agent/LLM. +/// Cancellation token. +/// The script execution result. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs rename to dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 18fa87999a..c6dc3bc629 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -2,151 +2,135 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// -/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// A skill source that discovers skills from filesystem directories containing SKILL.md files. /// /// -/// Searches directories recursively (up to levels) for SKILL.md files. -/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill +/// Searches directories recursively (up to 2 levels deep) for SKILL.md files. +/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill /// directory for files with matching extensions. Invalid resources are skipped with logged warnings. -/// Resource paths are checked against path traversal and symlink escape attacks. +/// Resource and script paths are checked against path traversal and symlink escape attacks. /// -internal sealed partial class FileAgentSkillLoader +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - private const int MaxNameLength = 64; - private const int MaxDescriptionLength = 1024; + + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; + private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. - // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools), + // Group 2 = quoted value, Group 3 = unquoted value. // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. - // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), - // "description: \"A skill\"" → (description, A skill, _) - private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Validates skill names: lowercase letters, numbers, and hyphens only; - // must not start or end with a hyphen; must not contain consecutive hyphens. - // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗ - private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + // Matches a "metadata:" line followed by indented sub-key/value pairs. + // Group 1 captures the entire indented block beneath the metadata key. + private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - private readonly ILogger _logger; + // Matches indented YAML "key: value" lines within a metadata block. + // Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value. + private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; + private readonly HashSet _allowedScriptExtensions; + private readonly AgentFileSkillScriptRunner? _scriptRunner; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Path to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([skillPath], scriptRunner, options, loggerFactory) + { + } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The logger instance. - /// File extensions to recognize as skill resources. When , defaults are used. - internal FileAgentSkillLoader(ILogger logger, IEnumerable? allowedResourceExtensions = null) + /// Paths to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { - this._logger = logger; + this._skillPaths = Throw.IfNull(skillPaths); + + var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); - ValidateExtensions(allowedResourceExtensions); + ValidateExtensions(resolvedOptions.AllowedResourceExtensions); + ValidateExtensions(resolvedOptions.AllowedScriptExtensions); this._allowedResourceExtensions = new HashSet( - allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"], + resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + StringComparer.OrdinalIgnoreCase); + + this._allowedScriptExtensions = new HashSet( + resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); + + this._scriptRunner = scriptRunner; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } - /// - /// Discovers skill directories and loads valid skills from them. - /// - /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder. - /// A dictionary of loaded skills keyed by skill name. - internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths) + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) { - var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); - - var discoveredPaths = DiscoverSkillDirectories(skillPaths); + var discoveredPaths = DiscoverSkillDirectories(this._skillPaths); LogSkillsDiscovered(this._logger, discoveredPaths.Count); + var skills = new List(); + foreach (string skillPath in discoveredPaths) { - FileAgentSkill? skill = this.ParseSkillFile(skillPath); + AgentFileSkill? skill = this.ParseSkillDirectory(skillPath); if (skill is null) { continue; } - if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) - { - LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); - - // Skip duplicate skill names, keeping the first one found. - continue; - } - - skills[skill.Frontmatter.Name] = skill; + skills.Add(skill); LogSkillLoaded(this._logger, skill.Frontmatter.Name); } LogSkillsLoadedTotal(this._logger, skills.Count); - return skills; - } - - /// - /// Reads a resource file from disk with path traversal and symlink guards. - /// - /// The skill that owns the resource. - /// Relative path of the resource within the skill directory. - /// Cancellation token. - /// The UTF-8 text content of the resource file. - /// - /// The resource is not registered, resolves outside the skill directory, or does not exist. - /// - internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) - { - resourceName = NormalizeResourcePath(resourceName); - - if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) - { - throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); - string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; - - if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); - } - - if (!File.Exists(fullPath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - if (HasSymlinkInPath(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); - } - - LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); - -#if NET - return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); -#else - return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); -#endif + return Task.FromResult(skills as IList); } private static List DiscoverSkillDirectories(IEnumerable skillPaths) @@ -185,30 +169,30 @@ private static void SearchDirectoriesForSkills(string directory, List re } } - private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath) + private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath) { string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName); - string content = File.ReadAllText(skillFilePath, Encoding.UTF8); - if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter)) { return null; } - List resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name); - return new FileAgentSkill( + return new AgentFileSkill( frontmatter: frontmatter, - body: body, - sourcePath: skillDirectoryFullPath, - resourceNames: resourceNames); + content: content, + path: skillDirectoryFullPath, + resources: resources, + scripts: scripts); } - private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter) { - frontmatter = null!; - body = null!; + frontmatter = null; Match match = s_frontmatterRegex.Match(content); if (!match.Success) @@ -217,10 +201,13 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski return false; } + string yamlContent = match.Groups[1].Value.Trim(); + string? name = null; string? description = null; - - string yamlContent = match.Groups[1].Value.Trim(); + string? license = null; + string? compatibility = null; + string? allowedTools = null; foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) { @@ -235,50 +222,62 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski { description = value; } + else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase)) + { + license = value; + } + else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase)) + { + compatibility = value; + } + else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase)) + { + allowedTools = value; + } } - if (string.IsNullOrWhiteSpace(name)) + // Parse metadata block (indented key-value pairs under "metadata:"). + AdditionalPropertiesDictionary? metadata = null; + Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent); + if (metadataMatch.Success) { - LogMissingFrontmatterField(this._logger, skillFilePath, "name"); - return false; + metadata = []; + foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value)) + { + metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + } } - if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) || + !AgentSkillFrontmatter.ValidateDescription(description, out validationReason)) { - LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."); + LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason); return false; } + frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility) + { + License = license, + AllowedTools = allowedTools, + Metadata = metadata, + }; + // skillFilePath is e.g. "/skills/my-skill/SKILL.md". // GetDirectoryName strips the filename → "/skills/my-skill". // GetFileName then extracts the last segment → "my-skill". // This gives us the skill's parent directory name to validate against the frontmatter name. string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty; - if (!string.Equals(name, directoryName, StringComparison.Ordinal)) + if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal)) { if (this._logger.IsEnabled(LogLevel.Error)) { - LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName)); + LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName)); } + frontmatter = null; return false; } - if (string.IsNullOrWhiteSpace(description)) - { - LogMissingFrontmatterField(this._logger, skillFilePath, "description"); - return false; - } - - if (description.Length > MaxDescriptionLength) - { - LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); - return false; - } - - frontmatter = new SkillFrontmatter(name, description); - body = content.Substring(match.Index + match.Length).TrimStart(); - return true; } @@ -287,15 +286,15 @@ private bool TryParseSkillDocument(string content, string skillFilePath, out Ski /// /// /// Recursively walks and collects files whose extension - /// matches , excluding SKILL.md itself. Each candidate + /// matches the allowed set, excluding SKILL.md itself. Each candidate /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with /// a warning. /// - private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) + private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + var resources = new List(); #if NET var enumerationOptions = new EnumerationOptions @@ -326,21 +325,21 @@ private List DiscoverResourceFiles(string skillDirectoryFullPath, string { LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); } + continue; } // Normalize the enumerated path to guard against non-canonical forms - // (redundant separators, 8.3 short names, etc.) that would produce - // malformed relative resource names. string resolvedFilePath = Path.GetFullPath(filePath); // Path containment check - if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath)) + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { if (this._logger.IsEnabled(LogLevel.Warning)) { LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } @@ -351,30 +350,86 @@ private List DiscoverResourceFiles(string skillDirectoryFullPath, string { LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } // Compute relative path and normalize to forward slashes - string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length); - resources.Add(NormalizeResourcePath(relativePath)); + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); } return resources; } /// - /// Checks that is under , - /// guarding against path traversal attacks. + /// Scans a skill directory for script files matching the configured extensions. /// - private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + /// + /// Recursively walks the skill directory and collects files whose extension + /// matches the allowed set. Each candidate is validated against path-traversal + /// and symlink-escape checks; unsafe files are skipped with a warning. + /// + private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; + var scripts = new List(); + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) +#endif + { + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + { + continue; + } + + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + } + + return scripts; } /// - /// Checks whether any segment in (relative to - /// ) is a symlink (reparse point). - /// Uses which is available on all target frameworks. + /// Checks whether any segment in the path (relative to the directory) is a symlink. /// private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) { @@ -399,11 +454,10 @@ private static bool HasSymlinkInPath(string fullPath, string normalizedDirectory } /// - /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing - /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are - /// treated as the same resource. + /// Normalizes a relative path by replacing backslashes with forward slashes + /// and trimming a leading "./" prefix. /// - private static string NormalizeResourcePath(string path) + private static string NormalizePath(string path) { if (path.IndexOf('\\') >= 0) { @@ -419,8 +473,7 @@ private static string NormalizeResourcePath(string path) } /// - /// Replaces control characters in a file path with '?' to prevent log injection - /// via crafted filenames (e.g., filenames containing newlines on Linux). + /// Replaces control characters in a file path with '?' to prevent log injection. /// private static string SanitizePathForLog(string path) { @@ -449,7 +502,7 @@ private static void ValidateExtensions(IEnumerable? extensions) if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal)) { #pragma warning disable CA2208 // Instantiate argument exceptions correctly - throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions)); + throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions"); #pragma warning restore CA2208 // Instantiate argument exceptions correctly } } @@ -467,9 +520,6 @@ private static void ValidateExtensions(IEnumerable? extensions) [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] - private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); @@ -479,15 +529,15 @@ private static void ValidateExtensions(IEnumerable? extensions) [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")] private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] - private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); - [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] - private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); - [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")] + private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] + private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs new file mode 100644 index 0000000000..edaec327fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for file-based skill sources. +/// +/// +/// Use this class to configure file-based skill discovery without relying on +/// positional constructor or method parameters. New options can be added here +/// without breaking existing callers. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillsSourceOptions +{ + /// + /// Gets or sets the allowed file extensions for skill resources. + /// When , defaults to .md, .json, .yaml, + /// .yml, .csv, .xml, .txt. + /// + public IEnumerable? AllowedResourceExtensions { get; set; } + + /// + /// Gets or sets the allowed file extensions for skill scripts. + /// When , defaults to .py, .js, .sh, + /// .ps1, .cs, .csx. + /// + public IEnumerable? AllowedScriptExtensions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs deleted file mode 100644 index f28bad3ab0..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents a loaded Agent Skill discovered from a filesystem directory. -/// -/// -/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) -/// and a markdown body with instructions. Resource files referenced in the body are validated at -/// discovery time and read from disk on demand. -/// -internal sealed class FileAgentSkill -{ - /// - /// Initializes a new instance of the class. - /// - /// Parsed YAML frontmatter (name and description). - /// The SKILL.md content after the closing --- delimiter. - /// Absolute path to the directory containing this skill. - /// Relative paths of resource files referenced in the skill body. - public FileAgentSkill( - SkillFrontmatter frontmatter, - string body, - string sourcePath, - IReadOnlyList? resourceNames = null) - { - this.Frontmatter = Throw.IfNull(frontmatter); - this.Body = Throw.IfNull(body); - this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); - this.ResourceNames = resourceNames ?? []; - } - - /// - /// Gets the parsed YAML frontmatter (name and description). - /// - public SkillFrontmatter Frontmatter { get; } - - /// - /// Gets the SKILL.md body content (without the YAML frontmatter). - /// - public string Body { get; } - - /// - /// Gets the directory path where the skill was discovered. - /// - public string SourcePath { get; } - - /// - /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). - /// - public IReadOnlyList ResourceNames { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs deleted file mode 100644 index 460faced70..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// An that discovers and exposes Agent Skills from filesystem directories. -/// -/// -/// -/// This provider implements the progressive disclosure pattern from the -/// Agent Skills specification: -/// -/// -/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). -/// Load — the full SKILL.md body is returned via the load_skill tool. -/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. -/// -/// -/// Skills are discovered by searching the configured directories for SKILL.md files. -/// Referenced resources are validated at initialization; invalid skills are excluded and logged. -/// -/// -/// Security: this provider only reads static content. Skill metadata is XML-escaped -/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. -/// Only use skills from trusted sources. -/// -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class FileAgentSkillsProvider : AIContextProvider -{ - private const string DefaultSkillsInstructionPrompt = - """ - You have access to skills containing domain-specific knowledge and capabilities. - Each skill provides specialized instructions, reference documents, and assets for specific tasks. - - - {0} - - - When a task aligns with a skill's domain: - 1. Use `load_skill` to retrieve the skill's instructions - 2. Follow the provided guidance - 3. Use `read_skill_resource` to read any references or other files mentioned by the skill - - Only load what is needed, when it is needed. - """; - - private readonly Dictionary _skills; - private readonly ILogger _logger; - private readonly FileAgentSkillLoader _loader; - private readonly AITool[] _tools; - private readonly string? _skillsInstructionPrompt; - - /// - /// Initializes a new instance of the class that searches a single directory for skills. - /// - /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - : this([skillPath], options, loggerFactory) - { - } - - /// - /// Initializes a new instance of the class that searches multiple directories for skills. - /// - /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - { - _ = Throw.IfNull(skillPaths); - - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - - this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions); - this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); - - this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); - - this._tools = - [ - AIFunctionFactory.Create( - this.LoadSkill, - name: "load_skill", - description: "Loads the full instructions for a specific skill."), - AIFunctionFactory.Create( - this.ReadSkillResourceAsync, - name: "read_skill_resource", - description: "Reads a file associated with a skill, such as references or assets."), - ]; - } - - /// - protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - if (this._skills.Count == 0) - { - return base.ProvideAIContextAsync(context, cancellationToken); - } - - return new ValueTask(new AIContext - { - Instructions = this._skillsInstructionPrompt, - Tools = this._tools - }); - } - - private string LoadSkill(string skillName) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - LogSkillLoading(this._logger, skillName); - - return skill.Body; - } - - private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (string.IsNullOrWhiteSpace(resourceName)) - { - return "Error: Resource name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - try - { - return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - LogResourceReadError(this._logger, skillName, resourceName, ex); - return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; - } - } - - private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) - { - string promptTemplate = DefaultSkillsInstructionPrompt; - - if (options?.SkillsInstructionPrompt is { } optionsInstructions) - { - try - { - _ = string.Format(optionsInstructions, string.Empty); - } - catch (FormatException ex) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string.", - nameof(options), - ex); - } - - if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.", - nameof(options)); - } - - promptTemplate = optionsInstructions; - } - - if (skills.Count == 0) - { - return null; - } - - var sb = new StringBuilder(); - - // Order by name for deterministic prompt output across process restarts - // (Dictionary enumeration order is not guaranteed and varies with hash randomization). - foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) - { - sb.AppendLine(" "); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); - sb.AppendLine(" "); - } - - return string.Format(promptTemplate, sb.ToString().TrimEnd()); - } - - [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] - private static partial void LogSkillLoading(ILogger logger, string skillName); - - [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] - private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs deleted file mode 100644 index 600c5b964c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// Configuration options for . -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class FileAgentSkillsProviderOptions -{ - /// - /// Gets or sets a custom system prompt template for advertising skills. - /// Use {0} as the placeholder for the generated skills list. - /// When , a default template is used. - /// - public string? SkillsInstructionPrompt { get; set; } - - /// - /// Gets or sets the file extensions recognized as discoverable skill resources. - /// Each value must start with a '.' character (for example, .md), and - /// extension comparisons are performed in a case-insensitive manner. - /// Files in the skill directory (and its subdirectories) whose extension matches - /// one of these values will be automatically discovered as resources. - /// When , a default set of extensions is used - /// (.md, .json, .yaml, .yml, .csv, .xml, .txt). - /// - public IEnumerable? AllowedResourceExtensions { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs deleted file mode 100644 index 123a6c43f4..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. -/// -internal sealed class SkillFrontmatter -{ - /// - /// Initializes a new instance of the class. - /// - /// Skill name. - /// Skill description. - public SkillFrontmatter(string name, string description) - { - this.Name = Throw.IfNullOrWhitespace(name); - this.Description = Throw.IfNullOrWhitespace(description); - } - - /// - /// Gets the skill name. Lowercase letters, numbers, and hyphens only. - /// - public string Name { get; } - - /// - /// Gets the skill description. Used for discovery in the system prompt. - /// - public string Description { get; } -} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs index 870dda648c..0f2b123fd9 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs @@ -7,6 +7,8 @@ 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 AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() @@ -16,7 +18,8 @@ public override Task RunWithNoMessageDoesNotFailAsync() } } -public class AIProjectClientAgentRunStreamingConversationTests() : RunTests(() => new()) +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] +public class AIProjectClientAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs index af4cee82e6..ad10ec5b7a 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs @@ -7,6 +7,8 @@ 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 AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() @@ -16,6 +18,7 @@ public override Task RunWithNoMessageDoesNotFailAsync() } } +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientAgentRunConversationTests() : RunTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index 9db48f3832..b3782a6601 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; @@ -8,6 +9,8 @@ namespace AzureAI.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()) { private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time."; @@ -87,6 +90,7 @@ public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() /// /// Represents a fixture for testing AIProjectClient with structured output of type provided at agent initialization. /// +[Obsolete("Use FoundryVersionedAgentStructuredOutputFixture instead.")] public class AIProjectClientStructuredOutputFixture : AIProjectClientFixture { public override async ValueTask InitializeAsync() diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs index 3b0c1c27b4..0e507e44c1 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs @@ -1,10 +1,13 @@ // 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() diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs index 1e47d0a970..a0ee72ebd4 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs @@ -1,10 +1,13 @@ // 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() diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs index 425197853e..bc5f38acf2 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -1,5 +1,7 @@ // 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; @@ -7,6 +9,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Files; using OpenAI.Responses; @@ -14,6 +17,7 @@ namespace AzureAI.IntegrationTests; +[Obsolete("Use FoundryVersionedAgentCreateTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientCreateTests { private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); @@ -51,7 +55,7 @@ public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string create Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); Assert.Equal(AgentDescription, agent.Description); - Assert.Equal(AgentInstructions, agent.Instructions); + Assert.Equal(AgentInstructions, agent.GetService()!.Instructions); var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name); Assert.NotNull(agentRecord); @@ -275,7 +279,7 @@ public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideT try { // Step 2: Wrap the agent version using AsAIAgent extension. - ChatClientAgent agent = this._client.AsAIAgent(agentVersion); + FoundryAgent agent = this._client.AsAIAgent(agentVersion); // Assert the agent was created correctly and retains version metadata. Assert.NotNull(agent); @@ -327,7 +331,7 @@ public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string create static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; var weatherFunction = AIFunctionFactory.Create(GetWeather); - ChatClientAgent agent = createMechanism switch + FoundryAgent agent = createMechanism switch { "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index 42892b99b3..112c76571b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -1,5 +1,7 @@ // 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; @@ -9,18 +11,20 @@ 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; +[Obsolete("Use FoundryVersionedAgentFixture instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientFixture : IChatClientAgentFixture { - private ChatClientAgent _agent = null!; + private FoundryAgent _agent = null!; private AIProjectClient _client = null!; - public IChatClient ChatClient => this._agent.ChatClient; + public IChatClient ChatClient => this._agent.GetService()!.ChatClient; public AIAgent Agent => this._agent; @@ -115,14 +119,14 @@ public async Task CreateChatClientAgentAsync( 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); + return (await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: instructions, tools: aiTools)).GetService()!; } public async Task CreateChatClientAgentAsync(ChatClientAgentOptions options) { options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); - return await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options); + return (await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options)).GetService()!; } public static string GenerateUniqueAgentName(string baseName) => @@ -170,12 +174,13 @@ public ValueTask DisposeAsync() public virtual async ValueTask InitializeAsync() { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - this._agent = await this.CreateChatClientAgentAsync(); + this._agent = await this._client.CreateAIAgentAsync(GenerateUniqueAgentName("HelpfulAssistant"), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: "You are a helpful assistant."); } public async Task InitializeAsync(ChatClientAgentOptions options) { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - this._agent = await this.CreateChatClientAgentAsync(options); + options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); + this._agent = await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options); } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs new file mode 100644 index 0000000000..5895ceb8b9 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs new file mode 100644 index 0000000000..d80b25deb2 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs new file mode 100644 index 0000000000..cd8dc6cb03 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Projects; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +/// +/// Integration tests for non-versioned creation via extension methods. +/// +public class ResponsesAgentExtensionCreateTests +{ + private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)); + + private static string Model => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName); + + private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential()); + + [Fact] + public async Task AsAIAgent_WithModelAndInstructions_CreatesChatClientAgentAndRunsAsync() + { + // Arrange + const string AgentName = "ResponsesAgentExtensionSimple"; + const string AgentDescription = "Integration test agent created from AIProjectClient.AsAIAgent(model, instructions)."; + const string VerificationToken = "integration-extension-ok"; + + FoundryAgent 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(); + + try + { + // Act + AgentResponse response = await agent.RunAsync("Return the verification token.", session); + + // Assert + 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); + } + finally + { + await DeleteSessionAsync(this._client, session); + } + } + + [Fact] + public async Task AsAIAgent_WithOptions_CreatesChatClientAgentAndRunsAsync() + { + // Arrange + const string VerificationToken = "integration-options-ok"; + ChatClientAgentOptions options = new() + { + Name = "ResponsesAgentExtensionOptions", + Description = "Integration test agent created from AIProjectClient.AsAIAgent(options).", + ChatOptions = new ChatOptions + { + ModelId = Model, + Instructions = $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.", + }, + }; + + FoundryAgent agent = this._client.AsAIAgent(options); + ChatClientAgentSession session = await agent.CreateConversationSessionAsync(); + + try + { + // Act + AgentResponse response = await agent.RunAsync("Return the verification token.", session); + + // Assert + 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 + { + await DeleteSessionAsync(this._client, session); + } + } + + private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession session) + { + ChatClientAgentSession typedSession = (ChatClientAgentSession)session; + + if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + await client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId); + } + else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + await DeleteResponseChainAsync(client, typedSession.ConversationId); + } + } + + private static async Task DeleteResponseChainAsync(AIProjectClient client, string lastResponseId) + { + var responsesClient = client.GetProjectOpenAIClient().GetProjectResponsesClient(); + var response = await responsesClient.GetResponseAsync(lastResponseId); + await responsesClient.DeleteResponseAsync(lastResponseId); + + if (response.Value.PreviousResponseId is not null) + { + await DeleteResponseChainAsync(client, response.Value.PreviousResponseId); + } + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs new file mode 100644 index 0000000000..ec678a00d9 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +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; + +/// +/// Integration test fixture that creates non-versioned Responses agents via the direct AIProjectClient.AsAIAgent(...) path. +/// +public class ResponsesAgentFixture : IChatClientAgentFixture +{ + private FoundryAgent _agent = null!; + private AIProjectClient _client = null!; + + public IChatClient ChatClient => this._agent.GetService()!.ChatClient; + + public AIAgent Agent => this._agent; + + public async Task CreateConversationAsync() + { + var response = await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync(); + return response.Value.Id; + } + + public async Task> GetChatHistoryAsync(AIAgent agent, AgentSession session) + { + ChatClientAgentSession chatClientSession = (ChatClientAgentSession)session; + + if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId); + } + + if (chatClientSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + return await this.GetChatHistoryFromResponsesChainAsync(chatClientSession.ConversationId); + } + + ChatHistoryProvider? chatHistoryProvider = agent.GetService(); + + if (chatHistoryProvider is null) + { + return []; + } + + return (await chatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList(); + } + + private async Task> GetChatHistoryFromResponsesChainAsync(string conversationId) + { + var openAIResponseClient = this._client.GetProjectOpenAIClient().GetProjectResponsesClient(); + var inputItems = await openAIResponseClient.GetResponseInputItemsAsync(conversationId).ToListAsync(); + var response = await openAIResponseClient.GetResponseAsync(conversationId); + ResponseItem responseItem = response.Value.OutputItems.FirstOrDefault()!; + + var previousMessages = inputItems + .Select(ConvertToChatMessage) + .Where(x => x.Text != "You are a helpful assistant.") + .Reverse(); + + ChatMessage responseMessage = ConvertToChatMessage(responseItem); + + return [.. previousMessages, responseMessage]; + } + + private static ChatMessage ConvertToChatMessage(ResponseItem item) + { + if (item is MessageResponseItem messageResponseItem) + { + ChatRole role = messageResponseItem.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant; + return new ChatMessage(role, messageResponseItem.Content.FirstOrDefault()?.Text); + } + + throw new NotSupportedException("This test currently only supports text messages"); + } + + private async Task> GetChatHistoryFromConversationAsync(string conversationId) + { + List messages = []; + await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc")) + { + var openAIItem = item.AsResponseResultItem(); + if (openAIItem is MessageResponseItem messageItem) + { + messages.Add(new ChatMessage + { + Role = new ChatRole(messageItem.Role.ToString()), + Contents = messageItem.Content + .Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText) + .Select(c => new TextContent(c.Text)) + .ToList() + }); + } + } + + return messages; + } + + public Task CreateChatClientAgentAsync( + string name = "HelpfulAssistant", + string instructions = "You are a helpful assistant.", + IList? aiTools = null) + { + return Task.FromResult(this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: instructions, + name: name, + tools: aiTools).GetService()!); + } + + public Task CreateChatClientAgentAsync(ChatClientAgentOptions options) + { + return Task.FromResult(this._client.AsAIAgent(options).GetService()!); + } + + // Non-versioned Responses agents have no server-side agent to delete. + public Task DeleteAgentAsync(ChatClientAgent agent) => Task.CompletedTask; + + public async Task DeleteSessionAsync(AgentSession session) + { + ChatClientAgentSession typedSession = (ChatClientAgentSession)session; + + if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId); + } + else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + await this.DeleteResponseChainAsync(typedSession.ConversationId!); + } + } + + private async Task DeleteResponseChainAsync(string lastResponseId) + { + var response = await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().GetResponseAsync(lastResponseId); + await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().DeleteResponseAsync(lastResponseId); + + if (response.Value.PreviousResponseId is not null) + { + await this.DeleteResponseChainAsync(response.Value.PreviousResponseId); + } + } + + // Non-versioned Responses agents have no server-side agent to clean up on dispose. + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } + + public virtual ValueTask InitializeAsync() + { + this._client = new AIProjectClient( + new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), + TestAzureCliCredentials.CreateAzureCliCredential()); + + this._agent = this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "You are a helpful assistant.", + name: "HelpfulAssistant"); + + return default; + } + + public ValueTask InitializeAsync(ChatClientAgentOptions options) + { + this._client = new AIProjectClient( + new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), + TestAzureCliCredentials.CreateAzureCliCredential()); + + this._agent = this._client.AsAIAgent(options); + + return default; + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs new file mode 100644 index 0000000000..5f21c316c4 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using Microsoft.Agents.AI; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) +{ + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} + +public class ResponsesAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) +{ + public override Func> AgentRunOptionsFactory => async () => + { + var conversationId = await this.Fixture.CreateConversationAsync(); + return new ChatClientAgentRunOptions(new() { ConversationId = conversationId }); + }; + + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs new file mode 100644 index 0000000000..71460f7737 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using Microsoft.Agents.AI; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentRunPreviousResponseTests() : RunTests(() => new()) +{ + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} + +public class ResponsesAgentRunConversationTests() : RunTests(() => new()) +{ + public override Func> AgentRunOptionsFactory => async () => + { + var conversationId = await this.Fixture.CreateConversationAsync(); + return new ChatClientAgentRunOptions(new() { ConversationId = conversationId }); + }; + + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs new file mode 100644 index 0000000000..19ebdb4e28 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new()) +{ + private const string NotSupported = "The direct Responses AsAIAgent path does not support specifying structured output type at invocation time."; + + /// + /// Verifies that response format provided at agent initialization is used when invoking RunAsync. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + AIAgent agent = this.Fixture.Agent; + AgentSession session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo)); + Assert.Equal("Paris", cityInfo.Name); + } + + /// + /// Verifies that generic RunAsync works when structured output is configured at agent initialization. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + AIAgent agent = this.Fixture.Agent; + AgentSession session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync( + new ChatMessage(ChatRole.User, "Provide information about the capital of France."), + session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + + Assert.NotNull(response.Result); + Assert.Equal("Paris", response.Result.Name); + } + + public override Task RunWithGenericTypeReturnsExpectedResultAsync() + { + Assert.Skip(NotSupported); + return base.RunWithGenericTypeReturnsExpectedResultAsync(); + } + + public override Task RunWithResponseFormatReturnsExpectedResultAsync() + { + Assert.Skip(NotSupported); + return base.RunWithResponseFormatReturnsExpectedResultAsync(); + } + + public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() + { + Assert.Skip(NotSupported); + return base.RunWithPrimitiveTypeReturnsExpectedResultAsync(); + } +} + +/// +/// Fixture for testing the direct Responses path with structured output of type provided at agent initialization. +/// +public class ResponsesAgentStructuredOutputFixture : ResponsesAgentFixture +{ + public override ValueTask InitializeAsync() + { + ChatClientAgentOptions agentOptions = new() + { + ChatOptions = new ChatOptions() + { + ModelId = TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + ResponseFormat = ChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + }, + }; + + return this.InitializeAsync(agentOptions); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs index 261faaded8..5c954d30e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -21,11 +21,173 @@ 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 /// @@ -199,7 +361,7 @@ public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsTrue_EnforcesInv // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -217,7 +379,7 @@ public void AsAIAgent_WithAgentVersion_WithRequireInvocableToolsFalse_AllowsDecl // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -409,7 +571,7 @@ public void AsAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var chatClient = agent.GetService(); Assert.NotNull(chatClient); var agentVersion = chatClient.GetService(); @@ -458,7 +620,7 @@ public async Task GetAIAgentAsync_WithNameAndTools_CreatesAgentAsync() // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -481,7 +643,7 @@ public async Task CreateAIAgentAsync_WithModelAndOptions_CreatesValidAgentAsync( // Assert Assert.NotNull(agent); Assert.Equal("test-agent", agent.Name); - Assert.Equal("Test instructions", agent.Instructions); + Assert.Equal("Test instructions", agent.GetService()!.Instructions); } /// @@ -570,7 +732,7 @@ public async Task CreateAIAgentAsync_WithDefinition_CreatesAgentSuccessfullyAsyn // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -592,7 +754,7 @@ public async Task CreateAIAgentAsync_WithoutToolsParameter_CreatesAgentSuccessfu // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -612,7 +774,7 @@ public async Task CreateAIAgentAsync_WithoutToolsInDefinition_CreatesAgentSucces // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -638,7 +800,7 @@ public async Task CreateAIAgentAsync_WithDefinitionTools_UsesDefinitionToolsAsyn // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -677,7 +839,7 @@ public async Task CreateAIAgentAsync_WithMixedToolsInDefinition_CreatesAgentSucc // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -721,7 +883,7 @@ public async Task CreateAIAgentAsync_WithNameAndAITools_SendsToolDefinitionViaHt // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); Assert.IsType(agentVersion.Definition); @@ -783,7 +945,7 @@ public void AsAIAgent_WithParameterTools_AcceptsTools() // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var chatClient = agent.GetService(); Assert.NotNull(chatClient); var agentVersion = chatClient.GetService(); @@ -815,7 +977,7 @@ public async Task CreateAIAgentAsync_WithStringParamsAndTools_CreatesAgentAsync( // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -843,7 +1005,7 @@ public async Task CreateAIAgentAsync_WithDefinitionTools_CreatesAgentAsync() // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -864,7 +1026,7 @@ public async Task GetAIAgentAsync_WithToolsParameter_CreatesAgentAsync() // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -912,7 +1074,7 @@ public async Task CreateAIAgentAsync_WithResponseToolsInDefinition_CreatesAgentS // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -952,7 +1114,7 @@ public async Task CreateAIAgentAsync_WithFunctionToolsInDefinition_AcceptsDeclar // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -979,7 +1141,7 @@ public async Task CreateAIAgentAsync_WithDeclarativeFunctionFromDefinition_Accep // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -1011,7 +1173,7 @@ public async Task CreateAIAgentAsync_WithDeclarativeFunctionInDefinition_Accepts // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -1064,7 +1226,7 @@ public async Task GetAIAgentAsync_WithOptions_PreservesCustomPropertiesAsync() // Assert Assert.NotNull(agent); Assert.Equal("test-agent", agent.Name); - Assert.Equal("Custom instructions", agent.Instructions); + Assert.Equal("Custom instructions", agent.GetService()!.Instructions); Assert.Equal("Custom description", agent.Description); } @@ -1353,7 +1515,7 @@ public async Task CreateAIAgentAsync_WithClientFactory_PreservesAgentPropertiesA // Assert Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); - Assert.Equal(Instructions, agent.Instructions); + Assert.Equal(Instructions, agent.GetService()!.Instructions); var wrappedClient = agent.GetService(); Assert.NotNull(wrappedClient); } @@ -1957,11 +2119,11 @@ public async Task CreateAIAgentAsync_WithTextResponseFormat_CreatesAgentSuccessf }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -1983,11 +2145,11 @@ public async Task CreateAIAgentAsync_WithJsonResponseFormatWithoutSchema_Creates }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2011,11 +2173,11 @@ public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchema_CreatesAge }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2044,11 +2206,11 @@ public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchemaAndStrictMo }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2077,11 +2239,11 @@ public async Task CreateAIAgentAsync_WithJsonResponseFormatWithSchemaAndStrictMo }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -2107,11 +2269,11 @@ public async Task CreateAIAgentAsync_WithRawRepresentationFactory_CreatesAgentSu }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2133,11 +2295,11 @@ public async Task CreateAIAgentAsync_WithRawRepresentationFactoryReturningNull_C }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2159,11 +2321,11 @@ public async Task CreateAIAgentAsync_WithRawRepresentationFactoryReturningNonCre }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -2186,7 +2348,7 @@ public async Task CreateAIAgentAsync_WithDescription_SetsDescriptionAsync() }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); @@ -2208,7 +2370,7 @@ public async Task CreateAIAgentAsync_WithoutDescription_CreatesAgentSuccessfully }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); @@ -2303,11 +2465,11 @@ public async Task GetAIAgentAsync_WithMatchingToolsProvided_CreatesAgentSuccessf }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -2330,7 +2492,7 @@ public async Task GetAIAgentAsync_WithAIContextProviders_PreservesProviderAsync( }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2352,7 +2514,7 @@ public async Task GetAIAgentAsync_WithChatHistoryProvider_PreservesProviderAsync }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2374,7 +2536,7 @@ public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesSetting }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2400,7 +2562,7 @@ public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_SkipsToolValidat }; // Act - should not throw even without tools when UseProvidedChatClientAsIs is true - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2432,7 +2594,7 @@ public async Task GetAIAgentAsync_WithUseProvidedChatClientAsIs_PreservesProvide }; // Act - UseProvidedChatClientAsIs is true, but provided AIFunctions should still be matched and preserved - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2463,11 +2625,11 @@ public async Task GetAIAgentAsync_WithEmptyVersion_CreatesAgentSuccessfullyAsync }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(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); } @@ -2525,11 +2687,11 @@ public async Task GetAIAgentAsync_WithWhitespaceVersion_CreatesAgentSuccessfully }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(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); } @@ -2679,11 +2841,11 @@ public async Task CreateAIAgentAsync_WithResponseToolAsAITool_CreatesAgentSucces }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2707,11 +2869,11 @@ public async Task CreateAIAgentAsync_WithHostedToolTypes_CreatesAgentSuccessfull }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2745,11 +2907,11 @@ public async Task GetAIAgentAsync_WithServerDefinedToolsAndMatchingProvidedTools }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2783,11 +2945,11 @@ public async Task GetAIAgentAsync_WithMixedServerTools_MatchesFunctionToolsOnlyA }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2841,11 +3003,11 @@ public void AsAIAgent_WithServerHostedTools_AddsToolsToAgentOptions() AgentVersion agentVersion = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson(agentDefinition: definition)))!; // Act - no tools provided, but requireInvocableTools is false when no tools param is passed - ChatClientAgent agent = client.AsAIAgent(agentVersion); + FoundryAgent agent = client.AsAIAgent(agentVersion); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -3135,17 +3297,16 @@ public TestChatClient(IChatClient innerClient) : base(innerClient) /// private sealed class MockPipelineResponse : PipelineResponse { - private readonly int _status; private readonly MockPipelineResponseHeaders _headers; public MockPipelineResponse(int status, BinaryData? content = null) { - this._status = status; + this.Status = status; this.Content = content ?? BinaryData.Empty; this._headers = new MockPipelineResponseHeaders(); } - public override int Status => this._status; + public override int Status { get; } public override string ReasonPhrase => "OK"; @@ -3206,9 +3367,10 @@ public override IEnumerator> GetEnumerator() /// /// Helper method to access internal ChatOptions property via reflection. /// - private static ChatOptions? GetAgentChatOptions(ChatClientAgent agent) + private static ChatOptions? GetAgentChatOptions(AIAgent agent) { - if (agent is null) + ChatClientAgent? chatClientAgent = agent as ChatClientAgent ?? agent.GetService(); + if (chatClientAgent is null) { return null; } @@ -3219,7 +3381,7 @@ public override IEnumerator> GetEnumerator() System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - return chatOptionsProperty?.GetValue(agent) as ChatOptions; + return chatOptionsProperty?.GetValue(chatClientAgent) as ChatOptions; } /// @@ -3260,6 +3422,7 @@ protected override ValueTask InvokedCoreAsync(InvokedContext context, Cancellati } } } +#pragma warning restore CS0618 /// /// Provides test data for invalid agent name validation tests. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs index 5c61e0b457..8582ccc2b6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs @@ -10,6 +10,8 @@ namespace Microsoft.Agents.AI.AzureAI.UnitTests; +#pragma warning disable CS0618 +[Obsolete("Uses obsolete AIProjectClient.GetAIAgentAsync compatibility extensions while validating chat-client behavior.")] public class AzureAIProjectChatClientTests { /// @@ -43,9 +45,12 @@ public async Task ChatClient_UsesDefaultConversationIdAsync() 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) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -92,9 +97,12 @@ public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversat 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) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -141,9 +149,12 @@ public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConvers 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) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -190,9 +201,12 @@ public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixe 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) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -208,3 +222,4 @@ public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixe Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); } } +#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs new file mode 100644 index 0000000000..1b77e8f57e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class FoundryAgentTests +{ + private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project"); + + #region Constructor validation tests + + [Fact] + public void Constructor_WithNullEndpoint_ThrowsArgumentNullException() + { + ArgumentNullException exception = Assert.Throws(() => + new FoundryAgent( + projectEndpoint: null!, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test instructions")); + + Assert.Equal("endpoint", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullCredential_ThrowsArgumentNullException() + { + ArgumentNullException exception = Assert.Throws(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: null!, + model: "gpt-4o-mini", + instructions: "Test instructions")); + + Assert.Equal("credential", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullModel_ThrowsArgumentException() + { + Assert.ThrowsAny(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: null!, + instructions: "Test instructions")); + } + + [Fact] + public void Constructor_WithEmptyModel_ThrowsArgumentException() + { + Assert.ThrowsAny(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: string.Empty, + instructions: "Test instructions")); + } + + [Fact] + public void Constructor_WithNullInstructions_ThrowsArgumentException() + { + Assert.ThrowsAny(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: null!)); + } + + [Fact] + public void Constructor_WithValidParams_CreatesAgent() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "You are a helpful assistant.", + name: "test-agent", + description: "A test agent"); + + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("A test agent", agent.Description); + } + + #endregion + + #region Property tests + + [Fact] + public void Name_ReturnsConfiguredName() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + name: "my-agent"); + + Assert.Equal("my-agent", agent.Name); + } + + [Fact] + public void Description_ReturnsConfiguredDescription() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + description: "Agent description"); + + Assert.Equal("Agent description", agent.Description); + } + + [Fact] + public void GetService_ReturnsAIProjectClient() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + AIProjectClient? client = agent.GetService(); + + Assert.NotNull(client); + } + + [Fact] + public void GetService_ReturnsChatClientAgent() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + ChatClientAgent? innerAgent = agent.GetService(); + + Assert.NotNull(innerAgent); + } + + [Fact] + public void GetService_ReturnsIChatClient() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + IChatClient? chatClient = agent.GetService(); + + Assert.NotNull(chatClient); + } + + [Fact] + public void GetService_ReturnsChatClientMetadata() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + ChatClientMetadata? metadata = agent.GetService(); + + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata.ProviderName); + } + + [Fact] + public void GetService_ReturnsNullForUnknownType() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + Assert.Null(agent.GetService()); + } + + #endregion + + #region Functional tests + + [Fact] + public async Task RunAsync_SendsRequestToResponsesAPIAsync() + { + bool requestTriggered = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + requestTriggered = 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 HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClientOptions clientOptions = new() + { + Transport = new HttpClientPipelineTransport(httpClient) + }; + + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "You are a helpful assistant.", + clientOptions: clientOptions); + + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + Assert.True(requestTriggered); + } + + [Fact] + public void Constructor_WithChatClientFactory_AppliesFactory() + { + bool factoryCalled = false; + + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + clientFactory: client => + { + factoryCalled = true; + return client; + }); + + Assert.True(factoryCalled); + Assert.NotNull(agent); + } + + [Fact] + public async Task Constructor_UserAgentHeaderAddedToRequestsAsync() + { + bool userAgentFound = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable? values)) + { + foreach (string value in values) + { + if (value.StartsWith("MEAI/", StringComparison.OrdinalIgnoreCase)) + { + userAgentFound = true; + } + } + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClientOptions clientOptions = new() + { + Transport = new HttpClientPipelineTransport(httpClient) + }; + + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + clientOptions: clientOptions); + + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + Assert.True(userAgentFound, "Expected MEAI user-agent header to be present in requests."); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs index 0a33c03ccd..9cd2ecea46 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs @@ -63,8 +63,8 @@ public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentNa json = ApplyInstructions(json, instructions); json = ApplyDescription(json, description); // Remove the version and id fields to simulate hosted agents without version - json = json.Replace("\"version\": \"1\",", "\"version\": \"\","); - json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); + json = json.Replace("\"version\": \"1\",", "\"version\": \"\",") + .Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); return json; } @@ -79,8 +79,8 @@ public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = nu json = ApplyInstructions(json, instructions); json = ApplyDescription(json, description); // Remove the version and id fields to simulate hosted agents without version - json = json.Replace("\"version\": \"1\",", "\"version\": \"\","); - json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); + json = json.Replace("\"version\": \"1\",", "\"version\": \"\",") + .Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); return json; } diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs index 9b3c95c5c2..d6092f5231 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs @@ -1,5 +1,7 @@ // 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; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs new file mode 100644 index 0000000000..eb4f706f30 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentFileSkillScriptTests +{ + [Fact] + public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); + var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); + + // Act & Assert + await Assert.ThrowsAsync( + () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() + { + // Arrange + var runnerCalled = false; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + runnerCalled = true; + return Task.FromResult("executed"); + } + var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A file skill"), + "---\nname: my-skill\n---\nContent", + "/skills/my-skill"); + + // Act + var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(runnerCalled); + Assert.Equal("executed", result); + } + + [Fact] + public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() + { + // Arrange + AgentFileSkill? capturedSkill = null; + AgentFileSkillScript? capturedScript = null; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + capturedSkill = skill; + capturedScript = scriptArg; + return Task.FromResult(null); + } + var script = CreateScript("capture", "/scripts/capture.py", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("owner-skill", "Owner"), + "Content", + "/skills/owner-skill"); + + // Act + await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.Same(fileSkill, capturedSkill); + Assert.Same(script, capturedScript); + } + + [Fact] + public void Script_HasCorrectNameAndPath() + { + // Arrange & Act + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); + + // Assert + Assert.Equal("my-script", script.Name); + Assert.Equal("/path/to/my-script.py", script.FullPath); + } + + /// + /// Helper to create an via reflection since the constructor is internal. + /// + private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + { + var ctor = typeof(AgentFileSkillScript).GetConstructor( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + null, + [typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)], + null) ?? throw new InvalidOperationException("Could not find internal constructor."); + + return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs new file mode 100644 index 0000000000..ef8f7780a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for script discovery and execution in . +/// +public sealed class AgentFileSkillsSourceScriptTests : IDisposable +{ + private static readonly string[] s_rubyExtension = new[] { ".rb" }; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + + private readonly string _testRoot; + + public AgentFileSkillsSourceScriptTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var skill = skills[0]; + Assert.NotNull(skill.Scripts); + Assert.Single(skill.Scripts!); + Assert.Equal("scripts/convert.py", skill.Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.sh", "echo 'sh'"); + CreateFile(skillDir, "scripts/run.js", "console.log('js')"); + CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'"); + CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();"); + CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(6, scriptNames.Count); + Assert.Contains("scripts/run.cs", scriptNames); + Assert.Contains("scripts/run.csx", scriptNames); + Assert.Contains("scripts/run.js", scriptNames); + Assert.Contains("scripts/run.ps1", scriptNames); + Assert.Contains("scripts/run.py", scriptNames); + Assert.Contains("scripts/run.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body."); + CreateFile(skillDir, "scripts/data.txt", "text data"); + CreateFile(skillDir, "scripts/config.json", "{}"); + CreateFile(skillDir, "scripts/notes.md", "# Notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync() + { + // Arrange + CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Scripts); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync() + { + // Arrange — scripts at any depth in the skill directory are discovered + string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); + CreateFile(skillDir, "convert.py", "print('root')"); + CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(2, scriptNames.Count); + Assert.Contains("convert.py", scriptNames); + Assert.Contains("tools/helper.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')"); + var executorCalled = false; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + executorCalled = true; + Assert.Equal("exec-skill", skill.Frontmatter.Name); + Assert.Equal("scripts/test.py", script.Name); + Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath); + return Task.FromResult("executed"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(executorCalled); + Assert.Equal("executed", scriptResult); + } + + [Fact] + public void Constructor_NullExecutor_DoesNotThrow() + { + // Arrange & Act & Assert — null runner is allowed when skills have no scripts + var source = new AgentFileSkillsSource(this._testRoot, null); + Assert.NotNull(source); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body."); + CreateFile(skillDir, "scripts/run.sh", "echo 'hello'"); + var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null); + + // Act — discovery succeeds even without a runner + var skills = await source.GetSkillsAsync(CancellationToken.None); + var script = skills[0].Scripts![0]; + + // Assert — running the script throws because no runner was provided + await Assert.ThrowsAsync(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.rb", "puts 'rb'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')"); + AIFunctionArguments? capturedArgs = null; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + capturedArgs = args; + return Task.FromResult("done"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var arguments = new AIFunctionArguments + { + ["value"] = 26.2, + ["factor"] = 1.60934 + }; + await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None); + + // Assert + Assert.NotNull(capturedArgs); + Assert.Equal(26.2, capturedArgs["value"]); + Assert.Equal(1.60934, capturedArgs["factor"]); + } + + private static string CreateSkillDir(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent) + { + string skillDir = CreateSkillDir(root, name, description, body); + CreateFile(skillDir, scriptRelativePath, scriptContent); + } + + private static void CreateFile(string root, string relativePath, string content) + { + string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs new file mode 100644 index 0000000000..c0f8412655 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for validation. +/// +public sealed class AgentSkillFrontmatterValidatorTests +{ + [Theory] + [InlineData("my-skill")] + [InlineData("a")] + [InlineData("skill123")] + [InlineData("a1b2c3")] + public void ValidateName_ValidName_ReturnsTrue(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Theory] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + [InlineData("UPPERCASE")] + [InlineData("consecutive--hyphens")] + [InlineData("special!chars")] + public void ValidateName_InvalidName_ReturnsFalse(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateName_NameExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longName = new('a', 65); + + // Act + bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateDescription_ValidDescription_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longDesc = new('x', 1025); + + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description) + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateCompatibility_Null_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_WithinMaxLength_ReturnsTrue() + { + // Arrange + string compatibility = new('x', 500); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse() + { + // Arrange + string compatibility = new('x', 501); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData("UPPERCASE")] + [InlineData("-leading")] + [InlineData("trailing-")] + [InlineData("consecutive--hyphens")] + public void Constructor_InvalidName_ThrowsArgumentException(string name) + { + // Act & Assert + var ex = Assert.Throws(() => new AgentSkillFrontmatter(name, "A valid description.")); + Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_NameExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longName = new('a', 65); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(longName, "A valid description.")); + } + + [Fact] + public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longDesc = new('x', 1025); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", longDesc)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(name!, "A valid description.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", description!)); + } + + [Fact] + public void Compatibility_ExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => frontmatter.Compatibility = longCompatibility); + } + + [Fact] + public void Compatibility_WithinMaxLength_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string compatibility = new('x', 500); + + // Act + frontmatter.Compatibility = compatibility; + + // Assert + Assert.Equal(compatibility, frontmatter.Compatibility); + } + + [Fact] + public void Compatibility_Null_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + + // Act + frontmatter.Compatibility = null; + + // Assert + Assert.Null(frontmatter.Compatibility); + } + + [Fact] + public void Constructor_WithCompatibility_SetsValue() + { + // Arrange & Act + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+"); + + // Assert + Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility); + } + + [Fact] + public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs new file mode 100644 index 0000000000..85335256a7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillsProviderBuilderTests +{ + private readonly TestAIAgent _agent = new(); + + private AIContextProvider.InvokingContext CreateInvokingContext() + { + return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + } + + [Fact] + public void Build_NoSourceConfigured_Succeeds() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Build_WithCustomSource_Succeeds() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("custom", "Custom skill", "Instructions.")); + var builder = new AgentSkillsProviderBuilder() + .UseSource(source); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void UseSource_NullSource_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseSource(null!)); + } + + [Fact] + public void UseFilter_NullPredicate_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFilter(null!)); + } + + [Fact] + public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFileScriptRunner(null!)); + } + + [Fact] + public void UseOptions_NullConfigure_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseOptions(null!)); + } + + [Fact] + public async Task Build_WithFilter_AppliesFilterToSkillsAsync() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert — the instructions should mention "keep-me" but not "drop-me" + Assert.NotNull(result.Instructions); + Assert.Contains("keep-me", result.Instructions); + Assert.DoesNotContain("drop-me", result.Instructions); + } + + [Fact] + public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should be called each time (dedup still calls through) + Assert.True(countingSource.CallCount >= 2); + } + + [Fact] + public async Task Build_WithCacheEnabled_CachesSkillsAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should only be called once due to caching + Assert.Equal(1, countingSource.CallCount); + } + + [Fact] + public void Build_FluentChaining_ReturnsSameBuilder() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — all fluent methods should return the same builder + var result = builder + .UseSource(source) + .UseScriptApproval(false) + .UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}"); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void Build_UseOptions_ConfiguresOptions() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — UseOptions should not throw and successfully configure + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(opts => opts.ScriptApproval = true) + .Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public async Task Build_WithMultipleCustomSources_AggregatesAllAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("from-one", "Source 1", "Instructions 1.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("from-two", "Source 2", "Instructions 2.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source1) + .UseSource(source2) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("from-one", result.Instructions); + Assert.Contains("from-two", result.Instructions); + } + + /// + /// A test source that counts how many times GetSkillsAsync is called. + /// + private sealed class CountingSource : AgentSkillsSource + { + private readonly AgentSkill[] _skills; + private int _callCount; + + public CountingSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + public int CallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult>(this._skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs new file mode 100644 index 0000000000..6dfc45918a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -0,0 +1,765 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class with . +/// +public sealed class AgentSkillsProviderTests : IDisposable +{ + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public AgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill tool (no resources, so no read_skill_resource) + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.DoesNotContain("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() + { + // Arrange + this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}" + }; + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + Assert.Contains("custom-prompt-skill", result.Instructions); + Assert.Contains("Custom prompt", result.Instructions); + } + + [Fact] + public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{skills}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{script_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithBothPlaceholders_Succeeds() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}" + }; + + // Act — should not throw + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{resource_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor)); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() + { + // Arrange — create skills in reverse alphabetical order + this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); + this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); + this.CreateSkill("mike-skill", "Mike skill", "Body M."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + [Fact] + public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") + ]); + var provider = new AgentSkillsProvider(source); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act — invoke concurrently from multiple threads + var tasks = Enumerable.Range(0, 10) + .Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask()) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert — GetSkillsAsync should have been called exactly once (provider-level caching) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "script-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: script-skill\ndescription: Skill with scripts\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "test.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync() + { + // Arrange + this.CreateSkill("no-script-skill", "No scripts", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.DoesNotContain("run_skill_script", toolNames); + } + + [Fact] + public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot); + + // Act & Assert + Assert.Throws(() => builder.Build()); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync() + { + // Arrange + this.CreateSkill("opts-skill", "Options skill", "Options body."); + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("opts-skill", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "multi-opts-1"); + string dir2 = Path.Combine(this._testRoot, "multi-opts-2"); + CreateSkillIn(dir1, "skill-x", "Skill X", "Body X."); + CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y."); + + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkills(new[] { dir1, dir2 }, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-x", result.Instructions); + Assert.Contains("skill-y", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync() + { + // Arrange — create a skill with both .md and .json resources + string skillDir = Path.Combine(this._testRoot, "res-filter-opts"); + CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body."); + File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8); + File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8); + + // Only allow .json resources + var options = new AgentFileSkillsSourceOptions + { + AllowedResourceExtensions = [".json"], + }; + var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fileSkill = Assert.IsType(skills[0]); + Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name)); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + [Fact] + public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync() + { + // Arrange + this.CreateSkill("content-skill", "Content test", "Skill body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + + // Act + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "content-skill" })); + + // Assert — should contain frontmatter and body + var text = content!.ToString()!; + Assert.Contains("---", text); + Assert.Contains("name: content-skill", text); + Assert.Contains("Skill body.", text); + } + + [Fact] + public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync() + { + // Arrange — create a skill with a script file + string skillDir = Path.Combine(this._testRoot, "builder-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: builder-skill\ndescription: Builder test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('ok')"); + + var executorCalled = false; + + // Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario) + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot) + .UseFileScriptRunner((skill, script, args, ct) => + { + executorCalled = true; + return Task.FromResult("executed"); + }) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be present and executor should work + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + + var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction; + await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary + { + ["skillName"] = "builder-skill", + ["scriptName"] = "scripts/run.py", + })); + + Assert.True(executorCalled); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } + + [Fact] + public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("no-cache-skill", "No cache test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called more than once since caching is disabled + Assert.True(source.GetSkillsCallCount > 1); + } + + [Fact] + public async Task Build_WithCachingEnabled_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("cached-skill", "Cached test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called exactly once (caching is on by default) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task Build_DefaultOptions_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("default-skill", "Default test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — default behavior caches + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() + { + // Arrange — create a skill with a script and enable ScriptApproval + string skillDir = Path.Combine(this._testRoot, "approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: approval-skill\ndescription: Approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { ScriptApproval = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync() + { + // Arrange — create a skill with a script, default options (no approval) + string skillDir = Path.Combine(this._testRoot, "no-approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-approval-skill\ndescription: No approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should NOT be wrapped + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsNotType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync() + { + // Arrange — with default caching, tools should be the same reference + this.CreateSkill("cached-tools-skill", "Cached tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should be the same reference (cached) + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync() + { + // Arrange — with caching disabled, tools should be rebuilt per invocation + this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { DisableCaching = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should not be the same reference + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.NotSame(result1.Tools, result2.Tools); + } + + [Fact] + public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync() + { + // Arrange + this.CreateSkill("file-ctor-skill", "File ctor test", "File body."); + var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("file-ctor-skill", result.Instructions); + Assert.NotNull(result.Tools); + Assert.Contains(result.Tools!, t => t.Name == "load_skill"); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync() + { + // Arrange — same skill name in two directories + string dir1 = Path.Combine(this._testRoot, "dup1"); + string dir2 = Path.Combine(this._testRoot, "dup2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "dup-skill", "First", "Body 1."); + CreateSkillIn(dir2, "dup-skill", "Second", "Body 2."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-skill" })); + + // Assert — only first occurrence should survive + Assert.NotNull(content); + Assert.Contains("Body 1.", content!.ToString()!); + } + + /// + /// A test skill source that counts how many times is called. + /// + private sealed class CountingAgentSkillsSource : AgentSkillsSource + { + private readonly IList _skills; + private int _callCount; + + public CountingAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + public int GetSkillsCallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(this._skills); + } + } + + private sealed class TestAgentSkill : AgentSkill + { + private readonly string _content; + + public TestAgentSkill(string name, string description, string content) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + public override string Content => this._content; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..860f402005 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class DeduplicatingAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + { + // Arrange + var skills = new AgentSkill[] + { + new TestAgentSkill("dupe", "First", "Instructions 1."), + new TestAgentSkill("dupe", "Second", "Instructions 2."), + new TestAgentSkill("unique", "Unique", "Instructions 3."), + }; + var inner = new TestAgentSkillsSource(skills); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description); + Assert.Contains(result, s => s.Frontmatter.Name == "unique"); + } + + [Fact] + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + { + // Arrange — use a custom source that returns skills with same name but different casing + var inner = new FakeDuplicateCaseSource(); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("First", result[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(System.Array.Empty()); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// A fake source that returns skills with names differing only by case. + /// + private sealed class FakeDuplicateCaseSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + // AgentSkillFrontmatter validates names must be lowercase, so we build + // two skills with the same lowercase name to test case-insensitive dedup. + var skills = new List + { + new TestAgentSkill("my-skill", "First", "Instructions 1."), + new TestAgentSkill("my-skill", "Second", "Instructions 2."), + }; + return Task.FromResult>(skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index 6134b04feb..e9dc2e0358 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -4,25 +4,25 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for the class. +/// Unit tests for the skill discovery and parsing logic. /// public sealed class FileAgentSkillLoaderTests : IDisposable { - private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + private static readonly string[] s_customExtensions = [".custom"]; + private static readonly string[] s_validExtensions = [".md", ".json", ".custom"]; + private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"]; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; - private readonly FileAgentSkillLoader _loader; public FileAgentSkillLoaderTests() { this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(this._testRoot); - this._loader = new FileAgentSkillLoader(NullLogger.Instance); } public void Dispose() @@ -34,23 +34,23 @@ public void Dispose() } [Fact] - public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync() { // Arrange _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("my-skill")); - Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); - Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + Assert.Equal("my-skill", skills[0].Frontmatter.Name); + Assert.Equal("A test skill", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "quoted-skill"); @@ -58,33 +58,35 @@ public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); - Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + Assert.Equal("quoted-skill", skills[0].Frontmatter.Name); + Assert.Equal("A quoted description", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "bad-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-name"); @@ -92,16 +94,17 @@ public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\ndescription: A skill without a name\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-desc"); @@ -109,9 +112,10 @@ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: no-desc\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); @@ -123,7 +127,7 @@ public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() [InlineData("trailing-hyphen-")] [InlineData("has spaces")] [InlineData("consecutive--hyphens")] - public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName) { // Arrange string skillDir = Path.Combine(this._testRoot, invalidName); @@ -136,16 +140,17 @@ public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync() { // Arrange string dir1 = Path.Combine(this._testRoot, "dupe"); @@ -162,34 +167,37 @@ public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: dupe\ndescription: Second\n---\nSecond body."); + var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var source = new DeduplicatingAgentSkillsSource(fileSource); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert – filesystem enumeration order is not guaranteed, so we only // verify that exactly one of the two duplicates was kept. Assert.Single(skills); - string desc = skills["dupe"].Frontmatter.Description; + string desc = skills[0].Frontmatter.Description; Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); } [Fact] - public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill() + public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync() { // Arrange — directory name differs from the frontmatter name _ = this.CreateSkillDirectoryWithRawContent( "wrong-dir-name", "---\nname: actual-skill-name\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources() + public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { // Arrange — create resource files in the skill directory string skillDir = Path.Combine(this._testRoot, "resource-skill"); @@ -200,20 +208,21 @@ public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResour File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() + public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { // Arrange — create a file with an extension not in the default list string skillDir = Path.Combine(this._testRoot, "ext-skill"); @@ -223,19 +232,20 @@ public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: ext-skill\ndescription: Extension test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.json", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.json", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() + public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync() { // Arrange — the SKILL.md file itself should not be in the resource list string skillDir = Path.Combine(this._testRoot, "selfref-skill"); @@ -244,19 +254,20 @@ public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: selfref-skill\ndescription: Self ref test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["selfref-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("notes.md", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("notes.md", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() + public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() { // Arrange — resource files in nested subdirectories string skillDir = Path.Combine(this._testRoot, "nested-res-skill"); @@ -266,26 +277,22 @@ public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["nested-res-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); } - private static readonly string[] s_customExtensions = new[] { ".custom" }; - private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" }; - private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" }; - [Fact] - public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() + public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a loader with custom extensions - var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions); + // Arrange — use a source with custom extensions string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data"); @@ -293,15 +300,16 @@ public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions }); // Act - var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — only .custom files should be discovered, not .json Assert.Single(skills); - var skill = skills["custom-ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.custom", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.custom", skill.Resources![0].Name); } [Theory] @@ -311,39 +319,39 @@ public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension) { // Arrange & Act & Assert - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } })); } [Fact] - public void Constructor_NullExtensions_UsesDefaults() + public async Task Constructor_NullExtensions_UsesDefaultsAsync() { // Arrange & Act - var loader = new FileAgentSkillLoader(NullLogger.Instance, null); string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body."); File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Assert — default extensions include .md - var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - Assert.Single(skills["null-ext"].ResourceNames); + var skills = await source.GetSkillsAsync(); + Assert.Single(skills[0].Resources!); } [Fact] public void Constructor_ValidExtensions_DoesNotThrow() { // Arrange & Act & Assert — should not throw - var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions); - Assert.NotNull(loader); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions }); + Assert.NotNull(source); } [Fact] public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException() { // Arrange & Act & Assert — one bad extension in the list should cause failure - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions)); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions })); } [Fact] - public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync() { // Arrange — resource file directly in the skill directory (not in a subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); @@ -353,54 +361,62 @@ public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: root-resource-skill\ndescription: Root resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — both root-level resource files should be discovered Assert.Single(skills); - var skill = skills["root-resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames() + public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync() { // Arrange — skill with no resource files _ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Empty(skills["no-resources"].ResourceNames); + Assert.Empty(skills[0].Resources!); } [Fact] - public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Enumerable.Empty(), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync() { // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); @@ -408,13 +424,14 @@ public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimi File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("nested-skill")); + Assert.Equal("nested-skill", skills[0].Frontmatter.Name); } [Fact] @@ -425,54 +442,19 @@ public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() string refsDir = Path.Combine(skillDir, "refs"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["read-skill"]; + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var skills = await source.GetSkillsAsync(); + var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + var content = await resource.ReadAsync(); // Assert Assert.Equal("Document content here.", content); } [Fact] - public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["simple-skill"]; - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); - } - - [Fact] - public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — skill with a legitimate resource, then try to read a traversal path at read time - string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit"); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["traverse-read"]; - - // Manually construct a skill with the traversal resource in its list to bypass discovery validation - var tampered = new FileAgentSkill( - skill.Frontmatter, - skill.Body, - skill.SourcePath, - s_traversalResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); - } - - [Fact] - public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync() { // Arrange — name longer than 64 characters string longName = new('a', 65); @@ -481,16 +463,17 @@ public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync() { // Arrange — description longer than 1024 characters string longDesc = new('x', 1025); @@ -499,71 +482,18 @@ public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } - [Fact] - public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with bare path, caller uses ./ prefix - string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["dotslash-read"]; - - // Act — caller passes ./refs/doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); - - // Assert - Assert.Equal("Document content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses backslashes - string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["backslash-read"]; - - // Act — caller passes refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); - - // Assert - Assert.Equal("Backslash content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes - string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["mixed-sep-read"]; - - // Act — caller passes .\refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); - - // Assert - Assert.Equal("Mixed separator content.", content); - } - #if NET [Fact] - public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() + public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() { // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); @@ -588,71 +518,179 @@ public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — skill should still load, but symlinked resources should be excluded - Assert.True(skills.ContainsKey("symlink-escape-skill")); - var skill = skills["symlink-escape-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("legit.md", skill.ResourceNames[0]); + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("legit.md", skill.Resources![0].Name); } +#endif - private static readonly string[] s_symlinkResource = ["refs/data.md"]; + [Fact] + public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync() + { + // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter + _ = this.CreateSkillDirectoryWithRawContent( + "bom-skill", + "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("bom-skill", skills[0].Frontmatter.Name); + Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description); + } [Fact] - public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() + public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync() { - // Arrange — build a skill with a symlinked subdirectory - string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(skillDir); + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "licensed-skill", + "---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); - string outsideDir = Path.Combine(this._testRoot, "outside-read"); - Directory.CreateDirectory(outsideDir); - File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); + // Act + var skills = await source.GetSkillsAsync(); - try - { - Directory.CreateSymbolicLink(refsDir, outsideDir); - } - catch (IOException) - { - // Symlink creation requires elevation on some platforms; skip gracefully. - return; - } + // Assert + Assert.Single(skills); + Assert.Equal("MIT", skills[0].Frontmatter.License); + } + + [Fact] + public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "compat-skill", + "---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); - // Manually construct a skill that bypasses discovery validation - var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); - var skill = new FileAgentSkill( - frontmatter: frontmatter, - body: "See [doc](refs/data.md).", - sourcePath: skillDir, - resourceNames: s_symlinkResource); + // Act + var skills = await source.GetSkillsAsync(); - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + // Assert + Assert.Single(skills); + Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility); } -#endif [Fact] - public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync() { - // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter + // Arrange _ = this.CreateSkillDirectoryWithRawContent( - "bom-skill", - "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + "tools-skill", + "---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools); + } + + [Fact] + public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "meta-skill", + "---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString()); + Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "quoted-meta", + "---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString()); + Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() + { + // Arrange + string content = string.Join( + "\n", + "---", + "name: full-skill", + "description: A skill with all fields", + "license: Apache-2.0", + "compatibility: Requires Python 3.10+", + "allowed-tools: grep glob view", + "metadata:", + " org: contoso", + " tier: premium", + "---", + "Full body content."); + _ = this.CreateSkillDirectoryWithRawContent("full-skill", content); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Equal("full-skill", fm.Name); + Assert.Equal("A skill with all fields", fm.Description); + Assert.Equal("Apache-2.0", fm.License); + Assert.Equal("Requires Python 3.10+", fm.Compatibility); + Assert.Equal("grep glob view", fm.AllowedTools); + Assert.NotNull(fm.Metadata); + Assert.Equal("contoso", fm.Metadata!["org"]?.ToString()); + Assert.Equal("premium", fm.Metadata!["tier"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() + { + // Arrange + _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("bom-skill")); - Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); - Assert.Equal("Body content.", skills["bom-skill"].Body); + var fm = skills[0].Frontmatter; + Assert.Null(fm.License); + Assert.Null(fm.Compatibility); + Assert.Null(fm.AllowedTools); + Assert.Null(fm.Metadata); } private string CreateSkillDirectory(string name, string description, string body) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs deleted file mode 100644 index 5da49525d4..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for the class. -/// -public sealed class FileAgentSkillsProviderTests : IDisposable -{ - private readonly string _testRoot; - private readonly TestAIAgent _agent = new(); - - public FileAgentSkillsProviderTests() - { - this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(this._testRoot); - } - - public void Dispose() - { - if (Directory.Exists(this._testRoot)) - { - Directory.Delete(this._testRoot, recursive: true); - } - } - - [Fact] - public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() - { - // Arrange - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Original instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.Equal("Original instructions", result.Instructions); - Assert.Null(result.Tools); - } - - [Fact] - public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() - { - // Arrange - this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Base instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("Base instructions", result.Instructions); - Assert.Contains("provider-skill", result.Instructions); - Assert.Contains("Provider skill test", result.Instructions); - - // Should have load_skill and read_skill_resource tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() - { - // Arrange - this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("null-instr-skill", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() - { - // Arrange - this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Custom template: {0}" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.StartsWith("Custom template:", result.Instructions); - Assert.Contains("custom-prompt-skill", result.Instructions); - Assert.Contains("Custom prompt", result.Instructions); - } - - [Fact] - public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() - { - // Arrange — template with unescaped braces and no valid {0} placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Bad template with {unescaped} braces" - }; - - // Act & Assert - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("SkillsInstructionPrompt", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException() - { - // Arrange -- valid format string but missing the required placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "No placeholder here" - }; - - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("{0}", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync() - { - // Arrange — valid custom template with {0} placeholder - this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "== Skills ==\n{0}\n== End ==" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — the custom template wraps the skill list - Assert.NotNull(result.Instructions); - Assert.StartsWith("== Skills ==", result.Instructions); - Assert.Contains("custom-tpl-skill", result.Instructions); - Assert.Contains("== End ==", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() - { - // Arrange — description with XML-sensitive characters - string skillDir = Path.Combine(this._testRoot, "xml-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("<tags>", result.Instructions); - Assert.Contains("&", result.Instructions); - } - - [Fact] - public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() - { - // Arrange - string dir1 = Path.Combine(this._testRoot, "dir1"); - string dir2 = Path.Combine(this._testRoot, "dir2"); - CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); - CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); - - // Act - var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Assert - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - Assert.NotNull(result.Instructions); - Assert.Contains("skill-a", result.Instructions); - Assert.Contains("skill-b", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() - { - // Arrange - this.CreateSkill("tools-skill", "Tools test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - - var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); - var inputContext = new AIContext { Tools = new[] { existingTool } }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — existing tool should be preserved alongside the new skill tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("existing_tool", toolNames); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() - { - // Arrange — create skills in reverse alphabetical order - this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); - this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); - this.CreateSkill("mike-skill", "Mike skill", "Body M."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — skills should appear in alphabetical order in the prompt - Assert.NotNull(result.Instructions); - int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); - int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); - int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); - Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); - Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); - } - - private void CreateSkill(string name, string description, string body) - { - CreateSkillIn(this._testRoot, name, description, body); - } - - private static void CreateSkillIn(string root, string name, string description, string body) - { - string skillDir = Path.Combine(root, name); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - $"---\nname: {name}\ndescription: {description}\n---\n{body}"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..de145004e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class FilteringAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => false); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions."), + new TestAgentSkill("keep-also", "KeepAlso", "Instructions.")); + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name)); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void Constructor_NullPredicate_Throws() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!)); + } + + [Fact] + public void Constructor_NullInnerSource_Throws() + { + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(null!, _ => true)); + } + + [Fact] + public async Task GetSkillsAsync_PreservesOrderAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("alpha", "Alpha", "Instructions."), + new TestAgentSkill("beta", "Beta", "Instructions."), + new TestAgentSkill("gamma", "Gamma", "Instructions."), + new TestAgentSkill("delta", "Delta", "Instructions.")); + + // Keep only alpha and gamma + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name is "alpha" or "gamma"); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("alpha", result[0].Frontmatter.Name); + Assert.Equal("gamma", result[1].Frontmatter.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs new file mode 100644 index 0000000000..8c97a31ae4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkill : AgentSkill +{ + private readonly AgentSkillFrontmatter _frontmatter; + private readonly string _content; + + /// + /// Initializes a new instance of the class. + /// + /// Kebab-case skill name. + /// Skill description. + /// Full skill content (body text). + public TestAgentSkill(string name, string description, string content) + { + this._frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + /// + public override AgentSkillFrontmatter Frontmatter => this._frontmatter; + + /// + public override string Content => this._content; + + /// + public override IReadOnlyList? Resources => null; + + /// + public override IReadOnlyList? Scripts => null; +} + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkillsSource : AgentSkillsSource +{ + private readonly IList _skills; + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this._skills); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs new file mode 100644 index 0000000000..a3d2bd0c6a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Shared test helper for integration tests that verify +/// end-to-end behavior with and +/// . +/// +internal static class ChatClientAgentTestHelper +{ + /// + /// Represents an expected service call during a test: an optional input verifier and the response to return. + /// + /// The the mock service should return for this call. + /// Optional callback to verify the messages sent to the service on this call. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ServiceCallExpectation( + ChatResponse Response, + Action>? VerifyInput = null); +#pragma warning restore CA1812 + + /// + /// Describes the expected shape of a message in the persisted history for structural comparison. + /// + /// The expected role of the message. + /// Optional substring that the message text should contain. + /// Optional array of expected types in the message. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ExpectedMessage( + ChatRole Role, + string? TextContains = null, + Type[]? ContentTypes = null); +#pragma warning restore CA1812 + + /// + /// The result of a RunAsync invocation, containing the response, session, agent, + /// captured service inputs, and call counts for detailed verification. + /// + public sealed record RunResult( + AgentResponse Response, + ChatClientAgentSession Session, + ChatClientAgent Agent, + Mock MockService, + int TotalServiceCalls, + List> CapturedServiceInputs); + + /// + /// Creates a mock that returns responses in sequence, + /// captures input messages, and optionally verifies inputs. + /// + /// The ordered sequence of expected service calls. + /// Shared call index counter (allows reuse across multiple RunAsync calls). + /// List that captured service inputs are appended to. + /// The configured mock. + public static Mock CreateSequentialMock( + List expectations, + Ref callIndex, + List> capturedInputs) + { + Mock mock = new(); + mock.Setup(s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ChatOptions?, CancellationToken>((msgs, _, _) => + { + int idx = callIndex.Value++; + var messageList = msgs.ToList(); + capturedInputs.Add(messageList); + + if (idx >= expectations.Count) + { + throw new InvalidOperationException( + $"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected."); + } + + var expectation = expectations[idx]; + expectation.VerifyInput?.Invoke(messageList); + return Task.FromResult(expectation.Response); + }); + return mock; + } + + /// + /// Runs the agent with the given inputs, automatically verifying service call count + /// and optional expected history, and returns the result for further assertions. + /// + /// Messages to pass to RunAsync. + /// Ordered service call expectations for the mock. + /// Options for configuring the agent. If null, defaults are used. + /// An existing session to reuse (for multi-turn tests). If null, a new session is created. + /// An existing agent to reuse (for multi-turn tests). If null, a new agent is created. + /// An existing mock to reuse (for multi-turn tests). If null, a new mock is created. + /// Shared call index for multi-turn tests. If null, a new counter is created. + /// Shared captured inputs list for multi-turn tests. If null, a new list is created. + /// Optional initial chat history to pre-populate in . + /// Optional to pass to RunAsync. + /// + /// If provided, asserts the total number of service calls matches. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// + /// If provided, asserts that the persisted history matches these expected messages. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// A containing the response, session, agent, mock, and captured inputs. + public static async Task RunAsync( + List inputMessages, + List serviceCallExpectations, + ChatClientAgentOptions? agentOptions = null, + ChatClientAgentSession? existingSession = null, + ChatClientAgent? existingAgent = null, + Mock? existingMock = null, + Ref? callIndex = null, + List>? capturedInputs = null, + List? initialChatHistory = null, + AgentRunOptions? runOptions = null, + int? expectedServiceCallCount = null, + List? expectedHistory = null) + { + callIndex ??= new Ref(0); + capturedInputs ??= []; + var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs); + agentOptions ??= new ChatClientAgentOptions(); + + var agent = existingAgent ?? new ChatClientAgent( + mock.Object, + options: agentOptions, + services: new ServiceCollection().BuildServiceProvider()); + + var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!; + + // Pre-populate initial chat history if provided. + if (initialChatHistory is not null) + { + (agent.ChatHistoryProvider as InMemoryChatHistoryProvider) + ?.SetMessages(session, new List(initialChatHistory)); + } + + var response = await agent.RunAsync(inputMessages, session, runOptions); + + var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs); + + // Auto-verify service call count if specified. + if (expectedServiceCallCount.HasValue) + { + Assert.Equal(expectedServiceCallCount.Value, callIndex.Value); + } + + // Auto-verify persisted history if specified. + if (expectedHistory is not null) + { + var history = GetPersistedHistory(agent, session); + AssertMessagesMatch(history, expectedHistory); + } + + return result; + } + + /// + /// Asserts that the actual message list matches the expected message patterns structurally. + /// Checks message count, roles, optional text content, and optional content types. + /// + /// The actual messages to verify. + /// The expected message patterns. + public static void AssertMessagesMatch(List actual, List expected) + { + Assert.True( + expected.Count == actual.Count, + $"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}"); + + for (int i = 0; i < expected.Count; i++) + { + var exp = expected[i]; + var act = actual[i]; + + Assert.True( + exp.Role == act.Role, + $"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}"); + + if (exp.TextContains is not null) + { + Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal); + } + + if (exp.ContentTypes is not null) + { + AssertContentTypes(act.Contents, exp.ContentTypes, i); + } + } + } + + /// + /// Gets the persisted chat history from the agent's . + /// + /// The agent whose history provider to query. + /// The session to get history for. + /// The list of persisted messages, or an empty list if no provider is available. + public static List GetPersistedHistory(ChatClientAgent agent, AgentSession session) + { + var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider; + return provider?.GetMessages(session) ?? []; + } + + /// + /// Formats the contents of a message list as a diagnostic string for test failure messages. + /// + /// The messages to format. + /// A human-readable representation of the messages. + public static string FormatMessages(IEnumerable messages) + { + var sb = new StringBuilder(); + int index = 0; + foreach (var msg in messages) + { + sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]"); + index++; + } + + return sb.ToString(); + } + + /// + /// A simple mutable reference wrapper for value types, allowing shared state across callbacks. + /// + public sealed class Ref(T value) where T : struct + { + public T Value { get; set; } = value; + } + + /// + /// Asserts that a message's content collection contains the expected content types. + /// + private static void AssertContentTypes(IList contents, Type[] expectedTypes, int messageIndex) + { + Assert.True( + contents.Count >= expectedTypes.Length, + $"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " + + $"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + + foreach (var expectedType in expectedTypes) + { + Assert.True( + contents.Any(c => expectedType.IsInstanceOfType(c)), + $"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 2b3cfe43e8..7241ca763e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -379,18 +379,23 @@ public async Task RunAsyncPassesChatOptionsWhenUsingChatClientAgentRunOptionsAsy } /// - /// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions. + /// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions. + /// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions + /// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client. /// [Fact] - public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync() + public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync() { // Arrange + ChatOptions? capturedOptions = null; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), - null, - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); ChatClientAgent agent = new(mockService.Object); var runOptions = new AgentRunOptions(); @@ -398,13 +403,9 @@ public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAs // Act await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions); - // Assert - mockService.Verify( - x => x.GetResponseAsync( - It.IsAny>(), - null, - It.IsAny()), - Times.Once); + // Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped) + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs new file mode 100644 index 0000000000..6300942c9d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests that verify the end-to-end approval flow behavior of the +/// class with , +/// ensuring that chat history is correctly persisted across multi-turn approval interactions. +/// +public class ChatClientAgent_ApprovalsTests +{ + #region Per-Service-Call Persistence Approval Tests + + /// + /// Verifies that with per-service-call persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history: + /// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller. + /// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again. + /// Final history: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model returns a function call (FICC will convert to approval request) + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after approval, FICC invokes the function and calls the model again + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1: initial request + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + // Verify Turn 1 returns exactly one approval request + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(1, result1.TotalServiceCalls); + + // Verify service received user message on first call + Assert.Single(capturedInputs); + Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + + // Act — Turn 2: send approval response + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + + // Verify second service call received the full conversation (user + FCC + FRC) + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + } + + #endregion + + #region End-of-Run Persistence Approval Tests + + /// + /// Verifies that with end-of-run persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = true, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + // End-of-run persistence retains the approval request from Turn 1 + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + #endregion + + #region Service-Stored History Approval Tests + + /// + /// Verifies that with service-stored history (ConversationId returned) and an approval-required tool, + /// the two-turn approval flow completes without errors and the session gets the ConversationId. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync() + { + // Arrange + const string ConversationId = "thread-456"; + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])]) + { + ConversationId = ConversationId, + }), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")]) + { + ConversationId = ConversationId, + }), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(ConversationId, result1.Session.ConversationId); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — session should retain the ConversationId, response should be correct + Assert.Equal(ConversationId, result2.Session.ConversationId); + Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C."); + } + + #endregion + + #region Approval Rejected Tests + + /// + /// Verifies that when an approval is rejected, the rejection result is persisted in the history + /// and the model receives the rejection information. + /// + [Fact] + public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model requests function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after rejection, model gets the rejection info and responds accordingly + new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2: reject the approval + var rejectionMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: rejectionMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — history should contain the rejection result (FRC with rejection) + var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session); + Assert.True( + history.Count >= 3, + $"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}"); + Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + Assert.Contains(history, m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + Assert.Contains(history, m => m.Role == ChatRole.Assistant && + m.Text == "I'm sorry, I cannot check the weather without your approval."); + + // Verify the second service call received the rejection FRC + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index cc9b7acb19..3e54dbc06e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -500,4 +500,158 @@ public async Task RunAsync_UsesOverrideChatHistoryProvider_WhenProvidedViaAdditi } #endregion + + #region End-to-End Chat History Persistence Tests + + /// + /// Verifies that with per-service-call persistence (default), a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with per-service-call persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + // First call: model requests a function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Second call: model returns final response after seeing function result + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that with end-of-run persistence, a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with end-of-run persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that when the service returns a ConversationId (service-stored history), + /// the session gets the ConversationId and no errors occur during the run. + /// + [Fact] + public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync() + { + // Arrange & Act + var result = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1); + + // Assert — session should have the conversation id from the service + Assert.Equal("thread-123", result.Session.ConversationId); + Assert.Contains(result.Response.Messages, m => m.Text == "Hi there"); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs index 6dda0f0278..28d38ea36a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs @@ -176,10 +176,12 @@ public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsy } /// - /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. + /// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId + /// when both agent and request have no ChatOptions. The sentinel conversation ID is set for + /// per-service-call persistence and stripped before reaching the inner client. /// [Fact] - public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() + public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync() { // Arrange Mock mockService = new(); @@ -189,7 +191,7 @@ public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAs It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + .Callback, ChatOptions?, CancellationToken>((msgs, opts, ct) => capturedChatOptions = opts) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); @@ -199,8 +201,9 @@ public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAs // Act await agent.RunAsync(messages); - // Assert - Assert.Null(capturedChatOptions); + // Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped) + Assert.NotNull(capturedChatOptions); + Assert.Null(capturedChatOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs index 459859224f..e7f91ab5d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs @@ -763,4 +763,171 @@ private static async IAsyncEnumerable CreateAsyncEnumerableA await Task.CompletedTask; } + + /// + /// Verifies that when per-service-call persistence is active and no real conversation ID exists, + /// sets the + /// sentinel on the chat options and strips it before + /// forwarding to the inner client. + /// + [Fact] + public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when end-of-run persistence is enabled + /// (mark-only mode), since the issue only applies to per-service-call persistence. + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = true, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should see options but NOT the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when a real conversation ID is already present + /// on the session (indicating server-side history management). + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync() + { + // Arrange + const string RealConversationId = "real-conv-123"; + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) + { + ConversationId = RealConversationId, + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Create a session with a real conversation ID. + var session = await agent.CreateSessionAsync(RealConversationId); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — the inner client should see the real conversation ID, not the sentinel + Assert.NotNull(capturedOptions); + Assert.Equal(RealConversationId, capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is set and stripped correctly in the streaming path. + /// + [Fact] + public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response"))); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")])) + { + // Consume the stream. + } + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the session's conversation ID is NOT set to the sentinel after the run. + /// The sentinel should only exist transiently on the ChatOptions for the pipeline. + /// + [Fact] + public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — session should NOT have the sentinel conversation ID + Assert.Null(session!.ConversationId); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 77d8d0a88d..7f06145a8e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -147,7 +147,7 @@ from i in Enumerable.Range(1, numAgents) for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +225,7 @@ public async Task BuildConcurrent_AgentsRunInParallelAsync() barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +258,7 @@ public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync() }), description: "nop")) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +296,7 @@ public async Task Handoffs_OneTransfer_ResponseServedBySecondAgentAsync() .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +406,7 @@ public async Task Handoffs_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctio .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +604,7 @@ public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync() .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -651,7 +651,7 @@ public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -680,36 +680,211 @@ public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) } } - private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( - Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + [Fact] + public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync() { - StringBuilder sb = new(); - - InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment(); - await using StreamingRun run = await environment.RunStreamingAsync(workflow, input); - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + int coordinatorCallCount = 0; - WorkflowOutputEvent? output = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => { - if (evt is AgentResponseUpdateEvent executorComplete) + coordinatorCallCount++; + if (coordinatorCallCount == 1) { - sb.Append(executorComplete.Data); + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); } - else if (evt is WorkflowOutputEvent e) + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "specialist responded"))), + name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + + // Turn 2: without ReturnToPrevious, coordinator should be invoked again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(2, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "specialist responded")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + Assert.Equal(1, specialistCallCount); + + // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again + Assert.Equal(2, specialistCallCount); // specialist called again + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync() + { + int coordinatorCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + Assert.Fail("Specialist should not be invoked."); + return new(); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + // First turn with no prior handoff: should route to initial (coordinator) agent + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + Assert.Equal(1, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) { - output = e; - break; + // First call: hand off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); } - else if (evt is WorkflowErrorEvent errorEvent) + // Subsequent calls: respond without handoff + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + // Specialist hands back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator → specialist → coordinator (specialist hands back) + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback + Assert.Equal(1, specialistCallCount); // specialist called once, then handed back + + // Turn 2: after handoff back to coordinator, should route to coordinator (not specialist) + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2 + Assert.Equal(1, specialistCallCount); // specialist NOT called + } + + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.TrySendMessageAsync(input); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + StringBuilder sb = new(); + WorkflowOutputEvent? output = null; + CheckpointInfo? lastCheckpoint = null; + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) { - Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + case AgentResponseUpdateEvent executorComplete: + sb.Append(executorComplete.Data); + break; + + case WorkflowOutputEvent e: + output = e; + break; + + case WorkflowErrorEvent errorEvent: + Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + break; + + case SuperStepCompletedEvent stepCompleted: + lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + break; } } - return (sb.ToString(), output?.As>()); + return new(sb.ToString(), output?.As>(), lastCheckpoint); } + private static Task RunWorkflowAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment()); + private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) { protected override async IAsyncEnumerable RunCoreStreamingAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index 4181dad409..09d83966aa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -34,7 +34,9 @@ public void Test_MessageMerger_AssemblesMessage() response.Messages[0].Role.Should().Be(ChatRole.Assistant); response.Messages[0].AuthorName.Should().Be(TestAuthorName1); response.AgentId.Should().Be(TestAgentId1); - response.CreatedAt.Should().NotBe(creationTime); + response.CreatedAt.Should().HaveValue(); + response.CreatedAt.Value.Should().BeOnOrAfter(creationTime); + response.CreatedAt.Value.Should().BeCloseTo(creationTime, precision: TimeSpan.FromSeconds(5)); response.Messages[0].CreatedAt.Should().Be(creationTime); response.Messages[0].Contents.Should().HaveCount(1); response.FinishReason.Should().BeNull(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs index 3d88ed22ab..993a6d462b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs @@ -20,7 +20,7 @@ protected override IEnumerable GetEpilogueMessages(AgentRunOptions? { IEnumerable? handoffs = chatClientOptions.ChatOptions .Tools? - .Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, + .Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.OrdinalIgnoreCase) is true); if (handoffs != null) @@ -58,7 +58,7 @@ public static Workflow CreateWorkflow() .Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i))) .ToArray(); - return new HandoffsWorkflowBuilder(echoAgents[0]) + return new HandoffWorkflowBuilder(echoAgents[0]) .WithHandoff(echoAgents[0], echoAgents[1]) .Build(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 7bf00c4fb9..3fa17a34bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -794,7 +794,7 @@ public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) { // Arrange TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); - Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build(); + Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build(); return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); } } diff --git a/python/.env.example b/python/.env.example index c09300d775..4e7ba727e5 100644 --- a/python/.env.example +++ b/python/.env.example @@ -1,6 +1,6 @@ # Azure AI -AZURE_AI_PROJECT_ENDPOINT="" -AZURE_AI_MODEL_DEPLOYMENT_NAME="" +FOUNDRY_PROJECT_ENDPOINT="" +FOUNDRY_MODEL="" # Bing connection for web search (optional, used by samples with web search) BING_CONNECTION_ID="" # Azure AI Search (optional, used by AzureAISearchContextProvider samples) @@ -13,8 +13,8 @@ AZURE_SEARCH_KNOWLEDGE_BASE_NAME="" # (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls) # OpenAI OPENAI_API_KEY="" -OPENAI_CHAT_MODEL_ID="" -OPENAI_RESPONSES_MODEL_ID="" +OPENAI_CHAT_MODEL="" +OPENAI_RESPONSES_MODEL="" # Azure OpenAI AZURE_OPENAI_ENDPOINT="" AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="" diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index d90e29226d..dbddbaac93 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -108,10 +108,10 @@ Content of `.env` or `openai.env`: ```env OPENAI_API_KEY="" -OPENAI_CHAT_MODEL_ID="gpt-4o-mini" +OPENAI_MODEL="gpt-4o-mini" ``` -You will then configure the ChatClient class with the keyword argument `env_file_path`: +You will then configure the ChatClient class with the keyword argument `env_file_path` (alternatively you can use `load_dotenv` in your code): ```python from agent_framework.openai import OpenAIChatClient diff --git a/python/README.md b/python/README.md index f9350a08a4..0a3042992f 100644 --- a/python/README.md +++ b/python/README.md @@ -47,7 +47,7 @@ Set as environment variables, or create a .env file at your project root: ```bash OPENAI_API_KEY=sk-... -OPENAI_CHAT_MODEL_ID=... +OPENAI_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... @@ -57,15 +57,25 @@ FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_MODEL=... ``` +For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), configuration +resolves in this order: + +1. Explicit Azure inputs such as `credential` or `azure_endpoint` +2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters +3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` + +This means mixed shells default to OpenAI when `OPENAI_API_KEY` is present. To force Azure routing, +pass an explicit Azure input such as `credential=AzureCliCredential()`. + You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient -client = AzureOpenAIChatClient( +client = OpenAIChatClient( api_key='', - endpoint='', - deployment_name='', + azure_endpoint='', + model='', api_version='', ) ``` diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py index 8370412394..21f50e930a 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py @@ -13,6 +13,7 @@ import logging import sys from collections.abc import Mapping, Sequence +from contextlib import contextmanager from copy import copy from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast from urllib.parse import urljoin, urlparse @@ -109,6 +110,12 @@ def _apply_azure_defaults( settings["token_endpoint"] = default_token_endpoint +@contextmanager +def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any: + """Preserve the legacy call shape without mutating process-wide environment state.""" + yield + + # endregion @@ -315,6 +322,8 @@ def __init__( "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + client_base_url = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -332,9 +341,7 @@ def __init__( if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - client_endpoint = azure_openai_settings.get("endpoint") - client_base_url = azure_openai_settings.get("base_url") - if not client_endpoint and not client_base_url: + if not endpoint_value and not client_base_url: raise ValueError("Please provide an endpoint or a base_url") client_args: dict[str, Any] = {"default_headers": merged_headers} @@ -346,8 +353,8 @@ def __init__( client_args["api_key"] = api_key_secret.get_secret_value() if client_base_url: client_args["base_url"] = str(client_base_url) - if client_endpoint and not client_base_url: - client_args["azure_endpoint"] = str(client_endpoint) + if endpoint_value and not client_base_url: + client_args["azure_endpoint"] = str(endpoint_value) if responses_deployment_name: client_args["azure_deployment"] = responses_deployment_name if "websocket_base_url" in kwargs: @@ -360,16 +367,19 @@ def __init__( self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = responses_deployment_name - super().__init__( - async_client=async_client, - model=responses_deployment_name, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - **kwargs, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url): + super().__init__( + async_client=async_client, + model=responses_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(client_base_url) if client_base_url else None, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + **kwargs, + ) @staticmethod def _create_client_from_project( @@ -530,6 +540,8 @@ def __init__( "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -547,8 +559,6 @@ def __init__( if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") if not endpoint_value and not base_url_value: raise ValueError("Please provide an endpoint or a base_url") @@ -573,16 +583,19 @@ def __init__( self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = chat_deployment_name - super().__init__( - async_client=async_client, - model=chat_deployment_name, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - additional_properties=additional_properties, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): + super().__init__( + async_client=async_client, + model=chat_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(base_url_value) if base_url_value else None, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + additional_properties=additional_properties, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + ) @override def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: @@ -842,6 +855,8 @@ def __init__( "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -859,8 +874,6 @@ def __init__( if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") if not endpoint_value and not base_url_value: raise ValueError("Please provide an endpoint or a base_url") @@ -885,11 +898,15 @@ def __init__( self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = embedding_deployment_name - super().__init__( - async_client=async_client, - model=embedding_deployment_name, - default_headers=default_headers, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): + super().__init__( + async_client=async_client, + model=embedding_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(base_url_value) if base_url_value else None, + api_version=azure_openai_settings.get("api_version"), + default_headers=default_headers, + ) if otel_provider_name is not None: self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py index 22e0a20d96..c1485d430d 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py @@ -2,6 +2,8 @@ import json import os +from functools import wraps +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import openai @@ -33,6 +35,8 @@ from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta from openai.types.chat.chat_completion_message import ChatCompletionMessage +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIChatClient is deprecated\\..*:DeprecationWarning") + # region Service Setup skip_if_azure_integration_tests_disabled = pytest.mark.skipif( @@ -41,6 +45,32 @@ ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> 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", "" + ) + 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}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization azure_chat_client = AzureOpenAIChatClient() @@ -820,6 +850,7 @@ def get_weather(location: str) -> str: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_response() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -851,6 +882,7 @@ async def test_azure_openai_chat_client_response() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_response_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -873,6 +905,7 @@ async def test_azure_openai_chat_client_response_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_streaming() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -909,6 +942,7 @@ async def test_azure_openai_chat_client_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_streaming_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -937,6 +971,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_basic_run(): """Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient.""" async with Agent( @@ -954,6 +989,7 @@ async def test_azure_openai_chat_client_agent_basic_run(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_basic_run_streaming(): """Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient.""" async with Agent( @@ -976,6 +1012,7 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_session_persistence(): """Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient.""" async with Agent( @@ -1002,6 +1039,7 @@ async def test_azure_openai_chat_client_agent_session_persistence(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_existing_session(): """Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances.""" # First conversation - capture the session @@ -1038,6 +1076,7 @@ async def test_azure_openai_chat_client_agent_existing_session(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_chat_client_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with Azure Chat Client.""" diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py index de78178df1..a172be577f 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py @@ -3,15 +3,20 @@ from __future__ import annotations import os +from functools import wraps +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework.azure import AzureOpenAIEmbeddingClient -from agent_framework_openai import OpenAIEmbeddingOptions +from agent_framework.openai import OpenAIEmbeddingOptions +from azure.identity.aio import AzureCliCredential from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIEmbeddingClient is deprecated\\..*:DeprecationWarning") + def _make_openai_response( embeddings: list[list[float]], @@ -106,20 +111,72 @@ def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None: skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( - not os.getenv("AZURE_OPENAI_ENDPOINT") - or (not os.getenv("AZURE_OPENAI_API_KEY") and not os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME")), - reason="No Azure OpenAI credentials provided; skipping integration tests.", + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com") + or ( + os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), + reason="No Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> 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", "" + ) + 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}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _get_azure_embedding_deployment_name() -> str: + return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + + +def _create_azure_openai_embedding_client( + *, + api_key: str | None = None, + credential: AzureCliCredential | None = None, +) -> AzureOpenAIEmbeddingClient: + resolved_api_key = ( + api_key if api_key is not None else None if credential is not None else os.getenv("AZURE_OPENAI_API_KEY") + ) + return AzureOpenAIEmbeddingClient( + deployment_name=_get_azure_embedding_deployment_name(), + api_key=resolved_api_key, + endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings() -> None: """End-to-end test of Azure OpenAI embedding generation.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - result = await client.get_embeddings(["hello world"]) + result = await client.get_embeddings(["hello world"]) assert len(result) == 1 assert isinstance(result[0].vector, list) @@ -133,11 +190,13 @@ async def test_integration_azure_openai_get_embeddings() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings_multiple() -> None: """Test Azure OpenAI embedding generation for multiple inputs.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - result = await client.get_embeddings(["hello", "world", "test"]) + result = await client.get_embeddings(["hello", "world", "test"]) assert len(result) == 3 dims = [len(e.vector) for e in result] @@ -147,12 +206,14 @@ async def test_integration_azure_openai_get_embeddings_multiple() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: """Test Azure OpenAI embedding generation with custom dimensions.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - options: OpenAIEmbeddingOptions = {"dimensions": 256} - result = await client.get_embeddings(["hello world"], options=options) + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) assert len(result) == 1 assert len(result[0].vector) == 256 diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py index 65e9629b96..99bd2061b7 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py @@ -3,9 +3,9 @@ import json import logging import os +from functools import wraps from pathlib import Path from typing import Annotated, Any -from unittest.mock import MagicMock import pytest from agent_framework import ( @@ -22,11 +22,40 @@ from pydantic import BaseModel from pytest import param +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") + skip_if_azure_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", ) + +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> 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", "" + ) + 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}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + logger = logging.getLogger(__name__) @@ -141,119 +170,6 @@ def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> AzureOpenAIResponsesClient() -def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with an existing AIProjectClient.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - # Create a mock AIProjectClient that returns a mock AsyncOpenAI client - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_client=mock_project_client, - deployment_name="gpt-4o", - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with a project endpoint and credential.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_endpoint="https://test-project.services.ai.azure.com", - deployment_name="gpt-4o", - credential=AzureCliCredential(), - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_create_client_from_project_with_project_client() -> None: - """Test _create_client_from_project with an existing project client.""" - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=mock_project_client, - project_endpoint=None, - credential=None, - ) - - assert result is mock_openai_client - mock_project_client.get_openai_client.assert_called_once() - - -def test_create_client_from_project_with_endpoint() -> None: - """Test _create_client_from_project with a project endpoint.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_credential = MagicMock() - - with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: - mock_instance = MockAIProjectClient.return_value - mock_instance.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=mock_credential, - ) - - assert result is mock_openai_client - MockAIProjectClient.assert_called_once() - mock_instance.get_openai_client.assert_called_once() - - -def test_create_client_from_project_missing_endpoint() -> None: - """Test _create_client_from_project raises error when endpoint is missing.""" - with pytest.raises(ValueError, match="project endpoint is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint=None, - credential=MagicMock(), - ) - - -def test_create_client_from_project_missing_credential() -> None: - """Test _create_client_from_project raises error when credential is missing.""" - with pytest.raises(ValueError, match="credential is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=None, - ) - - def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} @@ -285,8 +201,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: "option_name,option_value,needs_validation", [ # Simple ChatOptions - just verify they don't fail - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -299,7 +213,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: # OpenAIResponsesOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), # Complex options requiring output validation @@ -343,6 +256,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: ), ], ) +@_with_azure_openai_debug() async def test_integration_options( option_name: str, option_value: Any, @@ -358,127 +272,84 @@ async def test_integration_options( # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message - if option_name == "tools" or option_name == "tool_choice": - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] - elif option_name == "response_format": - # Use prompt that works well with structured output - messages = [ - Message(role="user", text="The weather in Seattle is sunny"), - Message(role="user", text="What is the weather in Seattle?"), - ] - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] + # Prepare test message + if option_name == "tools" or option_name == "tool_choice": + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name == "response_format": + # Use prompt that works well with structured output + messages = [ + Message(role="user", text="The weather in Seattle is sunny"), + Message(role="user", text="What is the weather in Seattle?"), + ] + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] - # Build options dict - options: dict[str, Any] = {option_name: option_value} + # Build options dict + options: dict[str, Any] = {option_name: option_value} - # Add tools if testing tool_choice to avoid errors - if option_name == "tool_choice": - options["tools"] = [get_weather] + # Add tools if testing tool_choice to avoid errors + if option_name == "tool_choice": + options["tools"] = [get_weather] - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) + # Test streaming mode + response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name == "tools" or option_name == "tool_choice": - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name == "response_format": - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - 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() + # Validate based on option type + if needs_validation: + if option_name == "tools" or option_name == "tool_choice": + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" + elif option_name == "response_format": + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + 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() @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_web_search() -> None: client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + response = await client.get_response( + messages=[ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + options={ + "tools": [ + AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) + ] + }, + stream=True, + ).get_final_response() - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [AzureOpenAIResponsesClient.get_web_search_tool()], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [ - AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) - ], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + assert response.text is not None @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search() -> None: """Test Azure responses client with file search tool.""" azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -509,6 +380,7 @@ async def test_integration_client_file_search() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search_streaming() -> None: """Test Azure responses client with file search tool and streaming.""" azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -541,6 +413,7 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_mcp_tool() -> None: """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -566,6 +439,7 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_code_interpreter_tool(): """Test Azure Responses Client agent with code interpreter tool.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -591,6 +465,7 @@ async def test_integration_client_agent_hosted_code_interpreter_tool(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_existing_session(): """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" # First conversation - capture the session @@ -627,6 +502,7 @@ async def test_integration_client_agent_existing_session(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_responses_client_tool_rich_content_image() -> None: """Test that Azure OpenAI Responses client can handle tool results containing images.""" image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" @@ -660,70 +536,3 @@ def get_test_image() -> Content: assert len(response.text) > 0 # sample_image.jpg contains a photo of a house; the model should mention it. assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" - - -# region Integration with Foundry V2 - - -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") - or os.getenv("AZURE_AI_MODEL", "") == "", - reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL provided; skipping integration tests.", -) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_function_call_roundtrip_preserves_fidelity(): - """Test that function calls roundtrip correctly with full fidelity preserved. - - This verifies the changes where: - 1. raw_representation is preserved when parsing function calls - 2. fc_id and status are included in additional_properties - 3. When re-sending messages, the full object fidelity is preserved - """ - call_count = 0 - - @tool(name="get_weather", approval_mode="never_require") - async def get_weather_tool(location: str) -> str: - """Get weather for a location.""" - nonlocal call_count - call_count += 1 - return f"Weather in {location} is sunny, 72F" - - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL"], - credential=AzureCliCredential(), - ) - - async with Agent( - client=client, - name="WeatherAgent", - instructions="You help check weather. Use get_weather when asked about weather.", - tools=[get_weather_tool], - default_options={"store": False}, # Store messages locally to test fidelity across messages - ) as agent: - session = agent.create_session() - - # First request - should invoke the tool - response1 = await agent.run("What is the weather in Seattle?", session=session) - - assert response1 is not None - assert response1.text is not None - assert call_count >= 1 - - # Verify the response contains expected content - response_text = response1.text.lower() - assert "seattle" in response_text or "sunny" in response_text or "72" in response_text - - # Second request - should work correctly with the preserved conversation - response2 = await agent.run("And how about in Portland?", session=session) - - assert response2 is not None - assert response2.text is not None - assert call_count >= 2 - - -# endregion diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py new file mode 100644 index 0000000000..bbf10e7b88 --- /dev/null +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft. All rights reserved. + +import warnings +from unittest.mock import MagicMock + +import pytest +from agent_framework import SupportsChatGetResponse + +warnings.filterwarnings( + "ignore", + message=r"RawAzureAIClient is deprecated\..*", + category=DeprecationWarning, +) + +from agent_framework.azure import AzureOpenAIResponsesClient # noqa: E402 +from azure.identity import AzureCliCredential # noqa: E402 + +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") + + +def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with an existing AIProjectClient.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + # Create a mock AIProjectClient that returns a mock AsyncOpenAI client + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with patch( + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_client=mock_project_client, + deployment_name="gpt-4o", + ) + + assert azure_responses_client.model == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with a project endpoint and credential.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + with patch( + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_endpoint="https://test-project.services.ai.azure.com", + deployment_name="gpt-4o", + credential=AzureCliCredential(), + ) + + assert azure_responses_client.model == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_create_client_from_project_with_project_client() -> None: + """Test _create_client_from_project with an existing project client.""" + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=mock_project_client, + project_endpoint=None, + credential=None, + ) + + assert result is mock_openai_client + mock_project_client.get_openai_client.assert_called_once() + + +def test_create_client_from_project_with_endpoint() -> None: + """Test _create_client_from_project with a project endpoint.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_credential = MagicMock() + + with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: + mock_instance = MockAIProjectClient.return_value + mock_instance.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=mock_credential, + ) + + assert result is mock_openai_client + MockAIProjectClient.assert_called_once() + mock_instance.get_openai_client.assert_called_once() + + +def test_create_client_from_project_missing_endpoint() -> None: + """Test _create_client_from_project raises error when endpoint is missing.""" + with pytest.raises(ValueError, match="project endpoint is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint=None, + credential=MagicMock(), + ) + + +def test_create_client_from_project_missing_credential() -> None: + """Test _create_client_from_project raises error when credential is missing.""" + with pytest.raises(ValueError, match="credential is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=None, + ) diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index f0d8cbc430..f3e459d0a4 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -3,6 +3,7 @@ import json import os import sys +import warnings from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from typing import Annotated, Any @@ -41,8 +42,21 @@ from pydantic import BaseModel, ConfigDict, Field from pytest import fixture -from agent_framework_azure_ai import AzureAIClient, AzureAISettings -from agent_framework_azure_ai._shared import from_azure_ai_tools +from agent_framework_azure_ai import AzureAIClient, AzureAISettings # noqa: E402 +from agent_framework_azure_ai._shared import from_azure_ai_tools # noqa: E402 + +warnings.filterwarnings( + "ignore", + message=r"RawAzureAIClient is deprecated\..*", + category=DeprecationWarning, +) +warnings.filterwarnings( + "ignore", + message=r"AzureAIClient is deprecated\..*", + category=DeprecationWarning, +) + +pytestmark = pytest.mark.filterwarnings("ignore:AzureAIClient is deprecated\\..*:DeprecationWarning") @pytest.fixture diff --git a/python/packages/core/README.md b/python/packages/core/README.md index 9d88cc4556..36f0f6e02c 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -29,8 +29,8 @@ Set as environment variables, or create a .env file at your project root: ```bash OPENAI_API_KEY=sk-... -OPENAI_CHAT_MODEL_ID=... -OPENAI_RESPONSES_MODEL_ID=... +OPENAI_CHAT_MODEL=... +OPENAI_RESPONSES_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... 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 6387ada58a..a71e62397f 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -481,7 +481,7 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini} + - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} @@ -514,7 +514,10 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`})]}),o.jsxs("div",{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 \\ + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`})] + }), o.jsxs("div", { + 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:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",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_CHAT_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} 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"} diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 5a4166112d..0566e75429 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -33,7 +33,7 @@ Then edit `.env` and add your API keys: ```bash # For OpenAI (minimum required) OPENAI_API_KEY="your-api-key-here" -OPENAI_CHAT_MODEL_ID="gpt-4o-mini" +OPENAI_CHAT_MODEL="gpt-4o-mini" # Or for Azure OpenAI AZURE_OPENAI_ENDPOINT="your-endpoint" diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index 4a43bcd90f..e137c1053c 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -3892,9 +3892,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, 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 5a90a5350a..dd5ae81180 100644 --- a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx +++ b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx @@ -243,7 +243,7 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini} + - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} @@ -802,7 +802,7 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`} + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`} diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index c0278b182e..24636d2146 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -1806,9 +1806,9 @@ flat-cache@^4.0.0: keyv "^4.5.4" flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index 3f7a5d5095..50c500ad4e 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -2,10 +2,9 @@ import importlib.metadata -from ._foundry_agent import FoundryAgent, RawFoundryAgent -from ._foundry_agent_client import RawFoundryAgentChatClient -from ._foundry_chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient -from ._foundry_memory_provider import FoundryMemoryProvider +from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient +from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient +from ._memory_provider import FoundryMemoryProvider try: __version__ = importlib.metadata.version(__name__) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py b/python/packages/foundry/agent_framework_foundry/_agent.py similarity index 57% rename from python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py rename to python/packages/foundry/agent_framework_foundry/_agent.py index 0976d5572a..67c6f6070d 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -1,30 +1,37 @@ # Copyright (c) Microsoft. All rights reserved. -"""Microsoft Foundry Agent client for connecting to pre-configured agents in Foundry. +"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. -This module provides ``RawFoundryAgentClient`` and ``FoundryAgentClient`` for -communicating with PromptAgents and HostedAgents via the Responses API. +This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses +that connect to existing PromptAgents or HostedAgents in Foundry. Use +``FoundryAgent`` for the recommended experience with full middleware and telemetry. """ from __future__ import annotations import logging import sys -from collections.abc import Callable, Mapping, MutableMapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool -from agent_framework._types import Message -from agent_framework.observability import ChatTelemetryLayer +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentMiddlewareLayer, + BaseContextProvider, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + Message, + RawAgent, + load_settings, +) +from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient - -from ._entra_id_authentication import AzureCredentialTypes - -logger: logging.Logger = logging.getLogger(__name__) +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -33,22 +40,25 @@ if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: - from typing_extensions import override # type: ignore # pragma: no cover + from typing_extensions import override # type: ignore[import] # pragma: no cover if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework import Agent, BaseContextProvider - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, + from agent_framework import ( + Agent, + BaseContextProvider, + ChatAndFunctionMiddlewareTypes, MiddlewareTypes, + ToolTypes, ) - from agent_framework._tools import ToolTypes + +logger: logging.Logger = logging.getLogger("agent_framework.foundry") + +AzureTokenProvider = Callable[[], str | Awaitable[str]] +AzureCredentialTypes = TokenCredential | AsyncTokenCredential class FoundryAgentSettings(TypedDict, total=False): @@ -203,8 +213,6 @@ def as_agent( **kwargs: Any, ) -> Agent[FoundryAgentOptionsT]: """Create a FoundryAgent that reuses this client's Foundry configuration.""" - from ._foundry_agent import FoundryAgent - function_tools = cast( FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None, tools, @@ -359,9 +367,7 @@ def __init__( allow_preview: bool | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: @@ -393,3 +399,236 @@ def __init__( function_invocation_configuration=function_invocation_configuration, **kwargs, ) + + +class RawFoundryAgent( # type: ignore[misc] + RawAgent[FoundryAgentOptionsT], +): + """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + For full middleware and telemetry support, use :class:`FoundryAgent`. + + Examples: + .. code-block:: python + + from agent_framework.foundry import RawFoundryAgent + from azure.identity import AzureCliCredential + + agent = RawFoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + result = await agent.run("Hello!") + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + 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, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + agent_name: The name of the Foundry agent to connect to. + Can also be set via environment variable FOUNDRY_AGENT_NAME. + agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). + Can also be set via environment variable FOUNDRY_AGENT_VERSION. + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers for injecting dynamic context. + client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). + Defaults to ``_FoundryAgentChatClient`` (full client middleware). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments passed to the Agent base class. + """ + # Create the client + actual_client_type = client_type or _FoundryAgentChatClient + if not issubclass(actual_client_type, RawFoundryAgentChatClient): + raise TypeError( + f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" + ) + + client = actual_client_type( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + super().__init__( + client=client, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + context_providers=context_providers, + **kwargs, + ) + + async def configure_azure_monitor( + self, + enable_sensitive_data: bool = False, + **kwargs: Any, + ) -> None: + """Setup observability with Azure Monitor (Microsoft Foundry integration). + + This method configures Azure Monitor for telemetry collection using the + connection string from the Foundry project client (accessed via the internal client). + + Args: + enable_sensitive_data: Enable sensitive data logging (prompts, responses). + Should only be enabled in development/test environments. Default is False. + **kwargs: Additional arguments passed to configure_azure_monitor(). + + Raises: + ImportError: If azure-monitor-opentelemetry-exporter is not installed. + """ + from azure.core.exceptions import ResourceNotFoundError + + client = self.client + if not isinstance(client, RawFoundryAgentChatClient): + raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") + + try: + conn_string = await client.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Foundry project. " + "Please ensure Application Insights is configured in your project, " + "or call configure_otel_providers() manually with custom exporters." + ) + return + + try: + from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] + except ImportError as exc: + raise ImportError( + "azure-monitor-opentelemetry is required for Azure Monitor integration. " + "Install it with: pip install azure-monitor-opentelemetry" + ) from exc + + from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation + + if "resource" not in kwargs: + kwargs["resource"] = create_resource() + + configure_azure_monitor( + connection_string=conn_string, + views=create_metric_views(), + **kwargs, + ) + + enable_instrumentation(enable_sensitive_data=enable_sensitive_data) + + +class FoundryAgent( # type: ignore[misc] + AgentMiddlewareLayer, + AgentTelemetryLayer, + RawFoundryAgent[FoundryAgentOptionsT], +): + """Microsoft Foundry Agent with full middleware and telemetry support. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + This is the recommended class for production use. + + Examples: + .. code-block:: python + + from agent_framework.foundry import FoundryAgent + from azure.identity import AzureCliCredential + + # Connect to a PromptAgent + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + tools=[my_function_tool], + ) + result = await agent.run("Hello!") + + # Connect to a HostedAgent (no version needed) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-hosted-agent", + credential=AzureCliCredential(), + ) + + # Custom client (e.g., raw client without client middleware) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + credential=AzureCliCredential(), + client_type=RawFoundryAgentChatClient, + ) + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + 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, + middleware: Sequence[MiddlewareTypes] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent with full middleware and telemetry. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + agent_name: The name of the Foundry agent to connect to. + agent_version: The version of the agent (for PromptAgents). + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers. + middleware: Optional agent-level middleware. + client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments. + """ + super().__init__( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + tools=tools, + context_providers=context_providers, + middleware=middleware, + client_type=client_type, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py similarity index 92% rename from python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py rename to python/packages/foundry/agent_framework_foundry/_chat_client.py index 8397f29639..51d1b96bb3 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -4,14 +4,17 @@ import logging import sys -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer -from agent_framework._types import Content +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatMiddlewareLayer, + Content, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + load_settings, +) from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -25,9 +28,8 @@ ) from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool from azure.ai.projects.models import MCPTool as FoundryMCPTool - -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._shared import resolve_file_ids +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -43,15 +45,13 @@ from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, - ) + from agent_framework import ChatAndFunctionMiddlewareTypes logger: logging.Logger = logging.getLogger("agent_framework.foundry") +AzureTokenProvider = Callable[[], str | Awaitable[str]] +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + class FoundrySettings(TypedDict, total=False): """Settings for Microsoft FoundryChatClient resolved from args and environment. @@ -67,6 +67,33 @@ class FoundrySettings(TypedDict, total=False): project_endpoint: str | None +def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: + """Resolve file IDs from strings or hosted-file Content objects.""" + if not file_ids: + return None + + resolved: list[str] = [] + for item in file_ids: + if isinstance(item, str): + if not item: + raise ValueError("file_ids must not contain empty strings.") + resolved.append(item) + elif isinstance(item, Content): + if item.type != "hosted_file": + raise ValueError( + f"Unsupported Content type {item.type!r} for code interpreter file_ids. " + "Only Content.from_hosted_file() is supported." + ) + if item.file_id is None: + raise ValueError( + "Content.from_hosted_file() item is missing a file_id. " + "Ensure the Content object has a valid file_id before using it in file_ids." + ) + resolved.append(item.file_id) + + return resolved if resolved else None + + FoundryChatOptionsT = TypeVar( "FoundryChatOptionsT", bound=TypedDict, # type: ignore[valid-type] @@ -492,9 +519,7 @@ def __init__( env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: diff --git a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py b/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py deleted file mode 100644 index b1ae8a4739..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable -from typing import Union - -from agent_framework.exceptions import ChatClientInvalidAuthException -from azure.core.credentials import TokenCredential -from azure.core.credentials_async import AsyncTokenCredential - -logger: logging.Logger = logging.getLogger(__name__) - -AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] -"""A callable that returns a bearer token string, either synchronously or asynchronously.""" - -AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] -"""Union of Azure credential types. - -Accepts: -- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``) -- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``) -""" - - -def resolve_credential_to_token_provider( - credential: AzureCredentialTypes | AzureTokenProvider, - token_endpoint: str | None, -) -> AzureTokenProvider: - """Convert an Azure credential or token provider into an ``ad_token_provider`` callable. - - If the credential is already a callable token provider, it is returned as-is - (``token_endpoint`` is not required in this case). - If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using - ``azure.identity.get_bearer_token_provider`` (sync or async variant) which - handles token caching and automatic refresh. - - Args: - credential: An Azure credential or token provider callable. - token_endpoint: The token scope/endpoint - (e.g. ``"https://cognitiveservices.azure.com/.default"``). - Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``. - - Returns: - A callable that returns a bearer token string (sync or async). - - Raises: - ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping. - """ - # Already a token provider callable (not a credential object) — use directly - if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)): - return credential - - if not token_endpoint: - raise ChatClientInvalidAuthException( - "A token endpoint must be provided either in settings, as an environment variable, or as an argument." - ) - - if isinstance(credential, AsyncTokenCredential): - from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider - - return get_async_bearer_token_provider(credential, token_endpoint) - - from azure.identity import get_bearer_token_provider - - return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type] diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py deleted file mode 100644 index 19c298eaf8..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py +++ /dev/null @@ -1,287 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. - -This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses -that connect to existing PromptAgents or HostedAgents in Foundry. Use -``FoundryAgent`` for the recommended experience with full middleware and telemetry. -""" - -from __future__ import annotations - -import logging -import sys -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, Any - -from agent_framework import ( - AgentMiddlewareLayer, - BaseContextProvider, - RawAgent, -) -from agent_framework.observability import AgentTelemetryLayer -from azure.ai.projects.aio import AIProjectClient - -from ._entra_id_authentication import AzureCredentialTypes -from ._foundry_agent_client import ( - RawFoundryAgentChatClient, - _FoundryAgentChatClient, # pyright: ignore[reportPrivateUsage] -) - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - from agent_framework._tools import FunctionTool - from agent_framework_openai._chat_client import OpenAIChatOptions - -logger: logging.Logger = logging.getLogger("agent_framework.foundry") - -FoundryAgentOptionsT = TypeVar( - "FoundryAgentOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", - covariant=True, -) - - -class RawFoundryAgent( # type: ignore[misc] - RawAgent[FoundryAgentOptionsT], -): - """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. - - Connects to an existing PromptAgent or HostedAgent in Foundry. - For full middleware and telemetry support, use :class:`FoundryAgent`. - - Examples: - .. code-block:: python - - from agent_framework.foundry import RawFoundryAgent - from azure.identity import AzureCliCredential - - agent = RawFoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-prompt-agent", - agent_version="1.0", - credential=AzureCliCredential(), - ) - result = await agent.run("Hello!") - """ - - def __init__( - self, - *, - project_endpoint: str | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - credential: AzureCredentialTypes | None = None, - 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, - client_type: type[RawFoundryAgentChatClient] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a Foundry Agent. - - Keyword Args: - project_endpoint: The Foundry project endpoint URL. - Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. - agent_name: The name of the Foundry agent to connect to. - Can also be set via environment variable FOUNDRY_AGENT_NAME. - agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). - Can also be set via environment variable FOUNDRY_AGENT_VERSION. - credential: Azure credential for authentication. - project_client: An existing AIProjectClient to use. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. - context_providers: Optional context providers for injecting dynamic context. - client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). - Defaults to ``_FoundryAgentChatClient`` (full client middleware). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments passed to the Agent base class. - """ - # Create the client - actual_client_type = client_type or _FoundryAgentChatClient - if not issubclass(actual_client_type, RawFoundryAgentChatClient): - raise TypeError( - f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" - ) - - client = actual_client_type( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - super().__init__( - client=client, # type: ignore[arg-type] - tools=tools, # type: ignore[arg-type] - context_providers=context_providers, - **kwargs, - ) - - async def configure_azure_monitor( - self, - enable_sensitive_data: bool = False, - **kwargs: Any, - ) -> None: - """Setup observability with Azure Monitor (Microsoft Foundry integration). - - This method configures Azure Monitor for telemetry collection using the - connection string from the Foundry project client (accessed via the internal client). - - Args: - enable_sensitive_data: Enable sensitive data logging (prompts, responses). - Should only be enabled in development/test environments. Default is False. - **kwargs: Additional arguments passed to configure_azure_monitor(). - - Raises: - ImportError: If azure-monitor-opentelemetry-exporter is not installed. - """ - from azure.core.exceptions import ResourceNotFoundError - - from ._foundry_agent_client import RawFoundryAgentChatClient - - client = self.client - if not isinstance(client, RawFoundryAgentChatClient): - raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") - - try: - conn_string = await client.project_client.telemetry.get_application_insights_connection_string() - except ResourceNotFoundError: - logger.warning( - "No Application Insights connection string found for the Foundry project. " - "Please ensure Application Insights is configured in your project, " - "or call configure_otel_providers() manually with custom exporters." - ) - return - - try: - from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] - except ImportError as exc: - raise ImportError( - "azure-monitor-opentelemetry is required for Azure Monitor integration. " - "Install it with: pip install azure-monitor-opentelemetry" - ) from exc - - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - - if "resource" not in kwargs: - kwargs["resource"] = create_resource() - - configure_azure_monitor( - connection_string=conn_string, - views=create_metric_views(), - **kwargs, - ) - - enable_instrumentation(enable_sensitive_data=enable_sensitive_data) - - -class FoundryAgent( # type: ignore[misc] - AgentMiddlewareLayer, - AgentTelemetryLayer, - RawFoundryAgent[FoundryAgentOptionsT], -): - """Microsoft Foundry Agent with full middleware and telemetry support. - - Connects to an existing PromptAgent or HostedAgent in Foundry. - This is the recommended class for production use. - - Examples: - .. code-block:: python - - from agent_framework.foundry import FoundryAgent - from azure.identity import AzureCliCredential - - # Connect to a PromptAgent - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-prompt-agent", - agent_version="1.0", - credential=AzureCliCredential(), - tools=[my_function_tool], - ) - result = await agent.run("Hello!") - - # Connect to a HostedAgent (no version needed) - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-hosted-agent", - credential=AzureCliCredential(), - ) - - # Custom client (e.g., raw client without client middleware) - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-agent", - credential=AzureCliCredential(), - client_type=RawFoundryAgentChatClient, - ) - """ - - def __init__( - self, - *, - project_endpoint: str | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - credential: AzureCredentialTypes | None = None, - 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, - middleware: Sequence[MiddlewareTypes] | None = None, - client_type: type[RawFoundryAgentChatClient] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a Foundry Agent with full middleware and telemetry. - - Keyword Args: - project_endpoint: The Foundry project endpoint URL. - agent_name: The name of the Foundry agent to connect to. - agent_version: The version of the agent (for PromptAgents). - credential: Azure credential for authentication. - project_client: An existing AIProjectClient to use. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. - context_providers: Optional context providers. - middleware: Optional agent-level middleware. - client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. - """ - super().__init__( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - tools=tools, - context_providers=context_providers, - middleware=middleware, - client_type=client_type, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - **kwargs, - ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py similarity index 94% rename from python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py rename to python/packages/foundry/agent_framework_foundry/_memory_provider.py index 3c24e3380e..36d4a27a43 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -13,25 +13,38 @@ from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext -from agent_framework._settings import load_settings +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentSession, + BaseContextProvider, + Message, + SessionContext, + load_settings, +) from azure.ai.projects.aio import AIProjectClient +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from openai.types.responses import ResponseInputItemParam -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import FoundryProjectSettings - if sys.version_info >= (3, 11): - from typing import Self # pragma: no cover + from typing import Self, TypedDict # pragma: no cover else: - from typing_extensions import Self # pragma: no cover + from typing_extensions import Self, TypedDict # pragma: no cover if TYPE_CHECKING: - from agent_framework._agents import SupportsAgentRun + from agent_framework import SupportsAgentRun + logger = logging.getLogger(__name__) +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + +class FoundryProjectSettings(TypedDict, total=False): + """Foundry project settings loaded from FOUNDRY_ environment variables.""" + + project_endpoint: str | None + class FoundryMemoryProvider(BaseContextProvider): """Foundry Memory context provider using the new BaseContextProvider hooks pattern. diff --git a/python/packages/foundry/agent_framework_foundry/_shared.py b/python/packages/foundry/agent_framework_foundry/_shared.py deleted file mode 100644 index 8eed50f067..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_shared.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -from collections.abc import Sequence - -from agent_framework import Content - -if sys.version_info >= (3, 11): - from typing import TypedDict # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -logger = logging.getLogger("agent_framework.foundry") - - -class FoundryProjectSettings(TypedDict, total=False): - """Foundry project settings loaded from FOUNDRY_ environment variables.""" - - project_endpoint: str | None - - -def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: - """Resolve file IDs from strings or hosted-file Content objects.""" - if not file_ids: - return None - - resolved: list[str] = [] - for item in file_ids: - if isinstance(item, str): - if not item: - raise ValueError("file_ids must not contain empty strings.") - resolved.append(item) - elif isinstance(item, Content): - if item.type != "hosted_file": - raise ValueError( - f"Unsupported Content type {item.type!r} for code interpreter file_ids. " - "Only Content.from_hosted_file() is supported." - ) - if item.file_id is None: - raise ValueError( - "Content.from_hosted_file() item is missing a file_id. " - "Ensure the Content object has a valid file_id before using it in file_ids." - ) - resolved.append(item.file_id) - - return resolved if resolved else None diff --git a/python/packages/foundry/tests/assets/sample_image.jpg b/python/packages/foundry/tests/assets/sample_image.jpg new file mode 100644 index 0000000000..ea6486656f Binary files /dev/null and b/python/packages/foundry/tests/assets/sample_image.jpg differ diff --git a/python/packages/foundry/tests/conftest.py b/python/packages/foundry/tests/foundry/conftest.py similarity index 100% rename from python/packages/foundry/tests/conftest.py rename to python/packages/foundry/tests/foundry/conftest.py diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py new file mode 100644 index 0000000000..2eb992d1a2 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -0,0 +1,413 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +import sys +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential + +from agent_framework_foundry._agent import ( + FoundryAgent, + RawFoundryAgent, + RawFoundryAgentChatClient, + _FoundryAgentChatClient, +) + +skip_if_foundry_agent_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_AGENT_NAME", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_AGENT_NAME provided; skipping integration tests.", +) + +_FOUNDRY_AGENT_ENV_VARS = ( + "FOUNDRY_PROJECT_ENDPOINT", + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", +) + + +@pytest.fixture(autouse=True) +def clear_foundry_agent_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry agent settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_AGENT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None: + """Test that agent_name is required.""" + + with pytest.raises(ValueError, match="Agent name is required"): + RawFoundryAgentChatClient( + project_client=MagicMock(), + ) + + +def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: + """Test construction with agent_name and project_client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + assert client.agent_version == "1.0" + + +def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: + """Test agent reference includes version when provided.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="my-agent", + agent_version="2.0", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} + + +def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None: + """Test agent reference omits version for HostedAgents.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "hosted-agent", "type": "agent_reference"} + assert "version" not in ref + + +def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: + """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" + + class CustomClient(RawFoundryAgentChatClient): + pass + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = CustomClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + agent = client.as_agent(instructions="You are helpful.") + + assert isinstance(agent, FoundryAgent) + assert agent.name == "test-agent" + assert isinstance(agent.client, CustomClient) + assert agent.client.project_client is mock_project + assert agent.client.agent_name == "test-agent" + assert agent.client.agent_version == "1.0" + + named_agent = client.as_agent(name="display-name", instructions="You are helpful.") + assert named_agent.name == "display-name" + assert named_agent.client.agent_name == "test-agent" + + +async def test_raw_foundry_agent_chat_client_prepare_options_validates_tools() -> None: + """Test that _prepare_options rejects non-FunctionTool objects.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): + await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, + ) + + +async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_tools() -> None: + """Test that _prepare_options accepts FunctionTool objects.""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + + return "ok" + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={}, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [my_func]}, + ) + + assert "extra_body" in result + assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + + +def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: + """Test that _check_model_presence does nothing (model is on service).""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + options: dict[str, Any] = {} + client._check_model_presence(options) + assert "model" not in options + + +def test_foundry_agent_chat_client_init() -> None: + """Test construction of the full-middleware client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = _FoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + + +def test_raw_foundry_agent_init_creates_client() -> None: + """Test that RawFoundryAgent creates a client internally.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + +def test_raw_foundry_agent_init_with_custom_client_type() -> None: + """Test that client_type parameter is respected.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + client_type=RawFoundryAgentChatClient, + ) + + assert isinstance(agent.client, RawFoundryAgentChatClient) + + +def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None: + """Test that invalid client_type raises TypeError.""" + + with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): + RawFoundryAgent( + project_client=MagicMock(), + agent_name="test-agent", + client_type=object, # type: ignore[arg-type] + ) + + +def test_raw_foundry_agent_init_with_function_tools() -> None: + """Test that FunctionTool and callables are accepted.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + + return "ok" + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + tools=[my_func], + ) + + assert agent.default_options.get("tools") is not None + + +def test_foundry_agent_init() -> None: + """Test construction of the full-middleware agent.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + +def test_foundry_agent_init_with_middleware() -> None: + """Test that agent-level middleware is accepted.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + class MyMiddleware(ChatMiddleware): + async def process(self, context: ChatContext) -> None: + pass + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + middleware=[MyMiddleware()], + ) + + assert agent.client is not None + + +async def test_foundry_agent_configure_azure_monitor() -> None: + """Test configure_azure_monitor delegates through the underlying client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await agent.configure_azure_monitor(enable_sensitive_data=True) + + mock_project.telemetry.get_application_insights_connection_string.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + assert call_kwargs["views"] == [] + assert call_kwargs["resource"] is mock_resource + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_foundry_agent_configure_azure_monitor_resource_not_found() -> None: + """Test configure_azure_monitor handles ResourceNotFoundError gracefully.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + + await agent.configure_azure_monitor() + + mock_project.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_foundry_agent_configure_azure_monitor_import_error() -> None: + """Test configure_azure_monitor raises ImportError when Azure Monitor is unavailable.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + original_import = __import__ + + def _import_with_missing_azure_monitor( + name: str, + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name == "azure.monitor.opentelemetry": + raise ImportError("No module named 'azure.monitor.opentelemetry'") + return original_import(name, globals, locals, fromlist, level) + + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await agent.configure_azure_monitor() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_agent_integration_tests_disabled +async def test_foundry_agent_basic_run() -> None: + """Smoke-test FoundryAgent against a real configured agent.""" + async with FoundryAgent(credential=AzureCliCredential()) as agent: + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_agent_integration_tests_disabled +async def test_foundry_agent_custom_client_run() -> None: + """Smoke-test FoundryAgent against a real configured agent.""" + async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent: + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py new file mode 100644 index 0000000000..7489be1896 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -0,0 +1,751 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os +import sys +from functools import wraps +from pathlib import Path +from typing import Annotated, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool +from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException +from agent_framework_openai import OpenAIContentFilterException +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential +from openai import BadRequestError +from pydantic import BaseModel +from pytest import param + +from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + location: str + weather: str | None = None + + +@tool(approval_mode="never_require") +async def get_weather(location: Annotated[str, "The location as a city name"]) -> str: + """Get the current weather in a given location.""" + return f"The current weather in {location} is sunny." + + +skip_if_foundry_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_MODEL", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.", +) + +_TEST_FOUNDRY_PROJECT_ENDPOINT = "https://test-project.services.ai.azure.com/" +_TEST_FOUNDRY_MODEL = "test-gpt-4o" +_FOUNDRY_CHAT_ENV_VARS = ("FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL") + + +@pytest.fixture(autouse=True) +def clear_foundry_chat_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry chat settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_CHAT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def _with_foundry_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + debug_message = ( + "Foundry debug: " + f"project_endpoint={os.getenv('FOUNDRY_PROJECT_ENDPOINT', '')}, " + f"model={os.getenv('FOUNDRY_MODEL', '')}" + ) + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _make_mock_openai_client() -> MagicMock: + client = MagicMock() + client.default_headers = {} + client.responses = MagicMock() + client.responses.create = AsyncMock() + client.responses.parse = AsyncMock() + client.files = MagicMock() + client.files.create = AsyncMock() + client.files.delete = AsyncMock() + client.vector_stores = MagicMock() + client.vector_stores.create = AsyncMock() + client.vector_stores.delete = AsyncMock() + client.vector_stores.files = MagicMock() + client.vector_stores.files.create_and_poll = AsyncMock() + return client + + +async def create_vector_store(client: FoundryChatClient) -> tuple[str, Content]: + """Create a vector store with sample documents for testing.""" + file = await client.client.files.create( + file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), + purpose="user_data", + ) + vector_store = await client.client.vector_stores.create( + name="knowledge_base", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + result = await client.client.vector_stores.files.create_and_poll( + vector_store_id=vector_store.id, + file_id=file.id, + poll_interval_ms=1000, + ) + if result.last_error is not None: + raise RuntimeError(f"Vector store file processing failed with status: {result.last_error.message}") + + return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) + + +async def delete_vector_store(client: FoundryChatClient, file_id: str, vector_store_id: str) -> None: + """Delete the vector store after tests.""" + await client.client.vector_stores.delete(vector_store_id=vector_store_id) + await client.client.files.delete(file_id=file_id) + + +def test_init() -> None: + mock_openai_client = _make_mock_openai_client() + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + client = FoundryChatClient(project_client=mock_project_client, model=_TEST_FOUNDRY_MODEL) + + assert client.model == _TEST_FOUNDRY_MODEL + assert isinstance(client, SupportsChatGetResponse) + assert client.project_client is mock_project_client + + +def test_init_with_default_header() -> None: + default_headers = {"X-Unit-Test": "test-guid"} + 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_FOUNDRY_MODEL, + default_headers=default_headers, + ) + + assert client.model == _TEST_FOUNDRY_MODEL + for key, value in default_headers.items(): + assert client.default_headers is not None + assert key in client.default_headers + assert client.default_headers[key] == value + + +def test_init_with_project_endpoint_creates_project_client() -> None: + credential = MagicMock() + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + + with patch("agent_framework_foundry._chat_client.AIProjectClient", return_value=project_client) as factory: + client = FoundryChatClient( + project_endpoint=_TEST_FOUNDRY_PROJECT_ENDPOINT, + model=_TEST_FOUNDRY_MODEL, + credential=credential, + allow_preview=True, + ) + + assert client.project_client is project_client + assert client.model == _TEST_FOUNDRY_MODEL + assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT + assert factory.call_args.kwargs["credential"] is credential + assert factory.call_args.kwargs["allow_preview"] is True + assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT + + +def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_MODEL", raising=False) + mock_openai_client = _make_mock_openai_client() + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with pytest.raises(ValueError, match="Model is required"): + FoundryChatClient(project_client=mock_project_client) + + +def test_init_with_empty_project_source_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) + + with pytest.raises(ValueError, match="Either 'project_endpoint' or 'project_client' is required"): + FoundryChatClient(model=_TEST_FOUNDRY_MODEL) + + +def test_init_with_project_endpoint_requires_credential() -> None: + with pytest.raises(ValueError, match="Azure credential is required"): + FoundryChatClient( + project_endpoint=_TEST_FOUNDRY_PROJECT_ENDPOINT, + model=_TEST_FOUNDRY_MODEL, + ) + + +async def test_configure_azure_monitor() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await client.configure_azure_monitor(enable_sensitive_data=True) + + project_client.telemetry.get_application_insights_connection_string.assert_called_once() + mock_configure.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + assert call_kwargs["views"] == [] + assert call_kwargs["resource"] is mock_resource + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_configure_azure_monitor_resource_not_found() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + await client.configure_azure_monitor() + + project_client.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_configure_azure_monitor_import_error() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + original_import = __import__ + + def _import_with_missing_azure_monitor( + name: str, + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name == "azure.monitor.opentelemetry": + raise ImportError("No module named 'azure.monitor.opentelemetry'") + return original_import(name, globals, locals, fromlist, level) + + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await client.configure_azure_monitor() + + +async def test_configure_azure_monitor_with_custom_resource() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + custom_resource = MagicMock() + mock_configure = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", return_value=[]), + patch("agent_framework.observability.create_resource") as mock_create_resource, + patch("agent_framework.observability.enable_instrumentation"), + ): + await client.configure_azure_monitor(resource=custom_resource) + + mock_create_resource.assert_not_called() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["resource"] is custom_resource + + +async def test_get_response_with_invalid_input() -> 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") + + with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"): + await client.get_response(messages=[]) + + +async def test_web_search_tool_with_location() -> 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") + + web_search_tool = FoundryChatClient.get_web_search_tool( + user_location={ + "city": "Seattle", + "country": "US", + "region": "WA", + "timezone": "America/Los_Angeles", + } + ) + + assert web_search_tool.user_location.city == "Seattle" + assert web_search_tool.user_location.country == "US" + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="What's the weather?")], + options={"tools": [web_search_tool], "tool_choice": "auto"}, + ) + + assert run_options["tools"] == [web_search_tool] + assert run_options["tool_choice"] == "auto" + + +async def test_code_interpreter_tool_variations() -> 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") + + code_tool = FoundryChatClient.get_code_interpreter_tool() + assert code_tool.container["type"] == "auto" + + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, + ) + + assert run_options["tools"] == [code_tool] + + code_tool_with_files = FoundryChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) + assert code_tool_with_files.container.file_ids == ["file1", "file2"] + + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Process these files")], + options={"tools": [code_tool_with_files]}, + ) + + assert run_options["tools"] == [code_tool_with_files] + + +async def test_hosted_file_search_tool_validation() -> 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") + + with pytest.raises(ValueError, match="vector_store_ids"): + FoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + file_search_tool = FoundryChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) + assert file_search_tool.vector_store_ids == ["vs_123"] + + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, + ) + + assert run_options["tools"] == [file_search_tool] + + +async def test_chat_message_parsing_with_function_calls() -> 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") + + function_call = Content.from_function_call( + call_id="test-call-id", + name="test_function", + arguments='{"param": "value"}', + additional_properties={"fc_id": "test-fc-id"}, + ) + function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully") + messages = [ + Message(role="user", text="Call a function"), + Message(role="assistant", contents=[function_call]), + Message(role="tool", contents=[function_result]), + ] + + prepared_messages = client._prepare_messages_for_openai(messages) + + assert prepared_messages == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Call a function"}], + }, + { + "call_id": "test-call-id", + "id": "fc_test-fc-id", + "type": "function_call", + "name": "test_function", + "arguments": '{"param": "value"}', + }, + { + "call_id": "test-call-id", + "type": "function_call_output", + "output": "Function executed successfully", + }, + ] + + +async def test_content_filter_exception() -> 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") + + mock_error = BadRequestError( + message="Content filter error", + response=MagicMock(), + body={"error": {"code": "content_filter", "message": "Content filter error"}}, + ) + mock_error.code = "content_filter" + client.client.responses.create.side_effect = mock_error + + with pytest.raises(OpenAIContentFilterException) as exc_info: + await client.get_response(messages=[Message(role="user", text="Test message")]) + + assert "content error" in str(exc_info.value) + + +async def test_response_format_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") + + mock_parsed_response = MagicMock() + mock_parsed_response.id = "parsed_response_123" + mock_parsed_response.text = "Parsed response" + mock_parsed_response.model = "test-model" + mock_parsed_response.created_at = 1000000000 + mock_parsed_response.metadata = {} + mock_parsed_response.output_parsed = None + mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None + mock_parsed_response.conversation = None + client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct, "store": True}, + ) + assert response.response_id == "parsed_response_123" + assert response.conversation_id == "parsed_response_123" + assert response.model == "test-model" + + +async def test_response_format_parse_path_with_conversation_id() -> 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") + + mock_parsed_response = MagicMock() + mock_parsed_response.id = "parsed_response_123" + mock_parsed_response.text = "Parsed response" + mock_parsed_response.model = "test-model" + mock_parsed_response.created_at = 1000000000 + mock_parsed_response.metadata = {} + mock_parsed_response.output_parsed = None + mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None + mock_parsed_response.conversation = MagicMock() + mock_parsed_response.conversation.id = "conversation_456" + client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct, "store": True}, + ) + assert response.response_id == "parsed_response_123" + assert response.conversation_id == "conversation_456" + assert response.model == "test-model" + + +async def test_bad_request_error_non_content_filter() -> 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") + + mock_error = BadRequestError( + message="Invalid request", + response=MagicMock(), + body={"error": {"code": "invalid_request", "message": "Invalid request"}}, + ) + mock_error.code = "invalid_request" + client.client.responses.parse = AsyncMock(side_effect=mock_error) + + with pytest.raises(ChatClientException) as exc_info: + await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct}, + ) + + assert "failed to complete the prompt" in str(exc_info.value) + + +def test_get_mcp_tool_with_project_connection_id() -> None: + tool_config = FoundryChatClient.get_mcp_tool( + name="Docs MCP", + project_connection_id="conn-123", + allowed_tools=["search_docs"], + ) + + assert tool_config["project_connection_id"] == "conn-123" + assert tool_config["allowed_tools"] == ["search_docs"] + assert tool_config["server_label"] == "Docs_MCP" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@pytest.mark.parametrize( + "option_name,option_value,needs_validation", + [ + param("max_tokens", 500, False, id="max_tokens"), + param("seed", 123, False, id="seed"), + param("user", "test-user-id", False, id="user"), + param("metadata", {"test_key": "test_value"}, False, id="metadata"), + param("tool_choice", "none", True, id="tool_choice_none"), + param("tools", [get_weather], True, id="tools_function"), + param("tool_choice", "auto", True, id="tool_choice_auto"), + param("response_format", OutputStruct, True, id="response_format_pydantic"), + param( + "response_format", + { + "type": "json_schema", + "json_schema": { + "name": "WeatherDigest", + "strict": True, + "schema": { + "title": "WeatherDigest", + "type": "object", + "properties": { + "location": {"type": "string"}, + "conditions": {"type": "string"}, + }, + "required": ["location", "conditions"], + "additionalProperties": False, + }, + }, + }, + True, + id="response_format_runtime_json_schema", + ), + ], +) +@_with_foundry_debug() +async def test_integration_options( + option_name: str, + option_value: Any, + needs_validation: bool, +) -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + client.function_invocation_configuration["max_iterations"] = 2 + + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + options: dict[str, Any] = {option_name: option_value} + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + + if needs_validation: + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + text = response.text.lower() + assert "sunny" in text or "seattle" in text + elif option_name.startswith("response_format"): + if option_value == OutputStruct: + assert response.value is not None + 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 + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@_with_foundry_debug() +async def test_integration_web_search() -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + + web_search_tool = FoundryChatClient.get_web_search_tool() + content = { + "messages": [ + Message( + role="user", + text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + ) + ], + "options": {"tool_choice": "auto", "tools": [web_search_tool]}, + } + response = await client.get_response(stream=True, **content).get_final_response() + + assert isinstance(response, ChatResponse) + assert "Rumi" in response.text + assert "Mira" in response.text + assert "Zoey" in response.text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@_with_foundry_debug() +async def test_integration_tool_rich_content_image() -> None: + image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" + image_bytes = image_path.read_bytes() + + @tool(approval_mode="never_require") + def get_test_image() -> Content: + return Content.from_data(data=image_bytes, media_type="image/jpeg") + + client = FoundryChatClient(credential=AzureCliCredential()) + client.function_invocation_configuration["max_iterations"] = 2 + + messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} + + response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" + + +def test_get_code_interpreter_tool() -> None: + """Test code interpreter tool creation.""" + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool() + assert tool_obj is not None + + +def test_get_code_interpreter_tool_with_file_ids() -> None: + """Test code interpreter tool with file IDs.""" + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) + assert tool_obj is not None + + +def test_get_file_search_tool() -> None: + """Test file search tool creation.""" + + tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) + assert tool_obj is not None + + +def test_get_file_search_tool_requires_vector_store_ids() -> None: + """Test that empty vector_store_ids raises ValueError.""" + + with pytest.raises(ValueError, match="vector_store_ids"): + RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + +def test_get_web_search_tool() -> None: + """Test web search tool creation.""" + + tool_obj = RawFoundryChatClient.get_web_search_tool() + assert tool_obj is not None + + +def test_get_web_search_tool_with_location() -> None: + """Test web search tool with user location.""" + + tool_obj = RawFoundryChatClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + search_context_size="high", + ) + assert tool_obj is not None + + +def test_get_image_generation_tool() -> None: + """Test image generation tool creation.""" + + tool_obj = RawFoundryChatClient.get_image_generation_tool() + assert tool_obj is not None + + +def test_get_mcp_tool() -> None: + """Test MCP tool creation.""" + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + assert tool_obj is not None + + +def test_get_mcp_tool_with_connection_id() -> None: + """Test MCP tool with project connection ID.""" + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="github_mcp", + project_connection_id="conn_abc123", + description="GitHub MCP via Foundry", + ) + assert tool_obj is not None diff --git a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py new file mode 100644 index 0000000000..005eed29ce --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py @@ -0,0 +1,501 @@ +# Copyright (c) Microsoft. All rights reserved. +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message +from agent_framework._sessions import AgentSession, SessionContext + +from agent_framework_foundry._memory_provider import FoundryMemoryProvider + + +@pytest.fixture +def mock_project_client() -> AsyncMock: + """Create a mock AIProjectClient.""" + mock_client = AsyncMock() + mock_client.beta = AsyncMock() + mock_client.beta.memory_stores = AsyncMock() + mock_client.beta.memory_stores.search_memories = AsyncMock() + mock_client.beta.memory_stores.begin_update_memories = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + return mock_client + + +@pytest.fixture +def mock_credential() -> Mock: + """Create a mock Azure credential.""" + return Mock() + + +# -- Initialization tests ------------------------------------------------------ + + +def test_init_with_all_params(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + source_id="custom_source", + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + context_prompt="Custom prompt", + update_delay=60, + ) + assert provider.source_id == "custom_source" + assert provider.project_client is mock_project_client + assert provider.memory_store_name == "test_store" + assert provider.scope == "user_123" + assert provider.context_prompt == "Custom prompt" + assert provider.update_delay == 60 + + +def test_init_default_source_id(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID + + +def test_init_default_context_prompt(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT + + +def test_init_default_update_delay(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.update_delay == 300 + + +def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMock, mock_credential: Mock) -> None: + with patch("agent_framework_foundry._memory_provider.AIProjectClient") as mock_ai_project_client: + mock_ai_project_client.return_value = mock_project_client + provider = FoundryMemoryProvider( + project_endpoint="https://test.project.endpoint", + credential=mock_credential, # type: ignore[arg-type] + allow_preview=True, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.project_client is mock_project_client + mock_ai_project_client.assert_called_once_with( + endpoint="https://test.project.endpoint", + credential=mock_credential, + allow_preview=True, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + + +def test_init_requires_project_endpoint_without_project_client() -> None: + with ( + patch("agent_framework_foundry._memory_provider.load_settings") as mock_load_settings, + patch.dict(os.environ, {}, clear=True), + pytest.raises(ValueError, match="project endpoint is required"), + ): + mock_load_settings.return_value = {"project_endpoint": None} + FoundryMemoryProvider( + memory_store_name="test_store", + scope="user_123", + ) + + +def test_init_requires_credential_without_project_client() -> None: + with pytest.raises(ValueError, match="Azure credential is required"): + FoundryMemoryProvider( + project_endpoint="https://test.project.endpoint", + memory_store_name="test_store", + scope="user_123", + ) + + +def test_init_requires_memory_store_name(mock_project_client: AsyncMock) -> None: + with pytest.raises(ValueError, match="memory_store_name is required"): + FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="", + scope="user_123", + ) + + +def test_init_requires_scope(mock_project_client: AsyncMock) -> None: + with pytest.raises(ValueError, match="scope is required"): + FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="", + ) + + +# -- before_run tests ---------------------------------------------------------- + + +async def test_retrieves_static_memories_on_first_run(mock_project_client: AsyncMock) -> None: + mem1 = Mock() + mem1.memory_item.content = "User prefers Python" + mem2 = Mock() + mem2.memory_item.content = "User is based in Seattle" + mock_search_result = Mock() + mock_search_result.memories = [mem1, mem2] + mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Should call search_memories twice: once for static, once for contextual + assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 + # Static memories should be cached + assert len(session.state[provider.source_id]["static_memories"]) == 2 + assert session.state[provider.source_id]["initialized"] is True + + +async def test_contextual_memories_added_to_context(mock_project_client: AsyncMock) -> None: + # Mock static search (first call) + static_mem = Mock() + static_mem.memory_item.content = "User prefers Python" + static_result = Mock() + static_result.memories = [static_mem] + + # Mock contextual search (second call) + contextual_mem = Mock() + contextual_mem.memory_item.content = "Last discussed async patterns" + contextual_result = Mock() + contextual_result.memories = [contextual_mem] + contextual_result.search_id = "search-123" + + mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Check that memories were added to context + assert provider.source_id in ctx.context_messages + added = ctx.context_messages[provider.source_id] + assert len(added) == 1 + assert "User prefers Python" in added[0].text # type: ignore[operator] + assert "Last discussed async patterns" in added[0].text # type: ignore[operator] + assert provider.context_prompt in added[0].text # type: ignore[operator] + assert session.state[provider.source_id]["previous_search_id"] == "search-123" + + +async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMock) -> None: + static_result = Mock() + static_result.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = static_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Should only call search_memories once for static memories + assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 + assert provider.source_id not in ctx.context_messages + + +async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) -> None: + mock_search_result = Mock() + mock_search_result.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + assert provider.source_id not in ctx.context_messages + + +async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMock) -> None: + static_mem = Mock() + static_mem.memory_item.content = "Static memory" + static_result = Mock() + static_result.memories = [static_mem] + contextual_result = Mock() + contextual_result.memories = [] + + mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + # First call + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 + + # Reset mock for second call + mock_project_client.beta.memory_stores.search_memories.reset_mock() + contextual_result2 = Mock() + contextual_result2.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 + + # Second call - should only search contextual, not static + ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) + ) + assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 + + +async def test_handles_search_exception_gracefully(mock_project_client: AsyncMock) -> None: + mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error") + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + # Should not raise exception + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # No memories added + assert provider.source_id not in ctx.context_messages + + +# -- after_run tests ----------------------------------------------------------- + + +async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_poller.update_id = "update-456" + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once() + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["name"] == "test_store" + assert call_kwargs["scope"] == "user_123" + assert len(call_kwargs["items"]) == 2 + assert call_kwargs["items"][0]["content"] == "question" + assert call_kwargs["items"][1]["content"] == "answer" + assert session.state[provider.source_id]["previous_update_id"] == "update-456" + + +async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text="hello"), + Message(role="tool", text="tool output"), + ], + session_id="s1", + ) + ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + items = call_kwargs["items"] + assert len(items) == 2 + assert items[0]["content"] == "hello" + assert items[1]["content"] == "reply" + + +async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text=""), + Message(role="user", text=" "), + ], + session_id="s1", + ) + ctx._response = AgentResponse(messages=[]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited() + + +async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + update_delay=60, + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["update_delay"] == 60 + + +async def test_uses_previous_update_id_for_incremental_updates(mock_project_client: AsyncMock) -> None: + mock_poller1 = Mock() + mock_poller1.update_id = "update-1" + mock_poller2 = Mock() + mock_poller2.update_id = "update-2" + + mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") + ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) + + # First update + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {}) + ) + assert session.state[provider.source_id]["previous_update_id"] == "update-1" + + # Second update should use previous_update_id + ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") + ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["previous_update_id"] == "update-1" + assert session.state[provider.source_id]["previous_update_id"] == "update-2" + + +async def test_handles_update_exception_gracefully(mock_project_client: AsyncMock) -> None: + mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error") + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + # Should not raise exception + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + +# -- Context manager tests ----------------------------------------------------- + + +async def test_aenter_delegates_to_client(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + result = await provider.__aenter__() + assert result is provider + mock_project_client.__aenter__.assert_awaited_once() + + +async def test_aexit_delegates_to_client(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + await provider.__aexit__(None, None, None) + mock_project_client.__aexit__.assert_awaited_once() + + +async def test_async_with_syntax(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + async with provider as p: + assert p is provider diff --git a/python/packages/foundry/tests/test_foundry_agent.py b/python/packages/foundry/tests/test_foundry_agent.py deleted file mode 100644 index 549d922ff9..0000000000 --- a/python/packages/foundry/tests/test_foundry_agent.py +++ /dev/null @@ -1,374 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for FoundryAgentClient and FoundryAgent classes.""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework._tools import tool - - -class TestRawFoundryAgentChatClient: - """Tests for RawFoundryAgentChatClient.""" - - def test_init_requires_agent_name(self) -> None: - """Test that agent_name is required.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - with pytest.raises(ValueError, match="Agent name is required"): - RawFoundryAgentChatClient( - project_client=MagicMock(), - ) - - def test_init_with_agent_name(self) -> None: - """Test construction with agent_name and project_client.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.agent_name == "test-agent" - assert client.agent_version == "1.0" - - def test_get_agent_reference_with_version(self) -> None: - """Test agent reference includes version when provided.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="my-agent", - agent_version="2.0", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} - - def test_get_agent_reference_without_version(self) -> None: - """Test agent reference omits version for HostedAgents.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="hosted-agent", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "hosted-agent", "type": "agent_reference"} - assert "version" not in ref - - def test_as_agent_returns_foundry_agent_and_preserves_client_type(self) -> None: - """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" - from agent_framework_foundry._foundry_agent import FoundryAgent - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - class CustomClient(RawFoundryAgentChatClient): - pass - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = CustomClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - agent = client.as_agent(instructions="You are helpful.") - - assert isinstance(agent, FoundryAgent) - assert agent.name == "test-agent" - assert isinstance(agent.client, CustomClient) - assert agent.client.project_client is mock_project - assert agent.client.agent_name == "test-agent" - assert agent.client.agent_version == "1.0" - - named_agent = client.as_agent(name="display-name", instructions="You are helpful.") - assert named_agent.name == "display-name" - assert named_agent.client.agent_name == "test-agent" - - async def test_prepare_options_validates_tools(self) -> None: - """Test that _prepare_options rejects non-FunctionTool objects.""" - from agent_framework import Message - - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - # A dict tool should be rejected - with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): - await client._prepare_options( - messages=[Message(role="user", contents="hi")], - options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, - ) - - async def test_prepare_options_accepts_function_tools(self) -> None: - """Test that _prepare_options accepts FunctionTool objects.""" - from agent_framework import Message - - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_openai = MagicMock() - mock_project.get_openai_client.return_value = mock_openai - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - @tool(approval_mode="never_require") - def my_func() -> str: - """A test function.""" - return "ok" - - # Should not raise — patch the parent's _prepare_options - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - new_callable=AsyncMock, - return_value={}, - ): - result = await client._prepare_options( - messages=[Message(role="user", contents="hi")], - options={"tools": [my_func]}, - ) - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" - - def test_check_model_presence_is_noop(self) -> None: - """Test that _check_model_presence does nothing (model is on service).""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - options: dict[str, Any] = {} - client._check_model_presence(options) - assert "model" not in options - - -class TestFoundryAgentChatClient: - """Tests for _FoundryAgentChatClient (full middleware).""" - - def test_init(self) -> None: - """Test construction of the full-middleware client.""" - from agent_framework_foundry._foundry_agent_client import _FoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = _FoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.agent_name == "test-agent" - - -class TestRawFoundryAgent: - """Tests for RawFoundryAgent.""" - - def test_init_creates_client(self) -> None: - """Test that RawFoundryAgent creates a client internally.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert agent.client is not None - assert agent.client.agent_name == "test-agent" - - def test_init_with_custom_client_type(self) -> None: - """Test that client_type parameter is respected.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - client_type=RawFoundryAgentChatClient, - ) - - assert isinstance(agent.client, RawFoundryAgentChatClient) - - def test_init_rejects_invalid_client_type(self) -> None: - """Test that invalid client_type raises TypeError.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): - RawFoundryAgent( - project_client=MagicMock(), - agent_name="test-agent", - client_type=object, # type: ignore[arg-type] - ) - - def test_init_with_function_tools(self) -> None: - """Test that FunctionTool and callables are accepted.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - @tool(approval_mode="never_require") - def my_func() -> str: - """A test function.""" - return "ok" - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - tools=[my_func], - ) - - assert agent.default_options.get("tools") is not None - - -class TestFoundryAgent: - """Tests for FoundryAgent (full middleware).""" - - def test_init(self) -> None: - """Test construction of the full-middleware agent.""" - from agent_framework_foundry._foundry_agent import FoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert agent.client is not None - assert agent.client.agent_name == "test-agent" - - def test_init_with_middleware(self) -> None: - """Test that agent-level middleware is accepted.""" - from agent_framework import ChatContext, ChatMiddleware - - from agent_framework_foundry._foundry_agent import FoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - class MyMiddleware(ChatMiddleware): - async def process(self, context: ChatContext) -> None: - pass - - agent = FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - middleware=[MyMiddleware()], - ) - - assert agent.client is not None - - -class TestFoundryChatClientToolMethods: - """Tests for RawFoundryChatClient tool factory methods.""" - - def test_get_code_interpreter_tool(self) -> None: - """Test code interpreter tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_code_interpreter_tool() - assert tool_obj is not None - - def test_get_code_interpreter_tool_with_file_ids(self) -> None: - """Test code interpreter tool with file IDs.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) - assert tool_obj is not None - - def test_get_file_search_tool(self) -> None: - """Test file search tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) - assert tool_obj is not None - - def test_get_file_search_tool_requires_vector_store_ids(self) -> None: - """Test that empty vector_store_ids raises ValueError.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - with pytest.raises(ValueError, match="vector_store_ids"): - RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) - - def test_get_web_search_tool(self) -> None: - """Test web search tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_web_search_tool() - assert tool_obj is not None - - def test_get_web_search_tool_with_location(self) -> None: - """Test web search tool with user location.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - search_context_size="high", - ) - assert tool_obj is not None - - def test_get_image_generation_tool(self) -> None: - """Test image generation tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_image_generation_tool() - assert tool_obj is not None - - def test_get_mcp_tool(self) -> None: - """Test MCP tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - assert tool_obj is not None - - def test_get_mcp_tool_with_connection_id(self) -> None: - """Test MCP tool with project connection ID.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_mcp_tool( - name="github_mcp", - project_connection_id="conn_abc123", - description="GitHub MCP via Foundry", - ) - assert tool_obj is not None diff --git a/python/packages/foundry/tests/test_foundry_memory_provider.py b/python/packages/foundry/tests/test_foundry_memory_provider.py deleted file mode 100644 index f7e02f8a89..0000000000 --- a/python/packages/foundry/tests/test_foundry_memory_provider.py +++ /dev/null @@ -1,507 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -import os -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message -from agent_framework._sessions import AgentSession, SessionContext - -from agent_framework_foundry._foundry_memory_provider import FoundryMemoryProvider - - -@pytest.fixture -def mock_project_client() -> AsyncMock: - """Create a mock AIProjectClient.""" - mock_client = AsyncMock() - mock_client.beta = AsyncMock() - mock_client.beta.memory_stores = AsyncMock() - mock_client.beta.memory_stores.search_memories = AsyncMock() - mock_client.beta.memory_stores.begin_update_memories = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock() - return mock_client - - -@pytest.fixture -def mock_credential() -> Mock: - """Create a mock Azure credential.""" - return Mock() - - -# -- Initialization tests ------------------------------------------------------ - - -class TestInit: - """Test FoundryMemoryProvider initialization.""" - - def test_init_with_all_params(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - source_id="custom_source", - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - context_prompt="Custom prompt", - update_delay=60, - ) - assert provider.source_id == "custom_source" - assert provider.project_client is mock_project_client - assert provider.memory_store_name == "test_store" - assert provider.scope == "user_123" - assert provider.context_prompt == "Custom prompt" - assert provider.update_delay == 60 - - def test_init_default_source_id(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID - - def test_init_default_context_prompt(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT - - def test_init_default_update_delay(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.update_delay == 300 - - def test_init_with_project_endpoint_and_credential( - self, mock_project_client: AsyncMock, mock_credential: Mock - ) -> None: - with patch("agent_framework_foundry._foundry_memory_provider.AIProjectClient") as mock_ai_project_client: - mock_ai_project_client.return_value = mock_project_client - provider = FoundryMemoryProvider( - project_endpoint="https://test.project.endpoint", - credential=mock_credential, # type: ignore[arg-type] - allow_preview=True, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.project_client is mock_project_client - mock_ai_project_client.assert_called_once_with( - endpoint="https://test.project.endpoint", - credential=mock_credential, - allow_preview=True, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - - def test_init_requires_project_endpoint_without_project_client(self) -> None: - with ( - patch("agent_framework_foundry._foundry_memory_provider.load_settings") as mock_load_settings, - patch.dict(os.environ, {}, clear=True), - pytest.raises(ValueError, match="project endpoint is required"), - ): - mock_load_settings.return_value = {"project_endpoint": None} - FoundryMemoryProvider( - memory_store_name="test_store", - scope="user_123", - ) - - def test_init_requires_credential_without_project_client(self) -> None: - with pytest.raises(ValueError, match="Azure credential is required"): - FoundryMemoryProvider( - project_endpoint="https://test.project.endpoint", - memory_store_name="test_store", - scope="user_123", - ) - - def test_init_requires_memory_store_name(self, mock_project_client: AsyncMock) -> None: - with pytest.raises(ValueError, match="memory_store_name is required"): - FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="", - scope="user_123", - ) - - def test_init_requires_scope(self, mock_project_client: AsyncMock) -> None: - with pytest.raises(ValueError, match="scope is required"): - FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="", - ) - - -# -- before_run tests ---------------------------------------------------------- - - -class TestBeforeRun: - """Test before_run hook.""" - - async def test_retrieves_static_memories_on_first_run(self, mock_project_client: AsyncMock) -> None: - """First call retrieves static (user profile) memories.""" - mem1 = Mock() - mem1.memory_item.content = "User prefers Python" - mem2 = Mock() - mem2.memory_item.content = "User is based in Seattle" - mock_search_result = Mock() - mock_search_result.memories = [mem1, mem2] - mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Should call search_memories twice: once for static, once for contextual - assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 - # Static memories should be cached - assert len(session.state[provider.source_id]["static_memories"]) == 2 - assert session.state[provider.source_id]["initialized"] is True - - async def test_contextual_memories_added_to_context(self, mock_project_client: AsyncMock) -> None: - """Contextual search returns memories → messages added to context with prompt.""" - # Mock static search (first call) - static_mem = Mock() - static_mem.memory_item.content = "User prefers Python" - static_result = Mock() - static_result.memories = [static_mem] - - # Mock contextual search (second call) - contextual_mem = Mock() - contextual_mem.memory_item.content = "Last discussed async patterns" - contextual_result = Mock() - contextual_result.memories = [contextual_mem] - contextual_result.search_id = "search-123" - - mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Check that memories were added to context - assert provider.source_id in ctx.context_messages - added = ctx.context_messages[provider.source_id] - assert len(added) == 1 - assert "User prefers Python" in added[0].text # type: ignore[operator] - assert "Last discussed async patterns" in added[0].text # type: ignore[operator] - assert provider.context_prompt in added[0].text # type: ignore[operator] - assert session.state[provider.source_id]["previous_search_id"] == "search-123" - - async def test_empty_input_skips_contextual_search(self, mock_project_client: AsyncMock) -> None: - """Empty input messages → only static search performed, no contextual search.""" - static_result = Mock() - static_result.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = static_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Should only call search_memories once for static memories - assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 - assert provider.source_id not in ctx.context_messages - - async def test_empty_search_results_no_messages(self, mock_project_client: AsyncMock) -> None: - """Empty search results → no messages added.""" - mock_search_result = Mock() - mock_search_result.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - assert provider.source_id not in ctx.context_messages - - async def test_static_memories_only_retrieved_once(self, mock_project_client: AsyncMock) -> None: - """Static memories are only retrieved on the first call.""" - static_mem = Mock() - static_mem.memory_item.content = "Static memory" - static_result = Mock() - static_result.memories = [static_mem] - contextual_result = Mock() - contextual_result.memories = [] - - mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - # First call - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 - - # Reset mock for second call - mock_project_client.beta.memory_stores.search_memories.reset_mock() - contextual_result2 = Mock() - contextual_result2.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 - - # Second call - should only search contextual, not static - ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) - ) - assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 - - async def test_handles_search_exception_gracefully(self, mock_project_client: AsyncMock) -> None: - """Search exception is logged but doesn't fail the operation.""" - mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error") - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - # Should not raise exception - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # No memories added - assert provider.source_id not in ctx.context_messages - - -# -- after_run tests ----------------------------------------------------------- - - -class TestAfterRun: - """Test after_run hook.""" - - async def test_stores_input_and_response(self, mock_project_client: AsyncMock) -> None: - """Stores input+response messages via begin_update_memories.""" - mock_poller = Mock() - mock_poller.update_id = "update-456" - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once() - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["name"] == "test_store" - assert call_kwargs["scope"] == "user_123" - assert len(call_kwargs["items"]) == 2 - assert call_kwargs["items"][0]["content"] == "question" - assert call_kwargs["items"][1]["content"] == "answer" - assert session.state[provider.source_id]["previous_update_id"] == "update-456" - - async def test_only_stores_user_assistant_system(self, mock_project_client: AsyncMock) -> None: - """Only stores user/assistant/system messages with text.""" - mock_poller = Mock() - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext( - input_messages=[ - Message(role="user", text="hello"), - Message(role="tool", text="tool output"), - ], - session_id="s1", - ) - ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - items = call_kwargs["items"] - assert len(items) == 2 - assert items[0]["content"] == "hello" - assert items[1]["content"] == "reply" - - async def test_skips_empty_messages(self, mock_project_client: AsyncMock) -> None: - """Skips messages with empty text.""" - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext( - input_messages=[ - Message(role="user", text=""), - Message(role="user", text=" "), - ], - session_id="s1", - ) - ctx._response = AgentResponse(messages=[]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited() - - async def test_uses_configured_update_delay(self, mock_project_client: AsyncMock) -> None: - """Uses the configured update_delay parameter.""" - mock_poller = Mock() - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - update_delay=60, - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["update_delay"] == 60 - - async def test_uses_previous_update_id_for_incremental_updates(self, mock_project_client: AsyncMock) -> None: - """Uses previous_update_id for incremental updates.""" - mock_poller1 = Mock() - mock_poller1.update_id = "update-1" - mock_poller2 = Mock() - mock_poller2.update_id = "update-2" - - mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") - ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) - - # First update - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {}) - ) - assert session.state[provider.source_id]["previous_update_id"] == "update-1" - - # Second update should use previous_update_id - ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") - ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["previous_update_id"] == "update-1" - assert session.state[provider.source_id]["previous_update_id"] == "update-2" - - async def test_handles_update_exception_gracefully(self, mock_project_client: AsyncMock) -> None: - """Update exception is logged but doesn't fail the operation.""" - mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error") - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) - - # Should not raise exception - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - -# -- Context manager tests ----------------------------------------------------- - - -class TestContextManager: - """Test __aenter__/__aexit__ delegation.""" - - async def test_aenter_delegates_to_client(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - result = await provider.__aenter__() - assert result is provider - mock_project_client.__aenter__.assert_awaited_once() - - async def test_aexit_delegates_to_client(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - await provider.__aexit__(None, None, None) - mock_project_client.__aexit__.assert_awaited_once() - - async def test_async_with_syntax(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - async with provider as p: - assert p is provider diff --git a/python/packages/lab/gaia/samples/openai_agent.py b/python/packages/lab/gaia/samples/openai_agent.py index a5709ecf2a..227b12c03c 100644 --- a/python/packages/lab/gaia/samples/openai_agent.py +++ b/python/packages/lab/gaia/samples/openai_agent.py @@ -7,7 +7,7 @@ Required Environment Variables: OPENAI_API_KEY: Your OpenAI API key - OPENAI_RESPONSES_MODEL_ID: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) + OPENAI_RESPONSES_MODEL: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) Optional Environment Variables: OPENAI_BASE_URL: Custom API base URL if using a proxy or compatible service @@ -19,7 +19,7 @@ Example: export OPENAI_API_KEY="sk-..." - export OPENAI_RESPONSES_MODEL_ID="gpt-4o" + export OPENAI_RESPONSES_MODEL="gpt-4o" """ from collections.abc import AsyncIterator diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md index d31506cf5d..48c3a306bd 100644 --- a/python/packages/openai/AGENTS.md +++ b/python/packages/openai/AGENTS.md @@ -27,6 +27,10 @@ agent_framework_openai/ All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). +The generic OpenAI clients support both OpenAI and Azure OpenAI routing. Precedence is: +explicit Azure inputs (`credential`, `azure_endpoint`, `api_version`) → OpenAI API key +(`OPENAI_API_KEY`) → Azure environment fallback (`AZURE_OPENAI_*`). + ## Dependencies - `agent-framework-core` — core abstractions diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md index 6ed4d20c03..e04a1f947a 100644 --- a/python/packages/openai/README.md +++ b/python/packages/openai/README.md @@ -1,17 +1,106 @@ # agent-framework-openai -OpenAI integration for Microsoft Agent Framework. Provides chat clients for the OpenAI Responses API and Chat Completions API. +OpenAI integration for Microsoft Agent Framework. + +This package provides: + +- `OpenAIChatClient` for the OpenAI Responses API +- `OpenAIChatCompletionClient` for the Chat Completions API +- `OpenAIEmbeddingClient` for embeddings ## Installation ```bash -pip install agent-framework-openai +pip install agent-framework-openai --pre +``` + +## Which chat client should I use? + +Use `OpenAIChatClient` for new work unless you specifically need the Chat Completions API. + +- `OpenAIChatClient` uses the Responses API and is the preferred general-purpose chat client. +- `OpenAIChatCompletionClient` uses the Chat Completions API and is mainly for compatibility with + existing Chat Completions-based integrations. + +The deprecated `OpenAIResponsesClient` alias points to `OpenAIChatClient`. + +## Environment variables + +### OpenAI + +These variables are used when the client is configured for OpenAI: + +| Variable | Purpose | +| --- | --- | +| `OPENAI_API_KEY` | OpenAI API key | +| `OPENAI_ORG_ID` | OpenAI organization ID | +| `OPENAI_BASE_URL` | Custom OpenAI-compatible base URL | +| `OPENAI_MODEL` | Generic fallback model | +| `OPENAI_RESPONSES_MODEL` | Preferred model for `OpenAIChatClient` | +| `OPENAI_CHAT_MODEL` | Preferred model for `OpenAIChatCompletionClient` | +| `OPENAI_EMBEDDING_MODEL` | Preferred model for `OpenAIEmbeddingClient` | + +Model lookup order: + +- `OpenAIChatClient`: `OPENAI_RESPONSES_MODEL` -> `OPENAI_MODEL` +- `OpenAIChatCompletionClient`: `OPENAI_CHAT_MODEL` -> `OPENAI_MODEL` +- `OpenAIEmbeddingClient`: `OPENAI_EMBEDDING_MODEL` -> `OPENAI_MODEL` + +### Azure OpenAI + +These variables are used when the client is configured for Azure OpenAI: + +| Variable | Purpose | +| --- | --- | +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI resource endpoint | +| `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` | + +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` + +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 +`credential`. + +## OpenAI example + +```python +from agent_framework.openai import OpenAIChatClient + +client = OpenAIChatClient(model="gpt-4.1") ``` -## Usage +## Azure OpenAI example ```python +from azure.identity.aio import AzureCliCredential + from agent_framework.openai import OpenAIChatClient -client = OpenAIChatClient(model_id="gpt-4o") +client = OpenAIChatClient( + model="my-responses-deployment", + azure_endpoint="https://my-resource.openai.azure.com", + credential=AzureCliCredential(), +) +``` + +## ChatClient vs ChatCompletionClient + +Use `OpenAIChatClient` when you want the Responses API as your default chat surface. + +Use `OpenAIChatCompletionClient` when you specifically need the Chat Completions API: + +```python +from agent_framework.openai import OpenAIChatCompletionClient + +client = OpenAIChatCompletionClient(model="gpt-4o-mini") ``` diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 86af86895e..b0d56ee26f 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -14,7 +14,6 @@ MutableMapping, Sequence, ) -from copy import copy from datetime import datetime, timezone from itertools import chain from typing import ( @@ -28,12 +27,11 @@ cast, overload, ) -from urllib.parse import urljoin, urlparse from agent_framework._clients import BaseChatClient -from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._tools import ( SHELL_TOOL_KIND_VALUE, FunctionInvocationConfiguration, @@ -87,8 +85,7 @@ from ._exceptions import OpenAIContentFilterException from ._shared import ( - DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, - get_api_key, + AzureTokenProvider, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -107,14 +104,15 @@ from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, - ) + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential logger = logging.getLogger("agent_framework.openai") + +DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" + OPENAI_SHELL_ENVIRONMENT_KEY = "openai.responses.shell.environment" OPENAI_SHELL_OUTPUT_TYPE_KEY = "openai.responses.shell.output_type" OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY = "openai.responses.local_shell.call_item_id" @@ -139,7 +137,7 @@ class ReasoningOptions(TypedDict, total=False): See: https://platform.openai.com/docs/guides/reasoning """ - effort: Literal["low", "medium", "high"] + effort: Literal["none", "low", "medium", "high", "xhigh"] """The effort level for reasoning. Higher effort means more reasoning tokens.""" summary: Literal["auto", "concise", "detailed"] @@ -272,8 +270,8 @@ class RawOpenAIChatClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -282,31 +280,77 @@ def __init__( instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, + *, azure_endpoint: str, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat client. + + 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``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, + but ``credential`` is the preferred Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( 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, base_url: str | None = None, azure_endpoint: str | None = None, @@ -318,29 +362,53 @@ def __init__( env_file_encoding: str | None = None, **kwargs: Any, ) -> None: - """Initialize a raw OpenAI Responses client. + """Initialize a raw OpenAI Chat client. Keyword Args: - model: OpenAI model name. + 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``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the - resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI - key auth, either pass the resource endpoint without that suffix to - ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. - Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` - is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + 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, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Responses default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - instruction_role: Role for instruction messages (e.g. ``"system"``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + 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``. """ if model_id is not None and model is None: import warnings @@ -348,98 +416,39 @@ def __init__( warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - openai_settings: dict[str, Any] = {} - use_azure_client = isinstance(async_client, AsyncAzureOpenAI) - if not async_client: - resolved_settings, use_azure_client = load_openai_service_settings( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), - default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, - ) - openai_settings = dict(resolved_settings) - - api_key_value = openai_settings.get("api_key") - if not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via the 'api_key' parameter or the " - "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." - ) - resolved_model = openai_settings.get("model") or model - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via the 'model' parameter or the " - "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if use_azure_client: - endpoint_value = openai_settings.get("azure_endpoint") - if ( - not openai_settings.get("base_url") - and endpoint_value - and (hostname := urlparse(str(endpoint_value)).hostname) - and hostname.endswith(".openai.azure.com") - ): - openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") - - client_args.pop("api_key") - if resolved_api_version := openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): - client_args["azure_endpoint"] = resolved_azure_endpoint - if callable(resolved_api_key): - client_args["azure_ad_token_provider"] = resolved_api_key - else: - client_args["api_key"] = resolved_api_key - client_args["azure_deployment"] = resolved_model - async_client = AsyncAzureOpenAI(**client_args) - else: - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, + default_headers=default_headers, + client=async_client, + 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"), + responses_mode=True, + ) - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + self.model: str = settings.get("model") or settings.get("deployment_name") or "" # Store configuration for serialization - resolved_base_url = openai_settings.get("base_url") or base_url - resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint - resolved_api_version = openai_settings.get("api_version") or api_version - self.org_id = openai_settings.get("org_id") or org_id - self.base_url = str(resolved_base_url) if resolved_base_url else None - self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None - self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None - - if instruction_role is not None: - self.instruction_role = instruction_role - + self.instruction_role = instruction_role if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] @@ -2452,8 +2461,8 @@ class OpenAIChatClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -2462,38 +2471,84 @@ def __init__( instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: ... + ) -> None: + """Initialize an OpenAI Responses client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + """ + ... @overload def __init__( self, - *, model: str | None = None, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, base_url: str | None = None, - azure_endpoint: str, - api_version: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: ... + ) -> None: + """Initialize an OpenAI Responses client. + + 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``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + """ + ... def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -2503,43 +2558,59 @@ def __init__( instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: """Initialize an OpenAI Responses client. Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - base_url: The base URL to use. If provided will override the standard value. - Can also be set via environment variable OPENAI_BASE_URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and - should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass - the resource endpoint without that suffix to ``azure_endpoint`` or pass the - full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered - from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. + 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. + 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, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Responses default. + default_headers: Default HTTP headers that are merged into each request. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role to use for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. kwargs: Other keyword parameters. + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + 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``. + Examples: .. code-block:: python @@ -2571,6 +2642,7 @@ class MyOptions(OpenAIChatOptions, total=False): super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, azure_endpoint=azure_endpoint, 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 aa78079dd2..514d0a2991 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -13,16 +13,15 @@ MutableMapping, Sequence, ) -from copy import copy from datetime import datetime, timezone from itertools import chain -from typing import Any, ClassVar, Generic, Literal, cast, overload +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework._clients import BaseChatClient from agent_framework._docstrings import apply_layered_docstring from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -59,8 +58,7 @@ from ._exceptions import OpenAIContentFilterException from ._shared import ( - DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, - get_api_key, + AzureTokenProvider, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -78,8 +76,16 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + logger = logging.getLogger("agent_framework.openai") +DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-12-01-preview" + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) @@ -179,8 +185,8 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -189,31 +195,77 @@ def __init__( instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... @overload def __init__( self, - *, model: str | None = None, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, base_url: str | None = None, - azure_endpoint: str, - api_version: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + 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``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Chat Completions default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( 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, base_url: str | None = None, azure_endpoint: str | None = None, @@ -228,26 +280,50 @@ def __init__( """Initialize a raw OpenAI Chat completion client. Keyword Args: - model: OpenAI model name. + 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``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the - resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI - key auth, either pass the resource endpoint without that suffix to - ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. - Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` - is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + 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, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Chat Completions default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - instruction_role: Role for instruction messages (e.g. ``"system"``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + 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``. """ if model_id is not None and model is None: import warnings @@ -255,89 +331,38 @@ def __init__( warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - openai_settings: dict[str, Any] = {} - use_azure_client = isinstance(async_client, AsyncAzureOpenAI) - if not async_client: - resolved_settings, use_azure_client = load_openai_service_settings( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), - default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, - ) - openai_settings = dict(resolved_settings) - - api_key_value = openai_settings.get("api_key") - if not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via the 'api_key' parameter or the " - "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." - ) - resolved_model = openai_settings.get("model") or model - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via the 'model' parameter or the " - "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if use_azure_client: - client_args.pop("api_key") - if resolved_api_version := openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): - client_args["azure_endpoint"] = resolved_azure_endpoint - if callable(resolved_api_key): - client_args["azure_ad_token_provider"] = resolved_api_key - else: - client_args["api_key"] = resolved_api_key - client_args["azure_deployment"] = resolved_model - async_client = AsyncAzureOpenAI(**client_args) - else: - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, + default_headers=default_headers, + client=async_client, + 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"), + ) - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + self.model: str = settings.get("model") or settings.get("deployment_name") or "" # Store configuration for serialization - resolved_base_url = openai_settings.get("base_url") or base_url - resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint - resolved_api_version = openai_settings.get("api_version") or api_version - self.org_id = openai_settings.get("org_id") or org_id - self.base_url = str(resolved_base_url) if resolved_base_url else None - self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None - self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None - - if instruction_role is not None: - self.instruction_role = instruction_role - + self.instruction_role = instruction_role if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] @@ -978,78 +1003,96 @@ class OpenAIChatCompletionClient( # type: ignore[misc] OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] @overload - def get_response( + def __init__( self, - messages: Sequence[Message], + model: str | None = None, *, - stream: Literal[False] = ..., - options: ChatOptions[ResponseModelBoundT], - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. - @overload - def get_response( - self, - messages: Sequence[Message], - *, - stream: Literal[False] = ..., - options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Awaitable[ChatResponse[Any]]: ... + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + """ + ... @overload - def get_response( + def __init__( self, - messages: Sequence[Message], + model: str | None = None, *, - stream: Literal[True], - options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, - ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. - @override - def get_response( - self, - messages: Sequence[Message], - *, - stream: bool = False, - options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - """Get a response from the OpenAI chat client with all standard layers enabled.""" - super_get_response = cast( - "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", - super().get_response, # type: ignore[misc] - ) - effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} - if middleware is not None: - effective_client_kwargs["middleware"] = middleware - return super_get_response( # type: ignore[no-any-return] - messages=messages, - stream=stream, - options=options, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=effective_client_kwargs, - **kwargs, - ) + 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``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Chat Completions default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + """ + ... def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, @@ -1065,33 +1108,50 @@ def __init__( """Initialize an OpenAI Chat completion client. Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - base_url: The base URL to use. If provided will override - the standard value for an OpenAI connector, the env vars or .env file value. - Can also be set via environment variable OPENAI_BASE_URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and - should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass - the resource endpoint without that suffix to ``azure_endpoint`` or pass the - full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered - from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + 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. + 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, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + default_headers: Default HTTP headers that are merged into each request. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role to use for instruction messages (for example ``"system"``). + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Chat Completions default. middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. function_invocation_configuration: Optional configuration for function invocation support. - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + 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``. Examples: .. code-block:: python @@ -1124,6 +1184,7 @@ class MyOptions(OpenAIChatCompletionOptions, total=False): super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, azure_endpoint=azure_endpoint, @@ -1137,6 +1198,74 @@ class MyOptions(OpenAIChatCompletionOptions, total=False): function_invocation_configuration=function_invocation_configuration, ) + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: ChatOptions[ResponseModelBoundT], + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[True], + options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + @override + def get_response( + self, + messages: Sequence[Message], + *, + stream: bool = False, + options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Get a response from the OpenAI chat client with all standard layers enabled.""" + super_get_response = cast( + "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", + super().get_response, # type: ignore[misc] + ) + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + if middleware is not None: + effective_client_kwargs["middleware"] = middleware + return super_get_response( # type: ignore[no-any-return] + messages=messages, + stream=stream, + options=options, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=effective_client_kwargs, + **kwargs, + ) + def _apply_openai_chat_completion_client_docstrings() -> None: """Align OpenAI chat completion client docstrings with the raw implementation.""" diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index ad959d5b39..9cb37ad4df 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -6,23 +6,31 @@ import struct import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from copy import copy -from typing import Any, ClassVar, Generic, Literal, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypedDict, overload from agent_framework._clients import BaseEmbeddingClient -from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._settings import SecretString +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails from agent_framework.observability import EmbeddingTelemetryLayer -from openai import AsyncOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI -from ._shared import OpenAISettings, get_api_key +from ._shared import AzureTokenProvider, load_openai_service_settings if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: from typing_extensions import TypeVar # type: ignore # pragma: no cover +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + +DEFAULT_AZURE_OPENAI_EMBEDDING_API_VERSION = "2024-10-21" + class OpenAIEmbeddingOptions(EmbeddingGenerationOptions, total=False): """OpenAI-specific embedding options. @@ -61,11 +69,11 @@ class RawOpenAIEmbeddingClient( INJECTABLE: ClassVar[set[str]] = {"client"} + @overload def __init__( self, *, model: str | None = None, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -73,21 +81,130 @@ def __init__( async_client: AsyncOpenAI | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: Embedding model identifier. When not provided, the constructor reads + ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + *, + model: str | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: Embedding deployment name. When not provided, the constructor reads + ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the embedding default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + def __init__( + 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, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, **kwargs: Any, ) -> None: """Initialize a raw OpenAI embedding client. Keyword Args: - model: OpenAI embedding model name. + 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``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. + 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, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use for Azure requests. When not provided explicitly, + Azure falls back to + ``AZURE_OPENAI_API_VERSION`` and then the embedding default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseEmbeddingClient``. + + Notes: + Environment resolution precedence is: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + 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``. """ if model_id is not None and model is None: import warnings @@ -95,59 +212,40 @@ def __init__( warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - if not async_client: - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - embedding_model=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - api_key_value = openai_settings.get("api_key") - resolved_model = openai_settings.get("embedding_model") or model - - # Only create a client when we have enough configuration. - # Subclasses that manage their own client pass no args here - if api_key_value: - if not resolved_model: - raise ValueError( - "OpenAI embedding model is required. " - "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_EMBEDDING_API_VERSION, + default_headers=default_headers, + client=async_client, + 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"), + ) - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + resolved_model = settings.get("model") or settings.get("deployment_name") + self.model: str | None = resolved_model.strip() if isinstance(resolved_model, str) and resolved_model else None # Store configuration for serialization - self.org_id = org_id - self.base_url = str(base_url) if base_url else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None + if use_azure_client: + self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] super().__init__(**kwargs) @@ -225,79 +323,183 @@ class OpenAIEmbeddingClient( RawOpenAIEmbeddingClient[OpenAIEmbeddingOptionsT], Generic[OpenAIEmbeddingOptionsT], ): - """OpenAI embedding client with telemetry support. - - Keyword Args: - model: The embedding model (e.g. "text-embedding-3-small"). - Can also be set via environment variable OPENAI_EMBEDDING_MODEL. - model_id: Deprecated alias for ``model``. - api_key: OpenAI API key. - Can also be set via environment variable OPENAI_API_KEY. - org_id: OpenAI organization ID. - default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client. - base_url: Custom API base URL. - otel_provider_name: Override the OpenTelemetry provider name for telemetry. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - - Examples: - .. code-block:: python + """OpenAI embedding client with telemetry support.""" - from agent_framework.openai import OpenAIEmbeddingClient + OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small - client = OpenAIEmbeddingClient() + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + base_url: str | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI embedding client. - # Or passing parameters directly - client = OpenAIEmbeddingClient( - model="text-embedding-3-small", - api_key="sk-...", - ) + Keyword Args: + model: Embedding model identifier. When not provided, the constructor reads + ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + otel_provider_name: Optional telemetry provider name override. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... - # Generate embeddings - result = await client.get_embeddings(["Hello, world!"]) - print(result[0].vector) - """ + @overload + def __init__( + self, + *, + model: str | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI embedding client. - OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + Keyword Args: + model: Embedding deployment name. When not provided, the constructor reads + ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the embedding default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + otel_provider_name: Optional telemetry provider name override. + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( self, *, model: str | None = None, api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, otel_provider_name: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize an OpenAI embedding client.""" + """Initialize an OpenAI embedding client. + + 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``. + 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, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use for Azure requests. When not provided explicitly, + Azure falls back to + ``AZURE_OPENAI_API_VERSION`` and then the embedding default. + otel_provider_name: Override the OpenTelemetry provider name. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. + + Notes: + Environment resolution precedence is: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + 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``. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIEmbeddingClient + + # Using environment variables + # Set OPENAI_API_KEY=sk-... + # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small + client = OpenAIEmbeddingClient() + + # Or passing OpenAI parameters directly + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key="sk-...", + ) + + # Or using Azure OpenAI with an Azure credential + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + azure_endpoint="https://example-resource.openai.azure.com/", + credential=my_azure_credential, + ) + """ super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, default_headers=default_headers, async_client=async_client, + otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - if otel_provider_name is not None: - self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] - - # Validate that the client was created successfully (from explicit args or env vars) - if self.client is None: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - if not self.model: - raise ValueError( - "OpenAI embedding model is required. " - "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." - ) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index c3c280d950..feb30cc7d5 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -3,19 +3,18 @@ from __future__ import annotations import logging -import os import sys from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from copy import copy -from typing import Any, ClassVar, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, cast import openai from agent_framework._serialization import SerializationMixin from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent from agent_framework._tools import FunctionTool -from dotenv import dotenv_values -from openai import AsyncOpenAI, AsyncStream, _legacy_response # type: ignore +from agent_framework.exceptions import SettingNotFoundError +from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore from openai.types import Completion from openai.types.audio import Transcription from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -24,10 +23,21 @@ from openai.types.responses.response_stream_event import ResponseStreamEvent from packaging.version import parse +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + logger: logging.Logger = logging.getLogger("agent_framework.openai") -DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-10-21" -DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" +AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # noqa: S105 # nosec B105 RESPONSE_TYPE = Union[ @@ -43,12 +53,7 @@ _legacy_response.HttpxBinaryResponseContent, ] -OPTION_TYPE = dict[str, Any] - -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover +AzureTokenProvider = Callable[[], str | Awaitable[str]] def _check_openai_version_for_callable_api_key() -> None: @@ -92,6 +97,10 @@ class OpenAISettings(TypedDict, total=False): Can be set via environment variable OPENAI_MODEL. embedding_model: The OpenAI embedding model to use, for example, text-embedding-3-small. Can be set via environment variable OPENAI_EMBEDDING_MODEL. + chat_model: The OpenAI chat-completions model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_CHAT_MODEL. + responses_model: The OpenAI responses model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_RESPONSES_MODEL. Examples: .. code-block:: python @@ -110,122 +119,232 @@ class OpenAISettings(TypedDict, total=False): settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env") """ - api_key: SecretString | Callable[[], str | Awaitable[str]] | None + api_key: SecretString | None base_url: str | None org_id: str | None model: str | None embedding_model: str | None - azure_endpoint: str | None + chat_model: str | None + responses_model: str | None + + +class AzureOpenAISettings(TypedDict, total=False): + """Azure OpenAI environment settings.""" + + 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 api_version: str | None -def _load_dotenv_values(*, env_file_path: str | None, env_file_encoding: str | None) -> dict[str, str]: - """Load dotenv values for non-standard environment variable aliases.""" - if env_file_path is None or not os.path.exists(env_file_path): - return {} +OpenAIModelSettingName = Literal["model", "embedding_model", "chat_model", "responses_model"] +AzureDeploymentSettingName = Literal[ + "deployment_name", "embedding_deployment_name", "chat_deployment_name", "responses_deployment_name" +] - raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=env_file_encoding or "utf-8") - return {key: value for key, value in raw_dotenv_values.items() if value is not None} +OPENAI_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { + "model": "OPENAI_MODEL", + "embedding_model": "OPENAI_EMBEDDING_MODEL", + "chat_model": "OPENAI_CHAT_MODEL", + "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", +} -def _get_setting_from_alias( - name: str, - *, - dotenv_values_by_name: Mapping[str, str], + +def _resolve_named_setting( + settings: Mapping[str, Any], + fields: Sequence[OpenAIModelSettingName | AzureDeploymentSettingName], ) -> str | None: - """Resolve a setting from an explicit env-var alias.""" - if dotenv_value := dotenv_values_by_name.get(name): - return dotenv_value - return os.getenv(name) + """Return the first populated value from ``fields``.""" + for field in fields: + value = settings.get(field) + if isinstance(value, str) and value: + return value + return None + + +def _join_env_names(env_names: Sequence[str]) -> str: + """Format env var names for user-facing error messages.""" + return ", ".join(f"'{env_name}'" for env_name in env_names) def load_openai_service_settings( *, model: str | None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None, + credential: AzureCredentialTypes | AzureTokenProvider | None, org_id: str | None, base_url: str | None, - azure_endpoint: str | None, + endpoint: str | None, api_version: str | None, + default_azure_api_version: str, + default_headers: Mapping[str, str] | None = None, + client: AsyncOpenAI | None = None, env_file_path: str | None, env_file_encoding: str | None, - azure_model_env_vars: Sequence[str], - default_azure_api_version: str, -) -> tuple[OpenAISettings, bool]: + openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",), + azure_deployment_fields: Sequence[AzureDeploymentSettingName] = ("deployment_name",), + responses_mode: bool = False, +) -> tuple[dict[str, Any], AsyncOpenAI, bool]: """Load OpenAI settings, including Azure OpenAI aliases. - The generic OpenAI clients primarily read from ``OPENAI_*`` variables. When an - ``AZURE_OPENAI_ENDPOINT`` (or ``AZURE_OPENAI_BASE_URL``) is available and no - explicit OpenAI base URL is configured, this helper switches to Azure-specific - environment variables for endpoint, API key, model deployment, and API version. + The generic OpenAI clients primarily read from ``OPENAI_*`` variables. Azure-specific + environment variables are used only when an explicit Azure signal is present + (``endpoint`` or ``credential``) or when no explicit + OpenAI API key is available. """ - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - model=model, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + # Merge APP_INFO into the headers + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + api_key_callable = api_key if callable(api_key) else None + api_key_str = api_key if not callable(api_key) else None + azure_client = isinstance(client, AsyncAzureOpenAI) + use_azure = azure_client or endpoint is not None or credential is not None + checked_openai = False + if not use_azure: + openai_settings_kwargs: dict[str, Any] = { + "api_key": api_key_str, + "org_id": org_id, + "base_url": base_url, + "env_file_path": env_file_path, + "env_file_encoding": env_file_encoding, + } + if model is not None: + openai_settings_kwargs[openai_model_fields[0]] = model + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + **openai_settings_kwargs, + ) + if resolved_model := _resolve_named_setting(openai_settings, openai_model_fields): + openai_settings["model"] = resolved_model + if client: + return openai_settings, client, False # type: ignore[return-value] + if openai_settings.get("api_key") is not None or api_key_callable is not None: + resolved_model = _resolve_named_setting(openai_settings, openai_model_fields) + if not resolved_model: + raise SettingNotFoundError( + "Model must be specified via the 'model' parameter or the " + f"{_join_env_names([OPENAI_MODEL_ENV_VARS[field] for field in openai_model_fields])} " + "environment variable." + ) - dotenv_values_by_name = _load_dotenv_values( + client_args: dict[str, Any] = { + "api_key": api_key_callable + if api_key_callable is not None + else openai_settings["api_key"].get_secret_value(), # type: ignore[reportOptionalMemberAccess, union-attr] + "organization": openai_settings.get("org_id"), + "default_headers": merged_headers, + } + if base_url := openai_settings.get("base_url"): + client_args["base_url"] = base_url + return openai_settings, AsyncOpenAI(**client_args), False # type: ignore[return-value] + checked_openai = True + azure_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + required_fields=None if client else [("base_url", "endpoint")], + api_key=api_key_str, + endpoint=endpoint, + base_url=base_url, + api_version=api_version or default_azure_api_version, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - - resolved_azure_endpoint = azure_endpoint - resolved_azure_base_url: str | None = None - if not openai_settings.get("base_url"): - if resolved_azure_endpoint is None: - resolved_azure_endpoint = _get_setting_from_alias( - "AZURE_OPENAI_ENDPOINT", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_endpoint is None: - resolved_azure_base_url = _get_setting_from_alias( - "AZURE_OPENAI_BASE_URL", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_base_url is not None: - openai_settings["base_url"] = resolved_azure_base_url - - use_azure_client = resolved_azure_endpoint is not None or resolved_azure_base_url is not None - if resolved_azure_endpoint is not None: - openai_settings["azure_endpoint"] = resolved_azure_endpoint - - if use_azure_client: - if api_key is None: - resolved_azure_api_key = _get_setting_from_alias( - "AZURE_OPENAI_API_KEY", - dotenv_values_by_name=dotenv_values_by_name, + if model is not None: + azure_settings[azure_deployment_fields[0]] = model + client_args = {} + resolved_azure_deployment = _resolve_named_setting(azure_settings, azure_deployment_fields) + if resolved_azure_deployment 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 + else: + deployment_env_guidance = _join_env_names([ + AZURE_DEPLOYMENT_ENV_VARS[field] for field in azure_deployment_fields + ]) + has_azure_configuration = ( + client is not None + or azure_settings.get("endpoint") is not None + or azure_settings.get("base_url") is not None + ) + if checked_openai and not has_azure_configuration: + raise SettingNotFoundError( + "OpenAI credentials are required. Provide the 'api_key' parameter or set 'OPENAI_API_KEY'. " + "To use Azure OpenAI instead, pass 'azure_endpoint' or set 'AZURE_OPENAI_ENDPOINT' or " + "'AZURE_OPENAI_BASE_URL'." ) - if resolved_azure_api_key is not None: - openai_settings["api_key"] = SecretString(resolved_azure_api_key) - - if model is None: - for env_var_name in azure_model_env_vars: - resolved_model = _get_setting_from_alias( - env_var_name, - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_model is not None: - openai_settings["model"] = resolved_model - break - - if api_version is not None: - openai_settings["api_version"] = api_version + raise SettingNotFoundError( + "Azure OpenAI client requires a deployment name, which can be provided via the 'model' parameter, " + f"or the {deployment_env_guidance} environment variable." + ) + if client: + return azure_settings, client, True # type: ignore[return-value] + client_args["default_headers"] = merged_headers + if endpoint := azure_settings.get("endpoint"): + if responses_mode: + client_args["base_url"] = f"{endpoint.rstrip('/')}/openai/v1/" else: - resolved_api_version = _get_setting_from_alias( - "AZURE_OPENAI_API_VERSION", - dotenv_values_by_name=dotenv_values_by_name, - ) - openai_settings["api_version"] = resolved_api_version or default_azure_api_version + client_args["azure_endpoint"] = endpoint + if base_url := azure_settings.get("base_url"): + client_args["base_url"] = base_url + if api_key := azure_settings.get("api_key"): + client_args["api_key"] = api_key.get_secret_value() + if api_key_callable: + client_args["api_key"] = api_key_callable + if api_version := azure_settings.get("api_version"): + client_args["api_version"] = api_version + if credential: + client_args["azure_ad_token_provider"] = _resolve_azure_credential_to_token_provider(credential) + if "api_key" not in client_args and "azure_ad_token_provider" not in client_args: + raise SettingNotFoundError( + "Azure OpenAI client requires either an API key or an Azure AD token provider." + " This can be provided either as a callable api_key or via the credential parameter." + ) + return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value] + + +def _resolve_azure_credential_to_token_provider( + credential: AzureCredentialTypes | AzureTokenProvider, +) -> AzureTokenProvider: + """Resolve an Azure credential or token provider for Azure OpenAI auth.""" + if callable(credential): + return credential - return openai_settings, use_azure_client + try: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + from azure.identity import get_bearer_token_provider + from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Azure credential auth requires the 'azure-identity' package. Install it with: pip install azure-identity" + ) from exc + + if isinstance(credential, AsyncTokenCredential): + return get_async_bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) + if isinstance(credential, TokenCredential): + return get_bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) # type: ignore[arg-type] + raise ValueError( + "The 'credential' parameter must be an Azure TokenCredential, AsyncTokenCredential, or a " + "callable token provider." + ) def maybe_append_azure_endpoint_guidance(message: str, *, azure_endpoint: str | None) -> str: diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index 1ef52baf81..34ea878a19 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -43,6 +43,8 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_ORG_ID", "OPENAI_MODEL", "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", @@ -53,6 +55,9 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "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_API_VERSION", ], @@ -97,6 +102,8 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_ORG_ID", "OPENAI_MODEL", "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", @@ -107,6 +114,9 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "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_API_VERSION", ], @@ -114,6 +124,9 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic 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_API_KEY": "test_api_key", "AZURE_OPENAI_API_VERSION": "2024-12-01-preview", diff --git a/python/packages/openai/tests/openai/test_assistant_provider.py b/python/packages/openai/tests/openai/test_assistant_provider.py index c05ea950a6..df811f6c37 100644 --- a/python/packages/openai/tests/openai/test_assistant_provider.py +++ b/python/packages/openai/tests/openai/test_assistant_provider.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import os from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock @@ -750,64 +749,3 @@ def test_merge_single_user_tool(self, mock_async_openai: MagicMock) -> None: # endregion - -# region Integration Tests - -skip_if_openai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), - reason="No real OPENAI_API_KEY provided; skipping integration tests.", -) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -class TestOpenAIAssistantProviderIntegration: - """Integration tests requiring real OpenAI API.""" - - async def test_create_and_run_agent(self) -> None: - """End-to-end test of creating and running an agent.""" - provider = OpenAIAssistantProvider() - - agent = await provider.create_agent( - name="IntegrationTestAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant. Respond briefly.", - ) - - try: - result = await agent.run("Say 'hello' and nothing else.") - result_text = str(result) - assert "hello" in result_text.lower() - finally: - # Clean up the assistant - await provider._client.beta.assistants.delete(agent.id) # type: ignore[reportPrivateUsage, union-attr] - - async def test_create_agent_with_function_tools_integration(self) -> None: - """Integration test with function tools.""" - provider = OpenAIAssistantProvider() - - @tool(approval_mode="never_require") - def get_current_time() -> str: - """Get the current time.""" - from datetime import datetime - - return datetime.now().strftime("%H:%M") - - agent = await provider.create_agent( - name="TimeAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant.", - tools=[get_current_time], - ) - - try: - result = await agent.run("What time is it? Use the get_current_time function.") - result_text = str(result) - # The response should contain time information - assert ":" in result_text or "time" in result_text.lower() - finally: - await provider._client.beta.assistants.delete(agent.id) # type: ignore[reportPrivateUsage, union-attr] - - -# endregion 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 897fe5f913..3c09839594 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -28,6 +28,7 @@ from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, + SettingNotFoundError, ) from openai import BadRequestError from openai.types.responses.response_reasoning_item import Summary @@ -109,6 +110,14 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(openai_responses_client, SupportsChatGetResponse) +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") + + openai_responses_client = OpenAIChatClient() + + assert openai_responses_client.model == "test_responses_model_id" + + def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): @@ -143,7 +152,7 @@ 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: - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatClient() @@ -151,7 +160,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatClient( model=model_id, ) @@ -203,34 +212,56 @@ async def test_get_response_with_invalid_input() -> None: async def test_get_response_with_all_parameters() -> None: - """Test get_response with all possible parameters to cover parameter handling logic.""" + """Test request preparation with a comprehensive parameter set.""" client = OpenAIChatClient(model="test-model", api_key="test-key") - # Test with comprehensive parameter set - should fail due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="Test message")], - options={ - "include": ["message.output_text.logprobs"], - "instructions": "You are a helpful assistant", - "max_tokens": 100, - "parallel_tool_calls": True, - "model": "gpt-4", - "previous_response_id": "prev-123", - "reasoning": {"chain_of_thought": "enabled"}, - "service_tier": "auto", - "response_format": OutputStruct, - "seed": 42, - "store": True, - "temperature": 0.7, - "tool_choice": "auto", - "tools": [get_weather], - "top_p": 0.9, - "user": "test-user", - "truncation": "auto", - "timeout": 30.0, - "additional_properties": {"custom": "value"}, - }, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Test message")], + options={ + "include": ["message.output_text.logprobs"], + "instructions": "You are a helpful assistant", + "max_tokens": 100, + "parallel_tool_calls": True, + "model": "gpt-4", + "previous_response_id": "prev-123", + "reasoning": {"chain_of_thought": "enabled"}, + "service_tier": "auto", + "response_format": OutputStruct, + "seed": 42, + "store": True, + "temperature": 0.7, + "tool_choice": "auto", + "tools": [get_weather], + "top_p": 0.9, + "user": "test-user", + "truncation": "auto", + "timeout": 30.0, + "additional_properties": {"custom": "value"}, + }, + ) + + assert run_options["include"] == ["message.output_text.logprobs"] + assert run_options["max_output_tokens"] == 100 + assert run_options["parallel_tool_calls"] is True + assert run_options["model"] == "gpt-4" + assert run_options["previous_response_id"] == "prev-123" + assert run_options["reasoning"] == {"chain_of_thought": "enabled"} + assert run_options["service_tier"] == "auto" + assert run_options["text_format"] is OutputStruct + assert run_options["store"] is True + assert run_options["temperature"] == 0.7 + assert run_options["tool_choice"] == "auto" + assert run_options["top_p"] == 0.9 + assert run_options["user"] == "test-user" + assert run_options["truncation"] == "auto" + assert run_options["timeout"] == 30.0 + assert run_options["additional_properties"] == {"custom": "value"} + assert len(run_options["tools"]) == 1 + assert run_options["tools"][0]["type"] == "function" + assert run_options["tools"][0]["name"] == "get_weather" + assert run_options["input"][0]["role"] == "system" + assert run_options["input"][0]["content"][0]["text"] == "You are a helpful assistant" + assert run_options["input"][1]["role"] == "user" + assert run_options["input"][1]["content"][0]["text"] == "Test message" @pytest.mark.asyncio @@ -248,12 +279,13 @@ async def test_web_search_tool_with_location() -> None: } ) - # Should raise an authentication error due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="What's the weather?")], - options={"tools": [web_search_tool], "tool_choice": "auto"}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="What's the weather?")], + options={"tools": [web_search_tool], "tool_choice": "auto"}, + ) + + assert run_options["tools"] == [web_search_tool] + assert run_options["tool_choice"] == "auto" async def test_code_interpreter_tool_variations() -> None: @@ -263,20 +295,22 @@ async def test_code_interpreter_tool_variations() -> None: # Test code interpreter using static method code_tool = OpenAIChatClient.get_code_interpreter_tool() - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message("user", ["Run some code"])], - options={"tools": [code_tool]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, + ) + + assert run_options["tools"] == [code_tool] # Test code interpreter with files using static method code_tool_with_files = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="Process these files")], - options={"tools": [code_tool_with_files]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Process these files")], + options={"tools": [code_tool_with_files]}, + ) + + assert run_options["tools"] == [code_tool_with_files] async def test_content_filter_exception() -> None: @@ -300,23 +334,23 @@ async def test_content_filter_exception() -> None: @pytest.mark.asyncio async def test_hosted_file_search_tool_validation() -> None: - """Test get_response HostedFileSearchTool validation.""" + """Test HostedFileSearchTool validation and request preparation.""" client = OpenAIChatClient(model="test-model", api_key="test-key") # Test file search tool with vector store IDs file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) - # Test using file search tool - may raise various exceptions depending on API response - with pytest.raises((ValueError, ChatClientInvalidRequestException, ChatClientException)): - await client.get_response( - messages=[Message("user", ["Test"])], - options={"tools": [file_search_tool]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, + ) + + assert run_options["tools"] == [file_search_tool] async def test_chat_message_parsing_with_function_calls() -> None: - """Test get_response message preparation with function call and result content types in conversation flow.""" + """Test message preparation with function call and function result content.""" client = OpenAIChatClient(model="test-model", api_key="test-key") # Create messages with function call and result content @@ -335,9 +369,27 @@ async def test_chat_message_parsing_with_function_calls() -> None: Message(role="tool", contents=[function_result]), ] - # This should exercise the message parsing logic - will fail due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response(messages=messages) + prepared_messages = client._prepare_messages_for_openai(messages) + + assert prepared_messages == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Call a function"}], + }, + { + "call_id": "test-call-id", + "id": "fc_test-fc-id", + "type": "function_call", + "name": "test_function", + "arguments": '{"param": "value"}', + }, + { + "call_id": "test-call-id", + "type": "function_call_output", + "output": "Function executed successfully", + }, + ] async def test_response_format_parse_path() -> None: @@ -3043,8 +3095,6 @@ async def get_api_key() -> str: "option_name,option_value,needs_validation", [ # Simple ChatOptions - just verify they don't fail - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -3057,7 +3107,6 @@ async def get_api_key() -> str: # OpenAIChatOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), # Complex options requiring output validation @@ -3113,70 +3162,56 @@ async def test_integration_options( they don't cause failures. Options marked with needs_validation also check that the feature actually works correctly. """ - openai_responses_client = OpenAIChatClient() + client = OpenAIChatClient() # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - openai_responses_client.function_invocation_configuration["max_iterations"] = 2 + client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] - elif option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] + # Prepare test message + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + # Use prompt that works well with structured output + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] - # Build options dict - options: dict[str, Any] = {option_name: option_value} + # Build options dict + options: dict[str, Any] = {option_name: option_value} - # Add tools if testing tool_choice to avoid errors - if option_name.startswith("tool_choice"): - options["tools"] = [get_weather] + # Add tools if testing tool_choice to avoid errors + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] - if streaming: - # Test streaming mode - response_stream = openai_responses_client.get_response( - stream=True, - messages=messages, - options=options, - ) + # Test streaming mode + response = await client.get_response(stream=True, messages=messages, options=options).get_final_response() - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await openai_responses_client.get_response( - messages=messages, - options=options, - ) + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - 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() + # Validate based on option type + if needs_validation: + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" + elif option_name.startswith("response_format"): + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + 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() @pytest.mark.timeout(300) @@ -3186,53 +3221,24 @@ async def test_integration_options( async def test_integration_web_search() -> None: client = OpenAIChatClient(model="gpt-5") - for streaming in [False, True]: - # Use static method for web search tool - web_search_tool = OpenAIChatClient.get_web_search_tool() - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [web_search_tool], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( - user_location={"country": "US", "city": "Seattle"}, - ) - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [web_search_tool_with_location], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + # Test that the client will use the web search tool with location + web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( + user_location={"country": "US", "city": "Seattle"}, + ) + content = { + "messages": [ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + "options": { + "tool_choice": "auto", + "tools": [web_search_tool_with_location], + }, + } + response = await client.get_response(stream=True, **content).get_final_response() + assert response.text is not None @pytest.mark.skip( @@ -3351,7 +3357,6 @@ def get_test_image() -> Content: assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" -@pytest.mark.timeout(300) @pytest.mark.flaky @pytest.mark.integration @skip_if_openai_integration_tests_disabled @@ -3363,14 +3368,11 @@ async def test_integration_agent_replays_local_tool_history_without_stale_fc_id( async def search_hotels(city: Annotated[str, "The city to search for hotels in"]) -> str: return f"The only hotel option in {city} is {hotel_code}." - client = OpenAIChatClient() + # override with model that does not do reasoning by default + client = OpenAIChatClient(model="gpt-5.4") client.function_invocation_configuration["max_iterations"] = 2 - agent = Agent( - client=client, - tools=[search_hotels], - default_options={"store": False}, - ) + agent = Agent(client=client, tools=[search_hotels], default_options={"store": False}) session = agent.create_session() first_response = await agent.run( 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 8646f2d959..918fe98767 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 @@ -4,12 +4,16 @@ import json import os +from functools import wraps from pathlib import Path from typing import Any +from unittest.mock import MagicMock, patch import pytest from agent_framework import Agent, AgentResponse, ChatResponse, Content, Message, SupportsChatGetResponse, tool -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI from pydantic import BaseModel from pytest import param @@ -20,11 +24,40 @@ 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_DEPLOYMENT_NAME", "") == "", + or ( + os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), reason="No real Azure OpenAI endpoint or responses deployment provided; skipping integration tests.", ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> 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", "" + ) + 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}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + class OutputStruct(BaseModel): """A structured output for testing purposes.""" @@ -32,18 +65,6 @@ class OutputStruct(BaseModel): weather: str | None = None -def _create_azure_openai_chat_client( - *, - api_key: Any = None, -) -> OpenAIChatClient: - return OpenAIChatClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - ) - - async def create_vector_store(client: OpenAIChatClient) -> tuple[str, Content]: """Create a vector store with sample documents for testing.""" file = await client.client.files.create( @@ -79,30 +100,117 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_openai_chat_client() + client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(client, SupportsChatGetResponse) 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"] - assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + assert client.azure_endpoint.startswith(azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]) 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_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_api_version_alone_does_not_override_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient(api_version="2024-10-21") + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] +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) + + client = OpenAIChatClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + 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.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + 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("OPENAI_RESPONSES_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatClient() + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIChatClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") + + @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_openai_chat_client() + client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] - assert client.api_version == "preview" + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.api_version is not None def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: @@ -123,8 +231,6 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ @pytest.mark.parametrize( "option_name,option_value,needs_validation", [ - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -136,7 +242,6 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ param("tool_choice", "none", True, id="tool_choice_none"), param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), param("tools", [get_weather], True, id="tools_function"), @@ -174,15 +279,14 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ ), ], ) +@_with_azure_openai_debug() async def test_integration_options( option_name: str, option_value: Any, needs_validation: bool, ) -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: @@ -233,64 +337,34 @@ async def test_integration_options( @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_web_search() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [OpenAIChatClient.get_web_search_tool()], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + response = await client.get_response( + messages=[ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + options={ + "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], + }, + stream=True, + ).get_final_response() + assert isinstance(response, ChatResponse) + assert response.text is not None @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) file_id, vector_store = await create_vector_store(client) try: response = await client.get_response( @@ -310,11 +384,10 @@ async def test_integration_client_file_search() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search_streaming() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) file_id, vector_store = await create_vector_store(client) try: response_stream = client.get_response( @@ -336,11 +409,10 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_mcp_tool() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], options={ @@ -361,11 +433,10 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")], @@ -381,14 +452,13 @@ async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_existing_session() -> None: async with AzureCliCredential() as credential: preserved_session = None async with Agent( - client=_create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as first_agent: session = first_agent.create_session() @@ -403,9 +473,7 @@ async def test_integration_client_agent_existing_session() -> None: if preserved_session: async with Agent( - client=_create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: second_response = await second_agent.run("What is my hobby?", session=preserved_session) @@ -418,6 +486,7 @@ async def test_integration_client_agent_existing_session() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_tool_rich_content_image() -> None: image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" image_bytes = image_path.read_bytes() @@ -428,9 +497,7 @@ def get_test_image() -> Content: return Content.from_data(data=image_bytes, media_type="image/jpeg") async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: 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 391432958f..deee60ac7a 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 @@ -13,7 +13,7 @@ SupportsChatGetResponse, tool, ) -from agent_framework.exceptions import ChatClientException +from agent_framework.exceptions import ChatClientException, SettingNotFoundError from openai import BadRequestError from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage @@ -37,6 +37,14 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) +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") + + open_ai_chat_completion = OpenAIChatCompletionClient() + + assert open_ai_chat_completion.model == "test_chat_model_id" + + def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): @@ -93,7 +101,7 @@ 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: - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient() @@ -101,7 +109,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient( model=model_id, ) @@ -1480,71 +1488,61 @@ async def test_integration_options( # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + # Use prompt that works well with structured output + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response( + messages=messages, + stream=True, + options=options, + ).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.messages is not None + if not option_name.startswith("tool_choice") and ( + (isinstance(option_value, str) and option_value != "required") + or (isinstance(option_value, dict) and option_value.get("mode") != "required") + ): + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name.startswith("tool_choice"): - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.messages is not None - if not option_name.startswith("tool_choice") and ( - (isinstance(option_value, str) and option_value != "required") - or (isinstance(option_value, dict) and option_value.get("mode") != "required") - ): - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - 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() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + 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() @pytest.mark.flaky 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 148fcb68b9..f9edab227a 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 @@ -3,7 +3,9 @@ from __future__ import annotations import os -from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any +from unittest.mock import MagicMock, patch import pytest from agent_framework import ( @@ -16,7 +18,9 @@ SupportsChatGetResponse, tool, ) -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI from agent_framework_openai import OpenAIChatCompletionClient @@ -25,21 +29,37 @@ 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_DEPLOYMENT_NAME", "") == "", + or ( + os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "") == "" and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), reason="No real Azure OpenAI endpoint or chat deployment provided; skipping integration tests.", ) -def _create_azure_chat_completion_client( - *, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, -) -> OpenAIChatCompletionClient: - return OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> 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", "" + ) + 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}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator @tool(approval_mode="never_require") @@ -60,9 +80,9 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_chat_completion_client() + client = OpenAIChatCompletionClient(azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT")) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -73,18 +93,86 @@ 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_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_API_VERSION", "preview") - client = _create_azure_chat_completion_client() +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatCompletionClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatCompletionClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +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) + + client = OpenAIChatCompletionClient() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] - assert client.api_version == "2024-10-21" + 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.setenv("OPENAI_CHAT_MODEL", "test_chat_model") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + 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("OPENAI_CHAT_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatCompletionClient() + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIChatCompletionClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: @@ -102,11 +190,10 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_response() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) assert isinstance(client, SupportsChatGetResponse) messages = [ @@ -134,11 +221,10 @@ async def test_azure_openai_chat_completion_client_response() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_response_tools() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="who are Emily and David?")], @@ -153,11 +239,10 @@ async def test_azure_openai_chat_completion_client_response_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_streaming() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( messages=[ @@ -190,11 +275,10 @@ async def test_azure_openai_chat_completion_client_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_streaming_tools() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( messages=[Message(role="user", text="who are Emily and David?")], @@ -215,13 +299,12 @@ async def test_azure_openai_chat_completion_client_streaming_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), ) as agent, ): response = await agent.run("Please respond with exactly: 'This is a response test.'") @@ -234,20 +317,14 @@ async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() -> None: async with ( AzureCliCredential() as credential, - Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), - ) as agent, + Agent(client=OpenAIChatCompletionClient(credential=credential)) as agent, ): full_text = "" - async for chunk in agent.run( - "Please respond with exactly: 'This is a streaming response test.'", - stream=True, - ): + async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True): assert isinstance(chunk, AgentResponseUpdate) if chunk.text: full_text += chunk.text @@ -258,13 +335,12 @@ async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() - @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_session_persistence() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as agent, ): @@ -281,14 +357,13 @@ async def test_azure_openai_chat_completion_client_agent_session_persistence() - @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_existing_session() -> None: async with AzureCliCredential() as credential: preserved_session = None async with Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as first_agent: session = first_agent.create_session() @@ -299,9 +374,7 @@ async def test_azure_openai_chat_completion_client_agent_existing_session() -> N if preserved_session: async with Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: second_response = await second_agent.run("What is my name?", session=preserved_session) @@ -314,13 +387,12 @@ async def test_azure_openai_chat_completion_client_agent_existing_session() -> N @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_chat_completion_client_agent_level_tool_persistence() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], ) as agent, 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 7117040ffc..4ef39697d6 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework.exceptions import SettingNotFoundError from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage @@ -32,13 +33,6 @@ def _make_openai_response( ) -@pytest.fixture -def openai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Set up environment variables for OpenAI embedding client.""" - monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") - monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") - - # --- OpenAI unit tests --- @@ -50,24 +44,39 @@ def test_openai_construction_with_explicit_params() -> None: assert client.model == "text-embedding-3-small" -def test_openai_construction_from_env(openai_unit_test_env: None) -> None: +def test_openai_construction_from_env(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() + assert client.model == openai_unit_test_env["OPENAI_EMBEDDING_MODEL"] + + +def test_with_callable_api_key() -> None: + """Test OpenAIEmbeddingClient initialization with callable API key.""" + + async def get_api_key() -> str: + return "test-api-key-123" + + client = OpenAIEmbeddingClient(model="text-embedding-3-small", api_key=get_api_key) + assert client.model == "text-embedding-3-small" + assert client.client is not None -def test_openai_construction_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - with pytest.raises(ValueError, match="API key is required"): +@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) +def test_openai_construction_without_openai_or_azure_config_raises_clear_error( + openai_unit_test_env: dict[str, str], +) -> None: + with pytest.raises(SettingNotFoundError): OpenAIEmbeddingClient(model="text-embedding-3-small") -def test_openai_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) - with pytest.raises(ValueError, match="embedding model is required"): - OpenAIEmbeddingClient(api_key="test-key") +@pytest.mark.parametrize("exclude_list", [["OPENAI_EMBEDDING_MODEL"]], indirect=True) +def test_openai_construction_falls_back_to_openai_model(openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIEmbeddingClient() + + assert client.model == openai_unit_test_env["OPENAI_MODEL"] -async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: +async def test_openai_get_embeddings(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response( embeddings=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], ) @@ -85,7 +94,7 @@ async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: assert result[0].dimensions == 3 -async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: +async def test_openai_get_embeddings_usage(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response( embeddings=[[0.1]], prompt_tokens=10, @@ -103,7 +112,7 @@ async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: assert result.usage["total_token_count"] == 10 -async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) -> None: +async def test_openai_options_passthrough_dimensions(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response(embeddings=[[0.1]]) client = OpenAIEmbeddingClient() client.client = MagicMock() @@ -118,7 +127,7 @@ async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) assert result.options is options -async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: None) -> None: +async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response(embeddings=[[0.1]]) client = OpenAIEmbeddingClient() client.client = MagicMock() @@ -132,7 +141,7 @@ async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: assert call_kwargs["encoding_format"] == "base64" -async def test_openai_base64_decoding(openai_unit_test_env: None) -> None: +async def test_openai_base64_decoding(openai_unit_test_env: dict[str, str]) -> None: import base64 import struct @@ -176,7 +185,7 @@ async def test_openai_error_when_no_model_id() -> None: await client.get_embeddings(["test"]) -async def test_openai_empty_values_returns_empty(openai_unit_test_env: None) -> None: +async def test_openai_empty_values_returns_empty(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() client.client = MagicMock() client.client.embeddings = 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 new file mode 100644 index 0000000000..3cf62a064d --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py @@ -0,0 +1,250 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from functools import wraps +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential +from openai import AsyncAzureOpenAI + +from agent_framework_openai import OpenAIEmbeddingClient, OpenAIEmbeddingOptions + +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", "") == "" + ), + reason="No real Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", +) + + +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> 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", "" + ) + 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}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _get_azure_embedding_deployment_name() -> str: + return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + + +def _create_azure_embedding_client( + *, + api_key: str | None = None, + credential: AsyncTokenCredential | None = None, +) -> OpenAIEmbeddingClient: + resolved_api_key = ( + api_key if api_key is not None else None if credential is not None else os.environ["AZURE_OPENAI_API_KEY"] + ) + return OpenAIEmbeddingClient( + model=_get_azure_embedding_deployment_name(), + api_key=resolved_api_key, + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + +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 isinstance(client.client, AsyncAzureOpenAI) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + + +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 isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +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) + + client = OpenAIEmbeddingClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + 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.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + 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("OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIEmbeddingClient() + + +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient() + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_api_version_alone_does_not_override_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient(api_version="2024-10-21") + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIEmbeddingClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +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.api_version == "2024-10-21" + + +def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1") + + client = OpenAIEmbeddingClient() + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + result = await client.get_embeddings(["hello world"]) + + assert len(result) == 1 + assert isinstance(result[0].vector, list) + assert len(result[0].vector) > 0 + assert all(isinstance(v, float) for v in result[0].vector) + assert result[0].model is not None + assert result.usage is not None + assert result.usage["input_token_count"] > 0 + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings_multiple() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + result = await client.get_embeddings(["hello", "world", "test"]) + + assert len(result) == 3 + dims = [len(embedding.vector) for embedding in result] + assert all(dimension == dims[0] for dimension in dims) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings_with_dimensions() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) + + assert len(result) == 1 + assert len(result[0].vector) == 256 diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py new file mode 100644 index 0000000000..b69feb7314 --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential + +from agent_framework_openai._shared import AZURE_OPENAI_TOKEN_SCOPE, _resolve_azure_credential_to_token_provider + + +class _AsyncTokenCredentialStub(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + +class _TokenCredentialStub(TokenCredential): + def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + +def test_resolve_azure_async_credential_wraps_provider() -> None: + credential = _AsyncTokenCredentialStub() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + resolved = _resolve_azure_credential_to_token_provider(credential) + + assert resolved is token_provider + mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE) + + +def test_resolve_azure_sync_credential_wraps_provider() -> None: + credential = _TokenCredentialStub() + token_provider = MagicMock() + + with patch("azure.identity.get_bearer_token_provider", return_value=token_provider) as mock_provider: + resolved = _resolve_azure_credential_to_token_provider(credential) + + assert resolved is token_provider + mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE) + + +def test_resolve_azure_callable_token_provider_passthrough() -> None: + token_provider = MagicMock() + + assert _resolve_azure_credential_to_token_provider(token_provider) is token_provider + + +def test_resolve_azure_invalid_credential_raises() -> None: + with pytest.raises(ValueError, match="credential"): + _resolve_azure_credential_to_token_provider(object()) # type: ignore[arg-type] diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index e03d532812..6650e510a9 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -57,8 +57,8 @@ Depending on the selected client, set the appropriate environment variables: **For OpenAI clients:** - `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model for `openai_chat` and `openai_assistants` -- `OPENAI_RESPONSES_MODEL_ID`: The OpenAI model for `openai_responses` +- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_chat` and `openai_assistants` +- `OPENAI_RESPONSES_MODEL`: The OpenAI model for `openai_responses` **For Anthropic client (`anthropic`):** - `ANTHROPIC_API_KEY`: Your Anthropic API key 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 8cd8947ef6..98b7d66f88 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 @@ -4,8 +4,9 @@ import os from agent_framework import Agent -from agent_framework.azure import AzureAISearchContextProvider, AzureOpenAIEmbeddingClient +from agent_framework.azure import AzureAISearchContextProvider from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIEmbeddingClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -31,8 +32,8 @@ - AZURE_SEARCH_INDEX_NAME: Your search index name - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") - - AZURE_OPENAI_EMBEDDING_MODEL_ID: (Optional) Your embedding model for hybrid search (e.g., "text-embedding-3-small") - - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using an OpenAI embedding model for hybrid search + - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: (Optional) Your Azure OpenAI embedding deployment for hybrid search + - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using Azure OpenAI embeddings """ # Sample queries to demonstrate RAG @@ -55,13 +56,13 @@ async def main() -> None: project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") - embedding_model = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL_ID", "text-embedding-3-small") + embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") embedding_client = None - if openai_endpoint and embedding_model: - embedding_client = AzureOpenAIEmbeddingClient( - endpoint=openai_endpoint, - model=embedding_model, + if openai_endpoint and embedding_deployment: + embedding_client = OpenAIEmbeddingClient( + azure_endpoint=openai_endpoint, + model=embedding_deployment, credential=credential, ) diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index 2bdd6d2233..c5ce2095b8 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -85,7 +85,7 @@ Alternatively, set environment variables globally: ```bash export OPENAI_API_KEY="your-key-here" -export OPENAI_CHAT_MODEL_ID="gpt-4o" +export OPENAI_CHAT_MODEL="gpt-4o" ``` ## Using DevUI with Your Own Agents diff --git a/python/samples/02-agents/embeddings/azure_openai_embeddings.py b/python/samples/02-agents/embeddings/azure_openai_embeddings.py index 16669eb51f..460bbcf7de 100644 --- a/python/samples/02-agents/embeddings/azure_openai_embeddings.py +++ b/python/samples/02-agents/embeddings/azure_openai_embeddings.py @@ -2,55 +2,59 @@ # Run with: uv run samples/02-agents/embeddings/azure_openai_embeddings.py - import asyncio +import os -from agent_framework.azure import AzureOpenAIEmbeddingClient +from agent_framework.openai import OpenAIEmbeddingClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv -load_dotenv() - -"""Azure OpenAI Embedding Client Example - -This sample demonstrates how to generate embeddings using the Azure OpenAI embedding client. -It supports both API key and Azure credential authentication. +"""This sample demonstrates Azure OpenAI embedding generation with ``OpenAIEmbeddingClient``. Prerequisites: - Set the following environment variables or add them to a .env file: - - AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint URL - - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: The embedding model deployment name - - AZURE_OPENAI_API_KEY: Your API key (or use Azure credential instead) + 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_API_VERSION``: Optional API version override + + Sign in with ``az login`` before running the sample. """ +load_dotenv() + async def main() -> None: """Generate embeddings with Azure OpenAI.""" - # 1. Create a client using environment variables. - # Reads AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME, - # and AZURE_OPENAI_API_KEY from environment. - client = AzureOpenAIEmbeddingClient() - - # 2. Generate a single embedding. - result = await client.get_embeddings(["Hello, world!"]) - print(f"Single embedding dimensions: {result[0].dimensions}") - print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") - print(f"Usage: {result.usage}") - print() - - # 3. Generate embeddings for multiple inputs. - texts = [ - "The weather is sunny today.", - "It is raining outside.", - "Machine learning is fascinating.", - ] - result = await client.get_embeddings(texts) - print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") - print() - - # 4. Generate embeddings with custom dimensions. - result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) - print(f"Custom dimensions: {result[0].dimensions}") + async with AzureCliCredential() as credential: + client = OpenAIEmbeddingClient( + model=os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + # 1. Generate a single embedding. + result = await client.get_embeddings(["Hello, world!"]) + print(f"Single embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Model: {result[0].model}") + print(f"Usage: {result.usage}") + print() + + # 2. Generate embeddings for multiple inputs. + texts = [ + "The weather is sunny today.", + "It is raining outside.", + "Machine learning is fascinating.", + ] + result = await client.get_embeddings(texts) + print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") + print(f"First embedding vector: {result[0].vector[:5]}") + print() + + # 3. Generate embeddings with custom dimensions. + result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) + print(f"Custom dimensions: {result[0].dimensions}") if __name__ == "__main__": diff --git a/python/samples/02-agents/embeddings/openai_embeddings.py b/python/samples/02-agents/embeddings/openai_embeddings.py index 001b6593f5..d49b3034dd 100644 --- a/python/samples/02-agents/embeddings/openai_embeddings.py +++ b/python/samples/02-agents/embeddings/openai_embeddings.py @@ -3,31 +3,32 @@ # Run with: uv run samples/02-agents/embeddings/openai_embeddings.py import asyncio +import os from agent_framework.openai import OpenAIEmbeddingClient from dotenv import load_dotenv -load_dotenv() - -"""OpenAI Embedding Client Example - -This sample demonstrates how to generate embeddings using the OpenAI embedding client. -It shows single and batch embedding generation, as well as custom dimensions. +"""This sample demonstrates OpenAI embedding generation with explicit constructor settings. Prerequisites: - Set the OPENAI_API_KEY environment variable or add it to a .env file. + Set ``OPENAI_API_KEY`` in your environment or in a local ``.env`` file. """ +load_dotenv() + async def main() -> None: """Generate embeddings with OpenAI.""" - client = OpenAIEmbeddingClient(model="text-embedding-3-small") + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key=os.getenv("OPENAI_API_KEY"), + ) # 1. Generate a single embedding. result = await client.get_embeddings(["Hello, world!"]) print(f"Single 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() @@ -39,7 +40,7 @@ async def main() -> None: ] result = await client.get_embeddings(texts) print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") - print(f"First embedding vector: {result[0].vector[:5]}") # Print first 5 values of the first embedding + print(f"First embedding vector: {result[0].vector[:5]}") print() # 3. Generate embeddings with custom dimensions. diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index 1df1a449b6..e07d63ddbd 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -17,7 +17,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to ## Prerequisites - `OPENAI_API_KEY` environment variable -- `OPENAI_RESPONSES_MODEL_ID` environment variable +- `OPENAI_RESPONSES_MODEL` environment variable For `mcp_github_pat.py`: - `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens) diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md index 754f96e815..5bd318575c 100644 --- a/python/samples/02-agents/middleware/README.md +++ b/python/samples/02-agents/middleware/README.md @@ -25,7 +25,7 @@ The new usage tracking sample uses `OpenAIResponsesClient`, so set the usual Ope ```bash export OPENAI_API_KEY="your-openai-api-key" -export OPENAI_RESPONSES_MODEL_ID="gpt-4.1-mini" +export OPENAI_RESPONSES_MODEL="gpt-4.1-mini" ``` Then run: diff --git a/python/samples/02-agents/observability/.env.example b/python/samples/02-agents/observability/.env.example index 11f0a07810..c1c24a5a72 100644 --- a/python/samples/02-agents/observability/.env.example +++ b/python/samples/02-agents/observability/.env.example @@ -40,8 +40,8 @@ ENABLE_SENSITIVE_DATA=true # OpenAI specific variables # ========================== OPENAI_API_KEY="..." -OPENAI_RESPONSES_MODEL_ID="gpt-4o-2024-08-06" -OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_RESPONSES_MODEL="gpt-4o-2024-08-06" +OPENAI_CHAT_MODEL="gpt-4o-2024-08-06" # Azure AI Foundry specific variables # ==================================== diff --git a/python/samples/02-agents/providers/azure/README.md b/python/samples/02-agents/providers/azure/README.md index 1e06bda482..cd34fe2717 100644 --- a/python/samples/02-agents/providers/azure/README.md +++ b/python/samples/02-agents/providers/azure/README.md @@ -1,12 +1,48 @@ # Azure Provider Samples -This folder contains Azure OpenAI chat completion samples for Agent Framework. +This folder contains Azure-backed samples for the generic OpenAI clients in +`agent_framework.openai`. -## Azure OpenAI ChatCompletionClient Samples +## Chat Completions API samples (`OpenAIChatCompletionClient`) | File | Description | |------|-------------| -| [`openai_chat_completion_client_azure_basic.py`](openai_chat_completion_client_azure_basic.py) | Azure OpenAI Chat Client Basic Example | -| [`openai_chat_completion_client_azure_with_explicit_settings.py`](openai_chat_completion_client_azure_with_explicit_settings.py) | Azure OpenAI Chat Client with Explicit Settings Example | -| [`openai_chat_completion_client_azure_with_function_tools.py`](openai_chat_completion_client_azure_with_function_tools.py) | Azure OpenAI Chat Client with Function Tools Example | -| [`openai_chat_completion_client_azure_with_session.py`](openai_chat_completion_client_azure_with_session.py) | Azure OpenAI Chat Client with Session Management Example | +| [`openai_chat_completion_client_basic.py`](openai_chat_completion_client_basic.py) | Basic Azure chat completions sample using explicit Azure settings and `credential=AzureCliCredential()`. | +| [`openai_chat_completion_client_with_explicit_settings.py`](openai_chat_completion_client_with_explicit_settings.py) | Azure chat completions sample with explicit settings. | +| [`openai_chat_completion_client_with_function_tools.py`](openai_chat_completion_client_with_function_tools.py) | Azure chat completions sample with function tools. | +| [`openai_chat_completion_client_with_session.py`](openai_chat_completion_client_with_session.py) | Azure chat completions sample with session management. | + +## Responses API samples (`OpenAIChatClient`) + +| File | Description | +|------|-------------| +| [`openai_client_basic.py`](openai_client_basic.py) | Basic Azure responses sample using explicit settings and `credential=AzureCliCredential()`. | +| [`openai_client_with_function_tools.py`](openai_client_with_function_tools.py) | Azure responses sample with function tools. | +| [`openai_client_with_session.py`](openai_client_with_session.py) | Azure responses sample with session management. | +| [`openai_client_with_structured_output.py`](openai_client_with_structured_output.py) | Azure responses sample with structured output. | + +## Environment Variables + +Set these before running the Azure provider samples: + +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_DEPLOYMENT_NAME` + +Optionally, you can also set: + +- `AZURE_OPENAI_API_KEY` +- `AZURE_OPENAI_API_VERSION` +- `AZURE_OPENAI_BASE_URL` + +These Azure samples are written around explicit Azure inputs such as +`credential=AzureCliCredential()`, so they stay on Azure even if `OPENAI_API_KEY` is also present. + +## Optional Dependencies + +Credential-based samples require `azure-identity`: + +```bash +pip install azure-identity +``` + +Run `az login` before executing the credential-based samples. diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py similarity index 70% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py index db5740cfc3..030828da89 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from random import randint from typing import Annotated @@ -16,14 +17,12 @@ """ Azure OpenAI Chat Client Basic Example -This sample demonstrates basic usage of OpenAIChatCompletionClient for direct chat-based -interactions, showing both streaming and non-streaming responses. +This sample demonstrates basic usage of OpenAIChatCompletionClient with explicit Azure +settings and a credential, showing both streaming and non-streaming responses. """ -# 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") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -37,11 +36,14 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - # Create agent with Azure Chat Client - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. agent = Agent( - client=OpenAIChatCompletionClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -56,11 +58,14 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - # Create agent with Azure Chat Client - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. agent = Agent( - client=OpenAIChatCompletionClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -75,7 +80,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Basic Azure Chat Client Agent Example ===") + print("=== Basic Azure Chat Completion Client Agent Example ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py similarity index 71% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py index 16ecc8a091..cf26f6ba38 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py @@ -15,16 +15,16 @@ load_dotenv() """ -Azure OpenAI Chat Client with Explicit Settings Example +OpenAI Chat Completion Client with Explicit Settings Example -This sample demonstrates creating Azure OpenAI Chat Client with explicit configuration +This samples connects to Azure OpenAI. + +This sample demonstrates creating OpenAI Chat Completion Client with explicit configuration settings rather than relying on environment variable defaults. """ -# 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") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -39,13 +39,12 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - _client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - credential=AzureCliCredential(), - ) agent = Agent( - client=_client, + client=OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + credential=AzureCliCredential(), + ), instructions="You are a helpful weather agent.", tools=[get_weather], ) diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_function_tools.py similarity index 100% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_function_tools.py diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_session.py similarity index 100% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_session.py diff --git a/python/samples/02-agents/providers/azure/openai_client_basic.py b/python/samples/02-agents/providers/azure/openai_client_basic.py new file mode 100644 index 0000000000..3029c03cda --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_basic.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client Basic Example + +This sample demonstrates basic usage of OpenAIChatClient with explicit Azure +settings and a credential, showing both streaming and non-streaming responses. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=OpenAIChatClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=OpenAIChatClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic Azure OpenAI Chat Client Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py new file mode 100644 index 0000000000..8080b65418 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from datetime import datetime, timezone +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Function Tools Example + +This sample demonstrates function tool integration with Azure OpenAI Chat Client, +showing both agent-level and query-level tool configuration patterns. +""" + + +# 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.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +@tool(approval_mode="never_require") +def get_time() -> str: + """Get the current UTC time.""" + current_time = datetime.now(timezone.utc) + return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." + + +async def tools_on_agent_level() -> None: + """Example showing tools defined when creating the agent.""" + print("=== Tools Defined on Agent Level ===") + + # Tools are provided when creating the agent + # The agent can use these tools for any query during its lifetime + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant that can provide weather and time information.", + tools=[get_weather, get_time], # Tools defined at agent creation + ) + + # First query - agent can use weather tool + query1 = "What's the weather like in New York?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1}\n") + + # Second query - agent can use time tool + query2 = "What's the current UTC time?" + print(f"User: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2}\n") + + # Third query - agent can use both tools if needed + query3 = "What's the weather in London and what's the current UTC time?" + print(f"User: {query3}") + result3 = await agent.run(query3) + print(f"Agent: {result3}\n") + + +async def tools_on_run_level() -> None: + """Example showing tools passed to the run method.""" + print("=== Tools Passed to Run Method ===") + + # Agent created without tools + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant.", + # No tools defined here + ) + + # First query with weather tool + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1, tools=[get_weather]) # Tool passed to run method + print(f"Agent: {result1}\n") + + # Second query with time tool + query2 = "What's the current UTC time?" + print(f"User: {query2}") + result2 = await agent.run(query2, tools=[get_time]) # Different tool for this query + print(f"Agent: {result2}\n") + + # Third query with multiple tools + query3 = "What's the weather in Chicago and what's the current UTC time?" + print(f"User: {query3}") + result3 = await agent.run(query3, tools=[get_weather, get_time]) # Multiple tools + print(f"Agent: {result3}\n") + + +async def mixed_tools_example() -> None: + """Example showing both agent-level tools and run-method tools.""" + print("=== Mixed Tools Example (Agent + Run Method) ===") + + # Agent created with some base tools + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a comprehensive assistant that can help with various information requests.", + tools=[get_weather], # Base tool available for all queries + ) + + # Query using both agent tool and additional run-method tools + query = "What's the weather in Denver and what's the current UTC time?" + print(f"User: {query}") + + # Agent has access to get_weather (from creation) + additional tools from run method + result = await agent.run( + query, + tools=[get_time], # Additional tools for this specific query + ) + print(f"Agent: {result}\n") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Agent with Function Tools Examples ===\n") + + await tools_on_agent_level() + await tools_on_run_level() + await mixed_tools_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_session.py b/python/samples/02-agents/providers/azure/openai_client_with_session.py new file mode 100644 index 0000000000..ad1c87f2d2 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_session.py @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Session Management Example + +This sample demonstrates session management with Azure OpenAI Chat Client, showing +persistent conversation context and simplified response handling. +""" + + +# 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.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation.""" + print("=== Automatic Session Creation Example ===") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence_in_memory() -> None: + """ + Example showing session persistence across multiple conversations. + In this example, messages are stored in-memory. + """ + print("=== Session Persistence Example (In-Memory) ===") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session, store=False) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session, store=False) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session, store=False) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """ + Example showing how to work with an existing session ID from the service. + In this example, messages are stored on the server using OpenAI conversation state. + """ + print("=== Existing Session ID Example ===") + + # First, create a conversation and capture the session ID + existing_session_id = None + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and get the session ID + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation from the previous session by using session ID.\n") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence_in_memory() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py b/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py new file mode 100644 index 0000000000..3c09efd6d4 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent, AgentResponse +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Structured Output Example + +This sample demonstrates using structured output capabilities with Azure OpenAI Chat Client, +showing Pydantic model integration for type-safe response parsing and data extraction. +""" + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + city: str + description: str + + +async def non_streaming_example() -> None: + print("=== Non-streaming example ===") + + # Create an Azure OpenAI Chat agent + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + name="CityAgent", + instructions="You are a helpful agent that describes cities in a structured format.", + ) + + # Ask the agent about a city + query = "Tell me about Paris, France" + print(f"User: {query}") + + # Get structured response from the agent using response_format parameter + result = await agent.run(query, options={"response_format": OutputStruct}) + + # Access the structured output using the parsed value + if structured_data := result.value: + print("Structured Output Agent:") + print(f"City: {structured_data.city}") + print(f"Description: {structured_data.description}") + else: + print(f"Failed to parse response: {result.text}") + + +async def streaming_example() -> None: + print("=== Streaming example ===") + + # Create an Azure OpenAI Chat agent + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + name="CityAgent", + instructions="You are a helpful agent that describes cities in a structured format.", + ) + + # Ask the agent about a city + query = "Tell me about Tokyo, Japan" + print(f"User: {query}") + + # Get structured response from streaming agent using AgentResponse.from_update_generator + # This method collects all streaming updates and combines them into a single AgentResponse + result = await AgentResponse.from_update_generator( + agent.run(query, stream=True, options={"response_format": OutputStruct}), + output_format_type=OutputStruct, + ) + + # Access the structured output using the parsed value + if structured_data := result.value: + print("Structured Output (from streaming with AgentResponse.from_update_generator):") + print(f"City: {structured_data.city}") + print(f"Description: {structured_data.description}") + else: + print(f"Failed to parse response: {result.text}") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Agent with Structured Output ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/custom/README.md b/python/samples/02-agents/providers/custom/README.md index ac58a77e69..766e5e0269 100644 --- a/python/samples/02-agents/providers/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -27,7 +27,7 @@ Both approaches allow you to extend the framework for your specific use cases wh ## Understanding Raw Client Classes -The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIResponsesClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. +The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. ### Warning: Raw Clients Should Not Normally Be Used Directly @@ -60,8 +60,8 @@ class MyCustomClient( For most use cases, use the fully-featured public client classes which already have all layers correctly composed: -- `OpenAIChatClient` - OpenAI Chat completions with all layers -- `OpenAIResponsesClient` - OpenAI Responses API with all layers +- `OpenAIChatCompletionClient` - OpenAI Chat Completions API with all layers +- `OpenAIChatClient` - OpenAI Responses API with all layers - `AzureOpenAIChatClient` - Azure OpenAI Chat with all layers - `AzureOpenAIResponsesClient` - Azure OpenAI Responses with all layers - `AzureAIClient` - Azure AI Project with all layers diff --git a/python/samples/02-agents/providers/openai/README.md b/python/samples/02-agents/providers/openai/README.md index 20e757d421..db71abfa89 100644 --- a/python/samples/02-agents/providers/openai/README.md +++ b/python/samples/02-agents/providers/openai/README.md @@ -1,67 +1,63 @@ -# OpenAI Agent Framework Examples +# OpenAI Provider Samples -This folder contains examples demonstrating different ways to create and use agents with the OpenAI clients from the `agent_framework.openai` package. +This folder contains OpenAI provider samples for the generic clients in +`agent_framework.openai`. -## Examples +## Chat Completions API samples (`OpenAIChatCompletionClient`) | File | Description | |------|-------------| -| [`openai_assistants_basic.py`](openai_assistants_basic.py) | Basic usage of `OpenAIAssistantProvider` with streaming and non-streaming responses. | -| [`openai_assistants_provider_methods.py`](openai_assistants_provider_methods.py) | Demonstrates all `OpenAIAssistantProvider` methods: `create_agent()`, `get_agent()`, and `as_agent()`. | -| [`openai_assistants_with_code_interpreter.py`](openai_assistants_with_code_interpreter.py) | Using `OpenAIAssistantsClient.get_code_interpreter_tool()` with `OpenAIAssistantProvider` to execute Python code. | -| [`openai_assistants_with_existing_assistant.py`](openai_assistants_with_existing_assistant.py) | Working with pre-existing assistants using `get_agent()` and `as_agent()` methods. | -| [`openai_assistants_with_explicit_settings.py`](openai_assistants_with_explicit_settings.py) | Configuring `OpenAIAssistantProvider` with explicit settings including API key and model ID. | -| [`openai_assistants_with_file_search.py`](openai_assistants_with_file_search.py) | Using `OpenAIAssistantsClient.get_file_search_tool()` with `OpenAIAssistantProvider` for file search capabilities. | -| [`openai_assistants_with_function_tools.py`](openai_assistants_with_function_tools.py) | Function tools with `OpenAIAssistantProvider` at both agent-level and query-level. | -| [`openai_assistants_with_response_format.py`](openai_assistants_with_response_format.py) | Structured outputs with `OpenAIAssistantProvider` using Pydantic models. | -| [`openai_assistants_with_session.py`](openai_assistants_with_session.py) | Session management with `OpenAIAssistantProvider` for conversation context persistence. | -| [`openai_chat_client_basic.py`](openai_chat_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with OpenAI models. | -| [`openai_chat_client_with_explicit_settings.py`](openai_chat_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific chat client, configuring settings explicitly including API key and model ID. | -| [`openai_chat_client_with_function_tools.py`](openai_chat_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`openai_chat_client_with_local_mcp.py`](openai_chat_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_chat_client_with_session.py`](openai_chat_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`openai_chat_client_with_web_search.py`](openai_chat_client_with_web_search.py) | Shows how to use `OpenAIChatClient.get_web_search_tool()` for web search capabilities with OpenAI agents. | -| [`openai_chat_client_with_runtime_json_schema.py`](openai_chat_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_basic.py`](openai_responses_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with OpenAI models. | -| [`openai_responses_client_image_analysis.py`](openai_responses_client_image_analysis.py) | Demonstrates how to use vision capabilities with agents to analyze images. | -| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use `OpenAIResponsesClient.get_image_generation_tool()` to create images based on text descriptions. | -| [`openai_responses_client_reasoning.py`](openai_responses_client_reasoning.py) | Demonstrates how to use reasoning capabilities with OpenAI agents, showing how the agent can provide detailed reasoning for its responses. | -| [`openai_responses_client_streaming_image_generation.py`](openai_responses_client_streaming_image_generation.py) | Demonstrates streaming image generation with partial images for real-time image creation feedback and improved user experience. | -| [`openai_responses_client_with_agent_as_tool.py`](openai_responses_client_with_agent_as_tool.py) | Shows how to use the agent-as-tool pattern with OpenAI Responses Client, where one agent delegates work to specialized sub-agents wrapped as tools using `as_tool()`. Demonstrates hierarchical agent architectures. | -| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use `OpenAIResponsesClient.get_code_interpreter_tool()` to write and execute Python code. | -| [`openai_responses_client_with_code_interpreter_files.py`](openai_responses_client_with_code_interpreter_files.py) | Shows how to use code interpreter with uploaded files for data analysis. | -| [`openai_responses_client_with_explicit_settings.py`](openai_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including API key and model ID. | -| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use `OpenAIResponsesClient.get_file_search_tool()` for searching through uploaded files. | -| [`openai_responses_client_with_function_tools.py`](openai_responses_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and run-level tools (provided with specific queries). | -| [`openai_responses_client_with_hosted_mcp.py`](openai_responses_client_with_hosted_mcp.py) | Shows how to use `OpenAIResponsesClient.get_mcp_tool()` for hosted MCP servers, including approval workflows. | -| [`openai_responses_client_with_local_mcp.py`](openai_responses_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_responses_client_with_runtime_json_schema.py`](openai_responses_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_with_structured_output.py`](openai_responses_client_with_structured_output.py) | Demonstrates how to use structured outputs with OpenAI agents to get structured data responses in predefined formats. | -| [`openai_responses_client_with_session.py`](openai_responses_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`openai_responses_client_with_web_search.py`](openai_responses_client_with_web_search.py) | Shows how to use `OpenAIResponsesClient.get_web_search_tool()` for web search capabilities. | +| [`chat_completion_client_basic.py`](chat_completion_client_basic.py) | Basic non-streaming and streaming chat completion sample with an explicit `gpt-5.4-nano` model and API key. | +| [`chat_completion_client_with_explicit_settings.py`](chat_completion_client_with_explicit_settings.py) | Chat completion sample with explicit model and API key settings. | +| [`chat_completion_client_with_function_tools.py`](chat_completion_client_with_function_tools.py) | Function tools with agent-level and run-level patterns. | +| [`chat_completion_client_with_local_mcp.py`](chat_completion_client_with_local_mcp.py) | Local MCP integration with the chat completions client. | +| [`chat_completion_client_with_runtime_json_schema.py`](chat_completion_client_with_runtime_json_schema.py) | Runtime JSON schema output with the chat completions client. | +| [`chat_completion_client_with_session.py`](chat_completion_client_with_session.py) | Session management with the chat completions client. | +| [`chat_completion_client_with_web_search.py`](chat_completion_client_with_web_search.py) | Web search with the chat completions client. | + +## Responses API samples (`OpenAIChatClient`) + +| File | Description | +|------|-------------| +| [`client_basic.py`](client_basic.py) | Basic non-streaming and streaming responses sample with an explicit `gpt-5.4-nano` model and API key. | +| [`client_image_analysis.py`](client_image_analysis.py) | Analyze images with the responses client. | +| [`client_image_generation.py`](client_image_generation.py) | Generate images from text prompts. | +| [`client_reasoning.py`](client_reasoning.py) | Reasoning-focused sample for models such as `gpt-5`. | +| [`client_streaming_image_generation.py`](client_streaming_image_generation.py) | Streaming image generation sample. | +| [`client_with_agent_as_tool.py`](client_with_agent_as_tool.py) | Agent-as-tool orchestration pattern. | +| [`client_with_code_interpreter.py`](client_with_code_interpreter.py) | Code interpreter sample. | +| [`client_with_code_interpreter_files.py`](client_with_code_interpreter_files.py) | Code interpreter sample with uploaded files. | +| [`client_with_explicit_settings.py`](client_with_explicit_settings.py) | Responses client with explicit model and API key settings. | +| [`client_with_file_search.py`](client_with_file_search.py) | Hosted file search sample. | +| [`client_with_function_tools.py`](client_with_function_tools.py) | Function tools with agent-level and run-level patterns. | +| [`client_with_hosted_mcp.py`](client_with_hosted_mcp.py) | Hosted MCP tools and approval workflows. | +| [`client_with_local_mcp.py`](client_with_local_mcp.py) | Local MCP integration with the responses client. | +| [`client_with_local_shell.py`](client_with_local_shell.py) | Local shell tool sample. | +| [`client_with_runtime_json_schema.py`](client_with_runtime_json_schema.py) | Runtime JSON schema output with the responses client. | +| [`client_with_session.py`](client_with_session.py) | Session management with the responses client. | +| [`client_with_shell.py`](client_with_shell.py) | Hosted shell tool sample. | +| [`client_with_structured_output.py`](client_with_structured_output.py) | Structured output with Pydantic models. | +| [`client_with_web_search.py`](client_with_web_search.py) | Web search with the responses client. | ## Environment Variables -Make sure to set the following environment variables before running the examples: +Set these before running the OpenAI provider samples: -- `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) -- `OPENAI_RESPONSES_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) -- For image processing examples, use a vision-capable model like `gpt-4o` or `gpt-4o-mini` +- `OPENAI_API_KEY` +- `OPENAI_MODEL` -Optionally, you can set: -- `OPENAI_ORG_ID`: Your OpenAI organization ID (if applicable) -- `OPENAI_API_BASE_URL`: Your OpenAI base URL (if using a different base URL) +Optionally, you can also set: -## Optional Dependencies +- `OPENAI_ORG_ID` +- `OPENAI_BASE_URL` + +If your shell also contains `AZURE_OPENAI_*` variables, these samples still stay on OpenAI as long as +`OPENAI_API_KEY` is present. To force Azure routing with the generic clients, pass an explicit Azure +input such as `credential`, `azure_endpoint`, or `api_version`, or use the Azure provider samples. -Some examples require additional dependencies: +## Optional Dependencies -- **Image Generation Example**: The `openai_responses_client_image_generation.py` example requires PIL (Pillow) for image display. Install with: - ```bash - # Using uv - uv add pillow +Some samples need extra packages: - # Or using pip - pip install pillow - ``` +- `client_image_generation.py` and `client_streaming_image_generation.py` use Pillow for image display. +- MCP samples require the relevant MCP server/tooling you configure locally. diff --git a/python/samples/02-agents/providers/openai/chat_completion_client_basic.py b/python/samples/02-agents/providers/openai/chat_completion_client_basic.py new file mode 100644 index 0000000000..9573ef0b16 --- /dev/null +++ b/python/samples/02-agents/providers/openai/chat_completion_client_basic.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatCompletionClient +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +OpenAI Chat Completion Client Basic Example + +This sample demonstrates basic usage of OpenAIChatCompletionClient with explicit model and +API key settings, showing both streaming and non-streaming responses. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=OpenAIChatCompletionClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=OpenAIChatCompletionClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather like in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic OpenAI Chat Completion Client Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py similarity index 74% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py index 20a3f720d1..59d11a4fda 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ load_dotenv() """ -OpenAI Responses Client with Explicit Settings Example +OpenAI Chat Completion Client with Explicit Settings Example -This sample demonstrates creating OpenAI Responses Client with explicit configuration +This sample demonstrates creating OpenAI Chat Completion Client with explicit configuration settings rather than relying on environment variable defaults. """ @@ -34,15 +34,13 @@ def get_weather( async def main() -> None: - print("=== OpenAI Responses Client with Explicit Settings ===") - - _client = OpenAIResponsesClient( - model=os.environ["OPENAI_MODEL"], - api_key=os.environ["OPENAI_API_KEY"], - ) + print("=== OpenAI Chat Completion Client with Explicit Settings ===") agent = Agent( - client=_client, + client=OpenAIChatCompletionClient( + model=os.environ["OPENAI_MODEL"], + api_key=os.environ["OPENAI_API_KEY"], + ), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py index 55f0ed9e19..7bbc6b9744 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py @@ -6,7 +6,7 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ load_dotenv() """ -OpenAI Responses Client with Function Tools Example +OpenAI Chat Completion Client with Function Tools Example -This sample demonstrates function tool integration with OpenAI Responses Client, +This sample demonstrates function tool integration with OpenAI Chat Completion Client, showing both agent-level and query-level tool configuration patterns. """ @@ -47,7 +47,7 @@ async def tools_on_agent_level() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -77,7 +77,7 @@ async def tools_on_run_level() -> None: # Agent created without tools agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful assistant.", # No tools defined here ) @@ -107,7 +107,7 @@ async def mixed_tools_example() -> None: # Agent created with some base tools agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) @@ -125,7 +125,7 @@ async def mixed_tools_example() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== OpenAI Chat Completion Client Agent with Function Tools Examples ===\n") await tools_on_agent_level() await tools_on_run_level() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py similarity index 88% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py index 00057dd76d..1c2387dc24 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py @@ -3,17 +3,17 @@ import asyncio from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client with Local MCP Example +OpenAI Chat Completion Client with Local MCP Example This sample demonstrates integrating Model Context Protocol (MCP) tools with -OpenAI Chat Client for extended functionality and external service access. +OpenAI Chat Completion Client for extended functionality and external service access. The Agent Framework now supports enhanced metadata extraction from MCP tool results, including error states, token usage, costs, and other arbitrary @@ -34,7 +34,7 @@ async def mcp_tools_on_run_level() -> None: url="https://learn.microsoft.com/api/mcp", ) as mcp_server, Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", ) as agent, @@ -60,7 +60,7 @@ async def mcp_tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime # The agent will connect to the MCP server through its context manager. async with Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -82,7 +82,7 @@ async def mcp_tools_on_agent_level() -> None: async def main() -> None: - print("=== OpenAI Chat Client Agent with MCP Tools Examples ===\n") + print("=== OpenAI Chat Completion Client Agent with MCP Tools Examples ===\n") await mcp_tools_on_agent_level() await mcp_tools_on_run_level() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py similarity index 89% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py index cdc4ce13fb..4cf5dfb844 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py @@ -4,14 +4,14 @@ import json from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient, OpenAIChatOptions from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client Runtime JSON Schema Example +OpenAI Chat Completion Client Runtime JSON Schema Example Demonstrates structured outputs when the schema is only known at runtime. Uses additional_chat_options to pass a JSON Schema payload directly to OpenAI @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient[OpenAIChatOptions](), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -72,7 +72,7 @@ async def streaming_example() -> None: print("=== Streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -108,7 +108,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== OpenAI Chat Client with runtime JSON Schema ===") + print("=== OpenAI Chat Completion Client with runtime JSON Schema ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_session.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_session.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_session.py index 773b18b3cc..99aac09e36 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_session.py @@ -5,7 +5,7 @@ from typing import Annotated from agent_framework import Agent, AgentSession, InMemoryHistoryProvider, tool -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -13,9 +13,9 @@ load_dotenv() """ -OpenAI Chat Client with Session Management Example +OpenAI Chat Completion Client with Session Management Example -This sample demonstrates session management with OpenAI Chat Client, showing +This sample demonstrates session management with OpenAI Chat Completion Client, showing conversation sessions and message history preservation across interactions. """ @@ -37,7 +37,7 @@ async def example_with_automatic_session_creation() -> None: print("=== Automatic Session Creation Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -62,7 +62,7 @@ async def example_with_session_persistence() -> None: print("Using the same session across multiple conversations to maintain context.\n") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -95,7 +95,7 @@ async def example_with_existing_session_messages() -> None: print("=== Existing Session Messages Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -118,7 +118,7 @@ async def example_with_existing_session_messages() -> None: # Create a new agent instance but use the existing session with its message history new_agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -142,7 +142,7 @@ async def example_with_existing_session_messages() -> None: async def main() -> None: - print("=== OpenAI Chat Client Agent Session Management Examples ===\n") + print("=== OpenAI Chat Completion Client Agent Session Management Examples ===\n") await example_with_automatic_session_creation() await example_with_session_persistence() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py similarity index 86% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py index 623dc25f43..f5da9ba633 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py @@ -3,22 +3,22 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client with Web Search Example +OpenAI Chat Completion Client with Web Search Example -This sample demonstrates using get_web_search_tool() with OpenAI Chat Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Completion Client for real-time information retrieval and current data access. """ async def main() -> None: - client = OpenAIChatClient(model="gpt-4o-search-preview") + client = OpenAIChatCompletionClient(model="gpt-4o-search-preview") # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py b/python/samples/02-agents/providers/openai/client_basic.py similarity index 73% rename from python/samples/02-agents/providers/openai/openai_chat_client_basic.py rename to python/samples/02-agents/providers/openai/client_basic.py index d2834fe1e9..138f1e57b6 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py +++ b/python/samples/02-agents/providers/openai/client_basic.py @@ -1,12 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from random import randint from typing import Annotated from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv +from pydantic import Field # Load environment variables from .env file load_dotenv() @@ -14,17 +16,15 @@ """ OpenAI Chat Client Basic Example -This sample demonstrates basic usage of OpenAIChatClient for direct chat-based -interactions, showing both streaming and non-streaming responses. +This sample demonstrates basic usage of OpenAIChatClient with explicit model and +API key settings, showing both streaming and non-streaming responses. """ -# 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") def get_weather( - location: Annotated[str, "The location to get the weather for."], + location: Annotated[str, Field(description="The location to get the weather for.")], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -36,13 +36,16 @@ async def non_streaming_example() -> None: print("=== Non-streaming Response Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Seattle?" + query = "What's the weather in Seattle?" print(f"User: {query}") result = await agent.run(query) print(f"Result: {result}\n") @@ -53,13 +56,16 @@ async def streaming_example() -> None: print("=== Streaming Response Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Portland?" + query = "What's the weather in Portland?" print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run(query, stream=True): diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py b/python/samples/02-agents/providers/openai/client_image_analysis.py similarity index 70% rename from python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py rename to python/samples/02-agents/providers/openai/client_image_analysis.py index 82fee38455..00c0207528 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/openai/client_image_analysis.py @@ -3,26 +3,26 @@ import asyncio from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Image Analysis Example +OpenAI Chat Client Image Analysis Example -This sample demonstrates using OpenAI Responses Client for image analysis and vision tasks, +This sample demonstrates using OpenAI Chat Client for image analysis and vision tasks, showing multi-modal content handling with text and images. """ async def main(): - print("=== OpenAI Responses Agent with Image Analysis ===") + print("=== OpenAI Chat Client Agent with Image Analysis ===") - # 1. Create an OpenAI Responses agent with vision capabilities + # 1. Create an OpenAI Chat agent with vision capabilities agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="VisionAgent", instructions="You are a image analysist, you get a image and need to respond with what you see in the picture.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py b/python/samples/02-agents/providers/openai/client_image_generation.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py rename to python/samples/02-agents/providers/openai/client_image_generation.py index 6e01a4dbbd..84e50674d4 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py +++ b/python/samples/02-agents/providers/openai/client_image_generation.py @@ -7,17 +7,17 @@ from pathlib import Path from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Image Generation Example +OpenAI Chat Client Image Generation Example This sample demonstrates how to generate images using OpenAI's DALL-E models -through the Responses Client. Image generation capabilities enable AI to create visual content from text, +through the Chat Client. Image generation capabilities enable AI to create visual content from text, making it ideal for creative applications, content creation, design prototyping, and automated visual asset generation. """ @@ -57,10 +57,10 @@ def save_image(output: Content) -> None: async def main() -> None: - print("=== OpenAI Responses Image Generation Agent Example ===") + print("=== OpenAI Chat Image Generation Agent Example ===") # Create an agent with customized image generation options - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful AI that can generate images.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py b/python/samples/02-agents/providers/openai/client_reasoning.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py rename to python/samples/02-agents/providers/openai/client_reasoning.py index a4fc3849b8..2eea8d2106 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py +++ b/python/samples/02-agents/providers/openai/client_reasoning.py @@ -3,14 +3,14 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient, OpenAIResponsesOptions +from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Reasoning Example +OpenAI Chat Client Reasoning Example This sample demonstrates advanced reasoning capabilities using OpenAI's gpt-5 models, showing step-by-step reasoning process visualization and complex problem-solving. @@ -25,7 +25,7 @@ agent = Agent( - client=OpenAIResponsesClient[OpenAIResponsesOptions](model_id="gpt-5"), + client=OpenAIChatClient[OpenAIChatOptions](model_id="gpt-5"), name="MathHelper", instructions="You are a personal math tutor. When asked a math question, " "reason over how best to approach the problem and share your thought process.", @@ -76,7 +76,7 @@ async def streaming_reasoning_example() -> None: async def main() -> None: - print("\033[92m=== Basic OpenAI Responses Reasoning Agent Example ===\033[0m") + print("\033[92m=== Basic OpenAI Chat Reasoning Agent Example ===\033[0m") await reasoning_example() await streaming_reasoning_example() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py b/python/samples/02-agents/providers/openai/client_streaming_image_generation.py similarity index 96% rename from python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py rename to python/samples/02-agents/providers/openai/client_streaming_image_generation.py index 7aafd6f704..412e6f8e6a 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py +++ b/python/samples/02-agents/providers/openai/client_streaming_image_generation.py @@ -7,12 +7,12 @@ import anyio from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() -"""OpenAI Responses Client Streaming Image Generation Example +"""OpenAI Chat Client Streaming Image Generation Example Demonstrates streaming partial image generation using OpenAI's image generation tool. Shows progressive image rendering with partial images for improved user experience. Note: The number of partial images received depends on generation speed: @@ -42,7 +42,7 @@ async def main(): """Demonstrate streaming image generation with partial images.""" print("=== OpenAI Streaming Image Generation Example ===\n") # Create agent with streaming image generation enabled - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful agent that can generate images.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/02-agents/providers/openai/client_with_agent_as_tool.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py rename to python/samples/02-agents/providers/openai/client_with_agent_as_tool.py index 567c7fcaef..d8a991242d 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py +++ b/python/samples/02-agents/providers/openai/client_with_agent_as_tool.py @@ -4,14 +4,14 @@ from collections.abc import Awaitable, Callable from agent_framework import Agent, FunctionInvocationContext -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Agent-as-Tool Example +OpenAI Chat Client Agent-as-Tool Example Demonstrates hierarchical agent architectures where one agent delegates work to specialized sub-agents wrapped as tools using as_tool(). @@ -35,9 +35,9 @@ async def logging_middleware( async def main() -> None: - print("=== OpenAI Responses Client Agent-as-Tool Pattern ===") + print("=== OpenAI Chat Client Agent-as-Tool Pattern ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create a specialized writer agent writer = Agent( diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py b/python/samples/02-agents/providers/openai/client_with_code_interpreter.py similarity index 85% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py rename to python/samples/02-agents/providers/openai/client_with_code_interpreter.py index c3a8eba82a..f318c29419 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py +++ b/python/samples/02-agents/providers/openai/client_with_code_interpreter.py @@ -6,25 +6,25 @@ Agent, Content, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Code Interpreter Example +OpenAI Chat Client with Code Interpreter Example -This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Chat Client for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the code interpreter tool with OpenAI Responses.""" - print("=== OpenAI Responses Agent with Code Interpreter Example ===") + """Example showing how to use the code interpreter tool with OpenAI Chat.""" + print("=== OpenAI Chat Client Agent with Code Interpreter Example ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful assistant that can write and execute Python code to solve problems.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py b/python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py similarity index 92% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py rename to python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py index 1636a22912..c286feff10 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py +++ b/python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py @@ -5,7 +5,7 @@ import tempfile from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from openai import AsyncOpenAI @@ -13,9 +13,9 @@ load_dotenv() """ -OpenAI Responses Client with Code Interpreter and Files Example +OpenAI Chat Client with Code Interpreter and Files Example -This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Chat Client for Python code execution and data analysis with uploaded files. """ @@ -69,8 +69,8 @@ async def main() -> None: temp_file_path, file_id = await create_sample_file_and_upload(openai_client) - # Create agent using OpenAI Responses client - client = OpenAIResponsesClient() + # Create agent using OpenAI Chat client + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful assistant that can analyze data files using Python code.", diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/client_with_explicit_settings.py similarity index 100% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/client_with_explicit_settings.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py b/python/samples/02-agents/providers/openai/client_with_file_search.py similarity index 85% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py rename to python/samples/02-agents/providers/openai/client_with_file_search.py index b6c9ac352f..042d888dff 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py +++ b/python/samples/02-agents/providers/openai/client_with_file_search.py @@ -3,23 +3,23 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with File Search Example +OpenAI Chat Client with File Search Example -This sample demonstrates using get_file_search_tool() with OpenAI Responses Client +This sample demonstrates using get_file_search_tool() with OpenAI Chat Client for direct document-based question answering and information retrieval. """ # Helper functions -async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, str]: +async def create_vector_store(client: OpenAIChatClient) -> tuple[str, str]: """Create a vector store with sample documents.""" file = await client.client.files.create( file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data" @@ -35,14 +35,14 @@ async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, str]: return file.id, vector_store.id -async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: +async def delete_vector_store(client: OpenAIChatClient, file_id: str, vector_store_id: str) -> None: """Delete the vector store after using it.""" await client.client.vector_stores.delete(vector_store_id=vector_store_id) await client.client.files.delete(file_id=file_id) async def main() -> None: - client = OpenAIResponsesClient() + client = OpenAIChatClient() message = "What is the weather today? Do a file search to find the answer." diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py b/python/samples/02-agents/providers/openai/client_with_function_tools.py similarity index 100% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/client_with_function_tools.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py similarity index 95% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py rename to python/samples/02-agents/providers/openai/client_with_hosted_mcp.py index 45e7ae736a..ffcdadb8da 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv if TYPE_CHECKING: @@ -14,10 +14,10 @@ load_dotenv() """ -OpenAI Responses Client with Hosted MCP Example +OpenAI Chat Client with Hosted MCP Example This sample demonstrates integrating hosted Model Context Protocol (MCP) tools with -OpenAI Responses Client, including user approval workflows for function call security. +OpenAI Chat Client, including user approval workflows for function call security. """ @@ -102,7 +102,7 @@ async def run_hosted_mcp_without_session_and_specific_approval() -> None: """Example showing Mcp Tools with approvals without using a session.""" print("=== Mcp with approvals and without session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool with specific approval mode mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -135,7 +135,7 @@ async def run_hosted_mcp_without_approval() -> None: """Example showing Mcp Tools without approvals.""" print("=== Mcp without approvals ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that never requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -167,7 +167,7 @@ async def run_hosted_mcp_with_session() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that always requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -200,7 +200,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that always requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -234,7 +234,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n") + print("=== OpenAI Chat Client Agent with Hosted Mcp Tools Examples ===\n") await run_hosted_mcp_without_approval() await run_hosted_mcp_without_session_and_specific_approval() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/client_with_local_mcp.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/client_with_local_mcp.py index 8f136021bb..f7b14a24b2 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_local_mcp.py @@ -3,17 +3,17 @@ import asyncio from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Local MCP Example +OpenAI Chat Client with Local MCP Example This sample demonstrates integrating local Model Context Protocol (MCP) tools with -OpenAI Responses Client for direct response generation with external capabilities. +OpenAI Chat Client for direct response generation with external capabilities. """ @@ -27,7 +27,7 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -65,7 +65,7 @@ async def run_with_mcp() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -87,7 +87,7 @@ async def run_with_mcp() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== OpenAI Chat Client Agent with Function Tools Examples ===\n") await run_with_mcp() await streaming_with_mcp() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py b/python/samples/02-agents/providers/openai/client_with_local_shell.py similarity index 96% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py rename to python/samples/02-agents/providers/openai/client_with_local_shell.py index b3135702a7..f4829cc5e7 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_local_shell.py @@ -5,14 +5,14 @@ from typing import Any from agent_framework import Agent, Message, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Local Shell Tool Example +OpenAI Chat Client with Local Shell Tool Example This sample demonstrates implementing a local shell tool using get_shell_tool(func=...) that wraps Python's subprocess module. Unlike the hosted shell tool (get_shell_tool()), @@ -53,7 +53,7 @@ async def main() -> None: print("=== OpenAI Agent with Local Shell Tool Example ===") print("NOTE: Commands will execute on your local machine.\n") - client = OpenAIResponsesClient() + client = OpenAIChatClient() local_shell_tool = client.get_shell_tool( func=run_bash, ) diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py similarity index 95% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py index ba21d0a325..3fcffeae6c 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py @@ -4,7 +4,7 @@ import json from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIChatClient[OpenAIChatOptions](), + client=OpenAIChatClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py b/python/samples/02-agents/providers/openai/client_with_session.py similarity index 93% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_session.py rename to python/samples/02-agents/providers/openai/client_with_session.py index e62c3bdaea..0dffeaef6c 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py +++ b/python/samples/02-agents/providers/openai/client_with_session.py @@ -5,7 +5,7 @@ from typing import Annotated from agent_framework import Agent, AgentSession, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -13,9 +13,9 @@ load_dotenv() """ -OpenAI Responses Client with Session Management Example +OpenAI Chat Client with Session Management Example -This sample demonstrates session management with OpenAI Responses Client, showing +This sample demonstrates session management with OpenAI Chat Client, showing persistent conversation context and simplified response handling. """ @@ -37,7 +37,7 @@ async def example_with_automatic_session_creation() -> None: print("=== Automatic Session Creation Example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -64,7 +64,7 @@ async def example_with_session_persistence_in_memory() -> None: print("=== Session Persistence Example (In-Memory) ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -103,7 +103,7 @@ async def example_with_existing_session_id() -> None: existing_session_id = None agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -124,7 +124,7 @@ async def example_with_existing_session_id() -> None: print("\n--- Continuing with the same session ID in a new agent instance ---") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py b/python/samples/02-agents/providers/openai/client_with_shell.py similarity index 82% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py rename to python/samples/02-agents/providers/openai/client_with_shell.py index b86f36fde5..5043d8e4a1 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_shell.py @@ -3,16 +3,16 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Shell Tool Example +OpenAI Chat Client with Shell Tool Example -This sample demonstrates using get_shell_tool() with OpenAI Responses Client +This sample demonstrates using get_shell_tool() with OpenAI Chat Client for executing shell commands in a managed container environment hosted by OpenAI. The shell tool allows the model to run commands like listing files, running scripts, @@ -21,10 +21,10 @@ async def main() -> None: - """Example showing how to use the shell tool with OpenAI Responses.""" - print("=== OpenAI Responses Agent with Shell Tool Example ===") + """Example showing how to use the shell tool with OpenAI Chat.""" + print("=== OpenAI Chat Client Agent with Shell Tool Example ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create a hosted shell tool with the default auto container environment shell_tool = client.get_shell_tool() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py b/python/samples/02-agents/providers/openai/client_with_structured_output.py similarity index 87% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py rename to python/samples/02-agents/providers/openai/client_with_structured_output.py index d2599c0bd8..57bade4412 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py +++ b/python/samples/02-agents/providers/openai/client_with_structured_output.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent, AgentResponse -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import BaseModel @@ -11,9 +11,9 @@ load_dotenv() """ -OpenAI Responses Client with Structured Output Example +OpenAI Chat Client with Structured Output Example -This sample demonstrates using structured output capabilities with OpenAI Responses Client, +This sample demonstrates using structured output capabilities with OpenAI Chat Client, showing Pydantic model integration for type-safe response parsing and data extraction. """ @@ -28,9 +28,9 @@ class OutputStruct(BaseModel): async def non_streaming_example() -> None: print("=== Non-streaming example ===") - # Create an OpenAI Responses agent + # Create an OpenAI Chat agent agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -54,9 +54,9 @@ async def non_streaming_example() -> None: async def streaming_example() -> None: print("=== Streaming example ===") - # Create an OpenAI Responses agent + # Create an OpenAI Chat agent agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -82,7 +82,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== OpenAI Responses Agent with Structured Output ===") + print("=== OpenAI Chat Client Agent with Structured Output ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py b/python/samples/02-agents/providers/openai/client_with_web_search.py similarity index 88% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py rename to python/samples/02-agents/providers/openai/client_with_web_search.py index 9f22807ba9..d0bab5a87a 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/client_with_web_search.py @@ -3,22 +3,22 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Web Search Example +OpenAI Chat Client with Web Search Example -This sample demonstrates using get_web_search_tool() with OpenAI Responses Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Client for direct real-time information retrieval and current data access. """ async def main() -> None: - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_assistants_basic.py b/python/samples/02-agents/providers/openai/openai_assistants_basic.py deleted file mode 100644 index 5901b6ef38..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_basic.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants Basic Example - -This sample demonstrates basic usage of OpenAIAssistantProvider with automatic -assistant lifecycle management, showing both streaming and non-streaming responses. -""" - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create a new assistant via the provider - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the assistant from OpenAI - await client.beta.assistants.delete(agent.id) - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create a new assistant via the provider - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - finally: - # Clean up the assistant from OpenAI - await client.beta.assistants.delete(agent.id) - - -async def main() -> None: - print("=== Basic OpenAI Assistants Provider Example ===") - - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py deleted file mode 100644 index 0cc9d33f73..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistant Provider Methods Example - -This sample demonstrates the methods available on the OpenAIAssistantProvider class: -- create_agent(): Create a new assistant on the service -- get_agent(): Retrieve an existing assistant by ID -- as_agent(): Wrap an SDK Assistant object without making HTTP calls -""" - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def create_agent_example() -> None: - """Create a new assistant using provider.create_agent().""" - print("\n--- create_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather assistant.", - tools=[get_weather], - ) - - try: - print(f"Created: {agent.name} (ID: {agent.id})") - result = await agent.run("What's the weather in Seattle?") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(agent.id) - - -async def get_agent_example() -> None: - """Retrieve an existing assistant by ID using provider.get_agent().""" - print("\n--- get_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create an assistant directly with SDK (simulating pre-existing assistant) - sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="ExistingAssistant", - instructions="You always respond with 'Hello!'", - ) - - try: - # Retrieve using provider - agent = await provider.get_agent(sdk_assistant.id) - print(f"Retrieved: {agent.name} (ID: {agent.id})") - - result = await agent.run("Hi there!") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(sdk_assistant.id) - - -async def as_agent_example() -> None: - """Wrap an SDK Assistant object using Agent(client=provider, ...).""" - print("\n--- as_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create assistant using SDK - sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="WrappedAssistant", - instructions="You respond with poetry.", - ) - - try: - # Wrap synchronously (no HTTP call) - agent = Agent(client=provider, agent=sdk_assistant) - print(f"Wrapped: {agent.name} (ID: {agent.id})") - - result = await agent.run("Tell me about the sunset.") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(sdk_assistant.id) - - -async def multiple_agents_example() -> None: - """Create and manage multiple assistants with a single provider.""" - print("\n--- Multiple Agents ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - weather_agent = await provider.create_agent( - name="WeatherSpecialist", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a weather specialist.", - tools=[get_weather], - ) - - greeter_agent = await provider.create_agent( - name="GreeterAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a friendly greeter.", - ) - - try: - print(f"Created: {weather_agent.name}, {greeter_agent.name}") - - greeting = await greeter_agent.run("Hello!") - print(f"Greeter: {greeting}") - - weather = await weather_agent.run("What's the weather in Tokyo?") - print(f"Weather: {weather}") - finally: - await client.beta.assistants.delete(weather_agent.id) - await client.beta.assistants.delete(greeter_agent.id) - - -async def main() -> None: - print("OpenAI Assistant Provider Methods") - - await create_agent_example() - await get_agent_example() - await as_agent_example() - await multiple_agents_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py deleted file mode 100644 index 044804e3c5..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import AgentResponseUpdate, ChatResponseUpdate -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from dotenv import load_dotenv -from openai import AsyncOpenAI -from openai.types.beta.threads.runs import ( - CodeInterpreterToolCallDelta, - RunStepDelta, - RunStepDeltaEvent, - ToolCallDeltaObject, -) -from openai.types.beta.threads.runs.code_interpreter_tool_call_delta import CodeInterpreter - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Code Interpreter Example - -This sample demonstrates using get_code_interpreter_tool() with OpenAI Assistants -for Python code execution and mathematical problem solving. -""" - - -def get_code_interpreter_chunk(chunk: AgentResponseUpdate) -> str | None: - """Helper method to access code interpreter data.""" - if ( - isinstance(chunk.raw_representation, ChatResponseUpdate) - and isinstance(chunk.raw_representation.raw_representation, RunStepDeltaEvent) - and isinstance(chunk.raw_representation.raw_representation.delta, RunStepDelta) - and isinstance(chunk.raw_representation.raw_representation.delta.step_details, ToolCallDeltaObject) - and chunk.raw_representation.raw_representation.delta.step_details.tool_calls - ): - for tool_call in chunk.raw_representation.raw_representation.delta.step_details.tool_calls: - if ( - isinstance(tool_call, CodeInterpreterToolCallDelta) - and isinstance(tool_call.code_interpreter, CodeInterpreter) - and tool_call.code_interpreter.input is not None - ): - return tool_call.code_interpreter.input - return None - - -async def main() -> None: - """Example showing how to use the code interpreter tool with OpenAI Assistants.""" - print("=== OpenAI Assistants Provider with Code Interpreter Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - chat_client = OpenAIAssistantsClient(client=client) - - agent = await provider.create_agent( - name="CodeHelper", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=[chat_client.get_code_interpreter_tool()], - ) - - try: - query = "Use code to get the factorial of 100?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - generated_code = "" - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - code_interpreter_chunk = get_code_interpreter_chunk(chunk) - if code_interpreter_chunk is not None: - generated_code += code_interpreter_chunk - - print(f"\nGenerated code:\n{generated_code}") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py deleted file mode 100644 index 563dbb38a4..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Existing Assistant Example - -This sample demonstrates working with pre-existing OpenAI Assistants -using the provider's get_agent() and as_agent() methods. -""" - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def example_get_agent_by_id() -> None: - """Example: Using get_agent() to retrieve an existing assistant by ID.""" - print("=== Get Existing Assistant by ID ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create an assistant via SDK (simulating an existing assistant) - created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="WeatherAssistant", - tools=[ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the weather for a given location.", - "parameters": { - "type": "object", - "properties": {"location": {"type": "string", "description": "The location"}}, - "required": ["location"], - }, - }, - } - ], - ) - print(f"Created assistant: {created_assistant.id}") - - try: - # Use get_agent() to retrieve the existing assistant - agent = await provider.get_agent( - assistant_id=created_assistant.id, - tools=[get_weather], # Required: implementation for function tools - instructions="You are a helpful weather agent.", - ) - - result = await agent.run("What's the weather like in Tokyo?") - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(created_assistant.id) - print("Assistant deleted.\n") - - -async def example_as_agent_wrap_sdk_object() -> None: - """Example: Using as_agent() to wrap an existing SDK Assistant object.""" - print("=== Wrap Existing SDK Assistant Object ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create and fetch an assistant via SDK - created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="SimpleAssistant", - instructions="You are a friendly assistant.", - ) - print(f"Created assistant: {created_assistant.id}") - - try: - # Use as_agent() to wrap the SDK object - agent = Agent( - client=provider, - agent=created_assistant, - instructions="You are an extremely helpful assistant. Be enthusiastic!", - ) - - result = await agent.run("Hello! What can you help me with?") - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(created_assistant.id) - print("Assistant deleted.\n") - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Existing Assistant Examples ===\n") - - await example_get_agent_by_id() - await example_as_agent_wrap_sdk_object() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py deleted file mode 100644 index d7adef004c..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Explicit Settings Example - -This sample demonstrates creating OpenAI Assistants with explicit configuration -settings rather than relying on environment variable defaults. -""" - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Explicit Settings ===") - - # Create client with explicit API key - client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"]) - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ["OPENAI_MODEL"], - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in New York?" - print(f"Query: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py deleted file mode 100644 index ad67986d4e..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import Content -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from dotenv import load_dotenv -from openai import AsyncOpenAI - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with File Search Example - -This sample demonstrates using get_file_search_tool() with OpenAI Assistants -for document-based question answering and information retrieval. -""" - - -async def create_vector_store(client: AsyncOpenAI) -> tuple[str, Content]: - """Create a vector store with sample documents.""" - file = await client.files.create( - file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data" - ) - vector_store = await client.vector_stores.create( - name="knowledge_base", - expires_after={"anchor": "last_active_at", "days": 1}, - ) - result = await client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: AsyncOpenAI, file_id: str, vector_store_id: str) -> None: - """Delete the vector store after using it.""" - await client.vector_stores.delete(vector_store_id=vector_store_id) - await client.files.delete(file_id=file_id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider with File Search Example ===\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - chat_client = OpenAIAssistantsClient(client=client) - - agent = await provider.create_agent( - name="SearchAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that searches files in a knowledge base.", - tools=[chat_client.get_file_search_tool()], - ) - - try: - query = "What is the weather today? Do a file search to find the answer." - file_id, vector_store_content = await create_vector_store(client) - - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run( - query, - stream=True, - options={"tool_resources": {"file_search": {"vector_store_ids": [vector_store_content.vector_store_id]}}}, - ): - if chunk.text: - print(chunk.text, end="", flush=True) - - await delete_vector_store(client, file_id, vector_store_content.vector_store_id) - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py deleted file mode 100644 index ffd64d9ca2..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from datetime import datetime, timezone -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Function Tools Example - -This sample demonstrates function tool integration with OpenAI Assistants, -showing both agent-level and query-level tool configuration patterns. -""" - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -@tool(approval_mode="never_require") -def get_time() -> str: - """Get the current UTC time.""" - current_time = datetime.now(timezone.utc) - return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." - - -async def tools_on_agent_level() -> None: - """Example showing tools defined when creating the agent.""" - print("=== Tools Defined on Agent Level ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - agent = await provider.create_agent( - name="InfoAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that can provide weather and time information.", - tools=[get_weather, get_time], # Tools defined at agent creation - ) - - try: - # First query - agent can use weather tool - query1 = "What's the weather like in New York?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query - agent can use time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - - # Third query - agent can use both tools if needed - query3 = "What's the weather in London and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3) - print(f"Agent: {result3}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def tools_on_run_level() -> None: - """Example showing tools passed to the run method.""" - print("=== Tools Passed to Run Method ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Agent created with base tools, additional tools can be passed at run time - agent = await provider.create_agent( - name="FlexibleAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant.", - tools=[get_weather], # Base tool - ) - - try: - # First query using base weather tool - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query with additional time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2, tools=[get_time]) # Additional tool for this query - print(f"Agent: {result2}\n") - - # Third query with both tools - query3 = "What's the weather in Chicago and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3, tools=[get_time]) # Time tool adds to weather - print(f"Agent: {result3}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def mixed_tools_example() -> None: - """Example showing both agent-level tools and run-method tools.""" - print("=== Mixed Tools Example (Agent + Run Method) ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Agent created with some base tools - agent = await provider.create_agent( - name="ComprehensiveAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a comprehensive assistant that can help with various information requests.", - tools=[get_weather], # Base tool available for all queries - ) - - try: - # Query using both agent tool and additional run-method tools - query = "What's the weather in Denver and what's the current UTC time?" - print(f"User: {query}") - - # Agent has access to get_weather (from creation) + additional tools from run method - result = await agent.run( - query, - tools=[get_time], # Additional tools for this specific query - ) - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Function Tools Examples ===\n") - - await tools_on_agent_level() - await tools_on_run_level() - await mixed_tools_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py b/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py deleted file mode 100644 index 740b36107d..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import BaseModel, ConfigDict - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistant Provider Response Format Example - -This sample demonstrates using OpenAIAssistantProvider with response_format -for structured outputs in two ways: -1. Setting default response_format at agent creation time (default_options) -2. Overriding response_format at runtime (options parameter in agent.run) -""" - - -class WeatherInfo(BaseModel): - """Structured weather information.""" - - location: str - temperature: int - conditions: str - recommendation: str - model_config = ConfigDict(extra="forbid") - - -class CityInfo(BaseModel): - """Structured city information.""" - - city_name: str - population: int - country: str - model_config = ConfigDict(extra="forbid") - - -async def main() -> None: - """Example of using response_format at creation time and runtime.""" - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create agent with default response_format (WeatherInfo) - agent = await provider.create_agent( - name="StructuredReporter", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="Return structured JSON based on the requested format.", - default_options={"response_format": WeatherInfo}, - ) - - try: - # Request 1: Uses default response_format from agent creation - print("--- Request 1: Using default response_format (WeatherInfo) ---") - query1 = "What's the weather like in Paris today?" - print(f"User: {query1}") - - result1 = await agent.run(query1) - - try: - weather = result1.value - print("Agent:") - print(f" Location: {weather.location}") - print(f" Temperature: {weather.temperature}") - print(f" Conditions: {weather.conditions}") - print(f" Recommendation: {weather.recommendation}") - except Exception: - print(f"Failed to parse response: {result1.text}") - - # Request 2: Override response_format at runtime with CityInfo - print("\n--- Request 2: Runtime override with CityInfo ---") - query2 = "Tell me about Tokyo." - print(f"User: {query2}") - - result2 = await agent.run(query2, options={"response_format": CityInfo}) - - try: - city = result2.value - print("Agent:") - print(f" City: {city.city_name}") - print(f" Population: {city.population}") - print(f" Country: {city.country}") - except Exception: - print(f"Failed to parse response: {result2.text}") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py deleted file mode 100644 index 2259c5638d..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import AgentSession, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Session Management Example - -This sample demonstrates session management with OpenAI Assistants, showing -persistent conversation sessions and context preservation across interactions. -""" - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation (service-managed session).""" - print("=== Automatic Session Creation Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - # First conversation - no session provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no session provided, will create another new session - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def example_with_session_persistence() -> None: - """Example showing session persistence across multiple conversations.""" - print("=== Session Persistence Example ===") - print("Using the same session across multiple conversations to maintain context.\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - # Create a new session that will be reused - session = agent.create_session() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # Second conversation using the same session - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, session=session) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, session=session) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same session.\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def example_with_existing_session_id() -> None: - """Example showing how to work with an existing session ID from the service.""" - print("=== Existing Session ID Example ===") - print("Using a specific session ID to continue an existing conversation.\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # First, create a conversation and capture the session ID - existing_session_id = None - assistant_id = None - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - assistant_id = agent.id - - try: - # Start a conversation and get the session ID - session = agent.create_session() - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") - - if existing_session_id: - print("\n--- Continuing with the same session ID using get_agent ---") - - # Get the existing assistant by ID - agent2 = await provider.get_agent( - assistant_id=assistant_id, - tools=[get_weather], # Must provide function implementations - ) - - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent2.run(query2, session=session) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous session.\n") - finally: - if assistant_id: - await client.beta.assistants.delete(assistant_id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider Session Management Examples ===\n") - - await example_with_automatic_session_creation() - await example_with_session_persistence() - await example_with_existing_session_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_basic.py b/python/samples/02-agents/providers/openai/openai_responses_client_basic.py deleted file mode 100644 index c615cf3252..0000000000 --- a/python/samples/02-agents/providers/openai/openai_responses_client_basic.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import Awaitable, Callable -from random import randint -from typing import Annotated - -from agent_framework import ( - Agent, - ChatContext, - ChatResponse, - Message, - MiddlewareTermination, - Role, - chat_middleware, - tool, -) -from agent_framework.openai import OpenAIResponsesClient -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Responses Client Basic Example - -This sample demonstrates basic usage of OpenAIResponsesClient for structured -response generation, showing both streaming and non-streaming responses. -""" - - -@chat_middleware -async def security_and_override_middleware( - context: ChatContext, - call_next: Callable[[], Awaitable[None]], -) -> None: - """Function-based middleware that implements security filtering and response override.""" - print("[SecurityMiddleware] Processing input...") - - # Security check - block sensitive information - blocked_terms = ["password", "secret", "api_key", "token"] - - for message in context.messages: - if message.text: - message_lower = message.text.lower() - for term in blocked_terms: - if term in message_lower: - print(f"[SecurityMiddleware] BLOCKED: Found '{term}' in message") - - # Override the response instead of calling AI - context.result = ChatResponse( - messages=[ - Message( - role=Role.ASSISTANT, - text="I cannot process requests containing sensitive information. " - "Please rephrase your question without including passwords, secrets, or other " - "sensitive data.", - ) - ] - ) - - # Terminate middleware execution with the blocked response - raise MiddlewareTermination(result=context.result) - - # Continue to next middleware or AI execution - await call_next() - - print("[SecurityMiddleware] Response generated.") - print(type(context.result)) - - -# 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.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - agent = Agent( - client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - agent = Agent( - client=OpenAIResponsesClient( - middleware=[security_and_override_middleware], - ), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - response = agent.run(query, stream=True) - async for chunk in response: - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - print(f"Final Result: {await response.get_final_response()}") - - -async def main() -> None: - print("=== Basic OpenAI Responses Client Agent Example ===") - - await streaming_example() - await non_streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/demos/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md similarity index 93% rename from python/samples/demos/ag_ui_workflow_handoff/README.md rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md index bd9a6b6a5f..51e1c9fc1c 100644 --- a/python/samples/demos/ag_ui_workflow_handoff/README.md +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md @@ -35,9 +35,9 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h From the Python repo root: ```bash -cd /Users/evmattso/git/agent-framework/python +cd python uv sync -uv run python samples/demos/ag_ui_workflow_handoff/backend/server.py +uv run python samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py ``` Backend default URL: @@ -47,8 +47,10 @@ Backend default URL: ## 2) Install Frontend Packages (npm) +From the `python/` directory (where Step 1 left you): + ```bash -cd /Users/evmattso/git/agent-framework/python/samples/demos/ag_ui_workflow_handoff/frontend +cd samples/05-end-to-end/ag_ui_workflow_handoff/frontend npm install ``` diff --git a/python/samples/demos/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/backend/server.py rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore new file mode 100644 index 0000000000..16c69217c0 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore @@ -0,0 +1,7 @@ +# dependencies +/node_modules + +# build artifacts +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/index.html b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/index.html rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts diff --git a/python/samples/05-end-to-end/m365-agent/.env.example b/python/samples/05-end-to-end/m365-agent/.env.example index 3c21a9e91c..100c2bf69d 100644 --- a/python/samples/05-end-to-end/m365-agent/.env.example +++ b/python/samples/05-end-to-end/m365-agent/.env.example @@ -1,6 +1,6 @@ # OpenAI Configuration OPENAI_API_KEY= -OPENAI_CHAT_MODEL_ID= +OPENAI_CHAT_MODEL= # Agent 365 Agentic Authentication Configuration USE_ANONYMOUS_MODE= 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 ecd1e6f632..6962a53229 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -21,7 +21,7 @@ export USE_ANONYMOUS_MODE=True # set to false if using auth # OpenAI export OPENAI_API_KEY="..." -export OPENAI_CHAT_MODEL_ID="..." +export OPENAI_CHAT_MODEL="..." ``` ## Installing Dependencies diff --git a/python/samples/README.md b/python/samples/README.md index fa091b78bc..82a008504c 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -61,6 +61,16 @@ client = OpenAIChatClient(env_file_path="path/to/custom.env") This allows different clients to use different configuration files if needed. +For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), routing +precedence is: + +1. Explicit Azure inputs such as `credential`, `azure_endpoint`, or `api_version` +2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters +3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` + +If you keep both OpenAI and Azure variables in your shell, the generic clients stay on OpenAI until +you pass an explicit Azure input. + For the getting-started samples, you'll need at minimum: ```bash AZURE_AI_PROJECT_ENDPOINT="your-foundry-project-endpoint" diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo deleted file mode 100644 index 9c052ccd41..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[78],[78,79,80,81,82],[78,80],[77,83],[69],[67,69],[58,66,67,68,70,72],[56],[59,64,69,72],[55,72],[59,60,63,64,65,72],[59,60,61,63,64,72],[56,57,58,59,60,64,65,66,68,69,70,72],[72],[54,56,57,58,59,60,61,63,64,65,66,67,68,69,70,71],[54,72],[59,61,62,64,65,72],[63,72],[64,65,69,72],[57,67],[47,76],[46,47],[47,48,49,50,51,52,53,73,74,75,76],[49,50,51,52],[49,50,51],[49],[50],[47],[77,84]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"2448a94bdacc4085b4fd26ccb7c3f323d04a220af29a24b61703903730b68984","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"}],"root":[85],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[80,1],[83,2],[79,1],[81,3],[82,1],[84,4],[70,5],[68,6],[69,7],[57,8],[58,6],[65,9],[56,10],[61,11],[62,12],[67,13],[73,14],[72,15],[55,16],[63,17],[64,18],[59,19],[66,5],[60,20],[48,21],[47,22],[77,23],[74,24],[52,25],[50,26],[51,27],[76,28],[85,29]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index 68fc7fc564..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts deleted file mode 100644 index 340562aff1..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vite").UserConfig; -export default _default; diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js deleted file mode 100644 index 96a3b3875f..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - port: 5173, - }, -});