diff --git a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md index cd5a54de1d49..6644b3b1e001 100644 --- a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md +++ b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md @@ -1,4 +1,4 @@ -# Instructions for migrating from Semantic Kernel Agents to Agent Framework in .NET projects. +# Instructions for migrating from Semantic Kernel Agents to Agent Framework in .NET projects. ## Scope @@ -22,13 +22,13 @@ For each project that needs to be migrated, you need to do the following: solution or you could not find the project, notify user and continue with other projects). - Identify the specific Semantic Kernel agent types being used: - `ChatCompletionAgent` → `ChatClientAgent` - - `OpenAIAssistantAgent` → `assistantsClient.CreateAIAgent()` (via OpenAI Assistants client extension) - - `AzureAIAgent` → `persistentAgentsClient.CreateAIAgent()` (via Azure AI Foundry client extension) - - `OpenAIResponseAgent` → `responsesClient.CreateAIAgent()` (via OpenAI Responses client extension) + - `OpenAIAssistantAgent` → `assistantsClient.AsAIAgent()` (via OpenAI Assistants client extension) + - `AzureAIAgent` → `persistentAgentsClient.AsAIAgent()` (via Azure AI Foundry client extension) + - `OpenAIResponseAgent` → `responsesClient.AsAIAgent()` (via OpenAI Responses client extension) - `A2AAgent` → `AIAgent` (via A2A card resolver) - `BedrockAgent` → Custom implementation required (not supported) - Determine if agents are being created new or retrieved from hosted services: - - **New agents**: Use `CreateAIAgent()` methods + - **New agents**: Use `AsAIAgent()` methods - **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Azure AI Foundry @@ -105,8 +105,8 @@ After completing migration, verify these specific items: 1. **Compilation**: Execute `dotnet build` on all modified projects - zero errors required 2. **Namespace Updates**: Confirm all `using Microsoft.SemanticKernel.Agents` statements are replaced 3. **Method Calls**: Verify all `InvokeAsync` calls are changed to `RunAsync` -4. **Return Types**: Confirm handling of `AgentRunResponse` instead of `IAsyncEnumerable>` -5. **Thread Creation**: Validate all thread creation uses `agent.GetNewThread()` pattern +4. **Return Types**: Confirm handling of `AgentResponse` instead of `IAsyncEnumerable>` +5. **Thread Creation**: Validate all thread creation uses `agent.CreateSessionAsync()` pattern 6. **Tool Registration**: Ensure `[KernelFunction]` attributes are removed and `AIFunctionFactory.Create()` is used 7. **Options Configuration**: Verify `AgentRunOptions` or `ChatClientAgentRunOptions` replaces `AgentInvokeOptions` 8. **Breaking Glass**: Test `RawRepresentation` access replaces `InnerContent` access @@ -119,8 +119,8 @@ Agent Framework provides functionality for creating and managing AI agents throu Key API differences: - Agent creation: Remove Kernel dependency, use direct client-based creation - Method names: `InvokeAsync` → `RunAsync`, `InvokeStreamingAsync` → `RunStreamingAsync` -- Return types: `IAsyncEnumerable>` → `AgentRunResponse` -- Thread creation: Provider-specific constructors → `agent.GetNewThread()` +- Return types: `IAsyncEnumerable>` → `AgentResponse` +- Thread creation: Provider-specific constructors → `agent.CreateSessionAsync()` - Tool registration: `KernelPlugin` system → Direct `AIFunction` registration - Options: `AgentInvokeOptions` → Provider-specific run options (e.g., `ChatClientAgentRunOptions`) @@ -142,14 +142,14 @@ Replace these Semantic Kernel agent classes with their Agent Framework equivalen |----------------------|----------------------------|-------------------| | `IChatCompletionService` | `IChatClient` | Convert to `IChatClient` using `chatService.AsChatClient()` extensions | | `ChatCompletionAgent` | `ChatClientAgent` | Remove `Kernel` parameter, add `IChatClient` parameter | -| `OpenAIAssistantAgent` | `AIAgent` (via extension) | **New**: `OpenAIClient.GetAssistantClient().CreateAIAgent()`
**Existing**: `OpenAIClient.GetAssistantClient().GetAIAgent(assistantId)` | -| `AzureAIAgent` | `AIAgent` (via extension) | **New**: `PersistentAgentsClient.CreateAIAgent()`
**Existing**: `PersistentAgentsClient.GetAIAgent(agentId)` | -| `OpenAIResponseAgent` | `AIAgent` (via extension) | Replace with `OpenAIClient.GetOpenAIResponseClient().CreateAIAgent()` | +| `OpenAIAssistantAgent` | `AIAgent` (via extension) | **New**: `OpenAIClient.GetAssistantClient().AsAIAgent()`
**Existing**: `OpenAIClient.GetAssistantClient().GetAIAgent(assistantId)` | +| `AzureAIAgent` | `AIAgent` (via extension) | **New**: `PersistentAgentsClient.AsAIAgent()`
**Existing**: `PersistentAgentsClient.GetAIAgent(agentId)` | +| `OpenAIResponseAgent` | `AIAgent` (via extension) | Replace with `OpenAIClient.GetResponsesClient().AsAIAgent()` | | `A2AAgent` | `AIAgent` (via extension) | Replace with `A2ACardResolver.GetAIAgentAsync()` | | `BedrockAgent` | Not supported | Custom implementation required | **Important distinction:** -- **CreateAIAgent()**: Use when creating new agents in the hosted service +- **AsAIAgent()**: Use when creating new agents in the hosted service - **GetAIAgent(agentId)**: Use when retrieving existing agents from the hosted service @@ -158,16 +158,16 @@ Replace these method calls: | Semantic Kernel Method | Agent Framework Method | Parameter Changes | |----------------------|----------------------|------------------| -| `agent.InvokeAsync(message, thread, options)` | `agent.RunAsync(message, thread, options)` | Same parameters, different return type | -| `agent.InvokeStreamingAsync(message, thread, options)` | `agent.RunStreamingAsync(message, thread, options)` | Same parameters, different return type | -| `new ChatHistoryAgentThread()` | `agent.GetNewThread()` | No parameters needed | -| `new OpenAIAssistantAgentThread(client)` | `agent.GetNewThread()` | No parameters needed | -| `new AzureAIAgentThread(client)` | `agent.GetNewThread()` | No parameters needed | +| `agent.InvokeAsync(message, thread, options)` | `agent.RunAsync(message, session, options)` | Same parameters, different return type | +| `agent.InvokeStreamingAsync(message, thread, options)` | `agent.RunStreamingAsync(message, session, options)` | Same parameters, different return type | +| `new ChatHistoryAgentThread()` | `await agent.CreateSessionAsync()` | Returns `AgentSession` | +| `new OpenAIAssistantAgentThread(client)` | `await agent.CreateSessionAsync()` | Returns `AgentSession` | +| `new AzureAIAgentThread(client)` | `await agent.CreateSessionAsync()` | Returns `AgentSession` | | `thread.DeleteAsync()` | Provider-specific cleanup | Use provider client directly | Return type changes: -- `IAsyncEnumerable>` → `AgentRunResponse` -- `IAsyncEnumerable` → `IAsyncEnumerable` +- `IAsyncEnumerable>` → `AgentResponse` +- `IAsyncEnumerable` → `IAsyncEnumerable` @@ -191,8 +191,8 @@ Agent Framework changes these behaviors compared to Semantic Kernel Agents: 1. **Thread Management**: Agent Framework automatically manages thread state. Semantic Kernel required manual thread updates in some scenarios (e.g., OpenAI Responses). 2. **Return Types**: - - Non-streaming: Returns single `AgentRunResponse` instead of `IAsyncEnumerable>` - - Streaming: Returns `IAsyncEnumerable` instead of `IAsyncEnumerable` + - Non-streaming: Returns single `AgentResponse` instead of `IAsyncEnumerable>` + - Streaming: Returns `IAsyncEnumerable` instead of `IAsyncEnumerable` 3. **Tool Registration**: Agent Framework uses direct function registration without requiring `[KernelFunction]` attributes. @@ -222,6 +222,8 @@ using Microsoft.Extensions.AI; using Microsoft.Agents.AI; // Provider-specific namespaces (add only if needed): using OpenAI; // For OpenAI provider +using OpenAI.Chat; // For ChatClient.AsAIAgent() extension +using OpenAI.Responses; // For ResponsesClient.AsAIAgent() extension using Azure.AI.OpenAI; // For Azure OpenAI provider using Azure.AI.Agents.Persistent; // For Azure AI Foundry provider using Azure.Identity; // For Azure authentication @@ -254,7 +256,7 @@ AIAgent agent = new ChatClientAgent(chatClient, instructions: "You are a helpful // Method 2: Extension method (recommended) AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); ``` @@ -314,7 +316,7 @@ AIAgent agent = new ChatClientAgent(chatClient, instructions: "You are a helpful // Method 2: Extension method (recommended) AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); ``` **Required changes:** @@ -338,12 +340,12 @@ AgentThread thread = new AzureAIAgentThread(azureClient); **With this unified Agent Framework pattern:** ```csharp // Use this single pattern for all agent types: -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` **Required changes:** 1. Remove all `new [Provider]AgentThread()` constructor calls -2. Replace with `agent.GetNewThread()` method call +2. Replace with `agent.CreateSessionAsync()` method call 3. Remove provider client parameters from thread creation 4. Use the same pattern regardless of agent provider type @@ -369,7 +371,7 @@ ChatCompletionAgent agent = new() { Kernel = kernel }; [Description("Get the weather for a location")] // Keep Description attribute static string GetWeather(string location) => $"Weather in {location}"; -AIAgent agent = chatClient.CreateAIAgent( +AIAgent agent = chatClient.AsAIAgent( instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); ``` @@ -415,12 +417,12 @@ await foreach (var item in agent.InvokeAsync(userInput, thread)) { ... } static string GetWeather(string location) => $"Weather in {location}"; // Start with an existing agent -AIAgent existingAgent = chatClient.CreateAIAgent( +AIAgent existingAgent = chatClient.AsAIAgent( instructions: "You are a helpful assistant"); // Create an augmented agent with additional tools using builder middleware var augmentedAgent = existingAgent.AsBuilder() - .Use(async (chatMessages, agentThread, agentRunOptions, next, cancellationToken) => + .Use(async (chatMessages, agentSession, agentRunOptions, next, cancellationToken) => { if (agentRunOptions is ChatClientAgentRunOptions chatClientAgentRunOptions) { @@ -429,12 +431,12 @@ var augmentedAgent = existingAgent.AsBuilder() chatClientAgentRunOptions.ChatOptions.Tools.Add(AIFunctionFactory.Create(GetWeather)); } - return await next(chatMessages, agentThread, agentRunOptions, cancellationToken); + return await next(chatMessages, agentSession, agentRunOptions, cancellationToken); }) .Build(); // Use the augmented agent with the additional tools -AgentRunResponse result = await augmentedAgent.RunAsync(userInput, thread); +AgentResponse result = await augmentedAgent.RunAsync(userInput, session); ``` **Required changes:** @@ -464,7 +466,7 @@ await foreach (AgentResponseItem item in agent.InvokeAsync(u **With this Agent Framework non-streaming pattern:** ```csharp -AgentRunResponse result = await agent.RunAsync(userInput, thread, options); +AgentResponse result = await agent.RunAsync(userInput, session, options); Console.WriteLine(result); ``` @@ -478,7 +480,7 @@ await foreach (StreamingChatMessageContent update in agent.InvokeStreamingAsync( **With this Agent Framework streaming pattern:** ```csharp -await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(userInput, thread, options)) +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(userInput, session, options)) { Console.Write(update); } @@ -487,8 +489,8 @@ await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(userInpu **Required changes:** 1. Replace `agent.InvokeAsync()` with `agent.RunAsync()` 2. Replace `agent.InvokeStreamingAsync()` with `agent.RunStreamingAsync()` -3. Change return type handling from `IAsyncEnumerable>` to `AgentRunResponse` -4. Change streaming type from `StreamingChatMessageContent` to `AgentRunResponseUpdate` +3. Change return type handling from `IAsyncEnumerable>` to `AgentResponse` +4. Change streaming type from `StreamingChatMessageContent` to `AgentResponseUpdate` 5. Remove `await foreach` for non-streaming calls 6. Access message content directly from result object instead of iterating @@ -537,7 +539,7 @@ services.AddTransient(sp => new() services.AddTransient(sp => new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are helpful")); + .AsAIAgent(instructions: "You are helpful")); ``` **Required changes:** @@ -564,11 +566,11 @@ For every thread created if there's intent to cleanup, the caller should track a ```csharp // For OpenAI Assistants (when cleanup is needed): var assistantClient = new OpenAIClient(apiKey).GetAssistantClient(); -await assistantClient.DeleteThreadAsync(thread.ConversationId); +await assistantClient.DeleteThreadAsync(session.ConversationId); // For Azure AI Foundry (when cleanup is needed): var persistentClient = new PersistentAgentsClient(endpoint, credential); -await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId); +await persistentClient.Threads.DeleteThreadAsync(session.ConversationId); // No thread and agent cleanup is needed for non-hosted agent providers like // - Azure OpenAI Chat Completion @@ -580,7 +582,7 @@ await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId); **Required changes:** 1. Remove `thread.DeleteAsync()` calls 2. Use provider-specific client for cleanup when required -3. Access thread ID via `thread.ConversationId` property +3. Access thread ID via `session.ConversationId` property 4. Only implement cleanup for providers that require it (Assistants, Azure AI Foundry) @@ -593,14 +595,14 @@ Use these exact patterns for each provider: ```csharp AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: instructions); + .AsAIAgent(instructions: instructions); ``` **OpenAI Assistants (New):** ```csharp AIAgent agent = new OpenAIClient(apiKey) .GetAssistantClient() - .CreateAIAgent(modelId, instructions: instructions); + .AsAIAgent(modelId, instructions: instructions); ``` **OpenAI Assistants (Existing):** @@ -614,13 +616,13 @@ AIAgent agent = new OpenAIClient(apiKey) ```csharp AIAgent agent = new AzureOpenAIClient(endpoint, credential) .GetChatClient(deploymentName) - .CreateAIAgent(instructions: instructions); + .AsAIAgent(instructions: instructions); ``` **Azure AI Foundry (New):** ```csharp AIAgent agent = new PersistentAgentsClient(endpoint, credential) - .CreateAIAgent(model: deploymentName, instructions: instructions); + .AsAIAgent(model: deploymentName, instructions: instructions); ``` **Azure AI Foundry (Existing):** @@ -665,9 +667,9 @@ using OpenAI; AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are helpful"); + .AsAIAgent(instructions: "You are helpful"); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` @@ -691,7 +693,7 @@ kernel.Plugins.Add(plugin); static string GetWeather([Description("Location")] string location) => $"Weather in {location}"; -AIAgent agent = chatClient.CreateAIAgent( +AIAgent agent = chatClient.AsAIAgent( instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); ``` @@ -714,7 +716,7 @@ await foreach (var result in agent.InvokeAsync(input, thread, options)) ```csharp ChatClientAgentRunOptions options = new(new ChatOptions { MaxOutputTokens = 1000 }); -AgentRunResponse result = await agent.RunAsync(input, thread, options); +AgentResponse result = await agent.RunAsync(input, session, options); Console.WriteLine(result); // Access underlying content when needed: @@ -742,7 +744,7 @@ await foreach (var result in agent.InvokeAsync(input, thread, options)) **With this Agent Framework non-streaming usage pattern:** ```csharp -AgentRunResponse result = await agent.RunAsync(input, thread, options); +AgentResponse result = await agent.RunAsync(input, session, options); Console.WriteLine($"Tokens: {result.Usage.TotalTokenCount}"); ``` @@ -762,7 +764,7 @@ await foreach (StreamingChatMessageContent response in agent.InvokeStreamingAsyn **With this Agent Framework streaming usage pattern:** ```csharp -await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread, options)) +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, session, options)) { if (update.Contents.OfType().FirstOrDefault() is { } usageContent) { @@ -787,7 +789,7 @@ await foreach (var content in agent.InvokeAsync(userInput, thread)) **With this Agent Framework breaking glass pattern:** ```csharp -var agentRunResponse = await agent.RunAsync(userInput, thread); +var agentRunResponse = await agent.RunAsync(userInput, session); // If the agent uses a ChatClient the first breaking glass probably will be a Microsoft.Extensions.AI.ChatResponse ChatResponse? chatResponse = agentRunResponse.RawRepresentation as ChatResponse; @@ -829,7 +831,7 @@ await foreach (var content in agent.InvokeAsync(userInput, thread)) **With this Agent Framework CodeInterpreter pattern:** ```csharp -var result = await agent.RunAsync(userInput, thread); +var result = await agent.RunAsync(userInput, session); Console.WriteLine(result); // Extract chat response MEAI type via first level breaking glass @@ -919,7 +921,7 @@ var openAIResponse = chatCompletion.GetRawResponse(); **Issue: Thread Type Mismatches** - **Problem**: Provider-specific thread constructors not found -- **Solution**: Replace all thread constructors with `agent.GetNewThread()` +- **Solution**: Replace all thread constructors with `agent.CreateSessionAsync()` **Issue: Options Configuration** - **Problem**: `AgentInvokeOptions` type not found @@ -937,7 +939,7 @@ var openAIResponse = chatCompletion.GetRawResponse(); 2. **Update Namespaces**: Replace SK namespaces with AF namespaces 3. **Update Agent Creation**: Remove Kernel, use direct client creation 4. **Update Method Calls**: Replace `InvokeAsync` with `RunAsync` -5. **Update Thread Creation**: Replace provider-specific constructors with `GetNewThread()` +5. **Update Thread Creation**: Replace provider-specific constructors with `await agent.CreateSessionAsync()` 6. **Update Tool Registration**: Remove attributes, use `AIFunctionFactory.Create()` 7. **Update Options**: Replace `AgentInvokeOptions` with provider-specific options 8. **Test and Validate**: Compile and test all functionality @@ -988,9 +990,9 @@ using OpenAI; AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` ### 2. Azure OpenAI Chat Completion Migration @@ -1038,7 +1040,7 @@ using Azure.Identity; AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant"); + .AsAIAgent(instructions: "You are a helpful assistant"); ``` ### 3. OpenAI Assistants Migration @@ -1083,12 +1085,12 @@ using OpenAI; AIAgent agent = new OpenAIClient(apiKey) .GetAssistantClient() - .CreateAIAgent(modelId, instructions: "You are a helpful assistant"); + .AsAIAgent(modelId, instructions: "You are a helpful assistant"); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); // Cleanup when needed -await assistantClient.DeleteThreadAsync(thread.ConversationId); +await assistantClient.DeleteThreadAsync(session.ConversationId); ``` **Retrieving an existing assistant:** @@ -1100,7 +1102,7 @@ AIAgent agent = new OpenAIClient(apiKey) .GetAssistantClient() .GetAIAgent(assistantId); // Use existing assistant ID -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` @@ -1163,12 +1165,12 @@ using Azure.Identity; var client = new PersistentAgentsClient(endpoint, new AzureCliCredential()); // Create a new AIAgent using Agent Framework -AIAgent agent = client.CreateAIAgent( +AIAgent agent = client.AsAIAgent( model: deploymentName, instructions: "You are a helpful assistant", tools: [/* List of specialized Azure.AI.Agents.Persistent.ToolDefinition types */]); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` **Retrieving an existing agent:** @@ -1182,7 +1184,7 @@ var client = new PersistentAgentsClient(endpoint, new AzureCliCredential()); // Retrieve an existing AIAgent using its ID AIAgent agent = await client.GetAIAgentAsync(agentId); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); ``` @@ -1269,20 +1271,21 @@ await foreach (AgentResponseItem responseItem in responseIte Agent Framework automatically manages the thread, so there's no need to manually update it. ```csharp -using Microsoft.Agents.AI.OpenAI; +using OpenAI.Chat; // For ChatClient.AsAIAgent() +using OpenAI.Responses; // For ResponsesClient.AsAIAgent() AIAgent agent = new OpenAIClient(apiKey) - .GetOpenAIResponseClient(modelId) - .CreateAIAgent( + .GetResponsesClient(modelId) + .AsAIAgent( name: "ResponseAgent", instructions: "Answer all queries in English and French.", tools: [/* AITools */]); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); -var result = await agent.RunAsync(userInput, thread); +var result = await agent.RunAsync(userInput, session); -// The thread will be automatically updated with the new response id from this point +// The session will be automatically updated with the new response id from this point ``` @@ -1336,21 +1339,22 @@ await foreach (AgentResponseItem responseItem in responseIte Agent Framework automatically manages the thread, so there's no need to manually update it. ```csharp -using Microsoft.Agents.AI.OpenAI; +using OpenAI.Chat; // For ChatClient.AsAIAgent() +using OpenAI.Responses; // For ResponsesClient.AsAIAgent() using Azure.AI.OpenAI; AIAgent agent = new AzureOpenAIClient(endpoint, new AzureCliCredential()) - .GetOpenAIResponseClient(modelId) - .CreateAIAgent( + .GetResponsesClient(modelId) + .AsAIAgent( name: "ResponseAgent", instructions: "Answer all queries in English and French.", tools: [/* AITools */]); -AgentThread thread = agent.GetNewThread(); +AgentSession session = await agent.CreateSessionAsync(); -var result = await agent.RunAsync(userInput, thread); +var result = await agent.RunAsync(userInput, session); -// The thread will be automatically updated with the new response id from this point +// The session will be automatically updated with the new response id from this point ``` @@ -1513,7 +1517,7 @@ AITool[] tools = AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent( + .AsAIAgent( instructions: "You are a weather assistant", tools: tools); ``` @@ -1562,10 +1566,10 @@ var renderedTemplate = await new KernelPromptTemplateFactory() AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(modelId) - .CreateAIAgent(instructions: renderedTemplate); + .AsAIAgent(instructions: renderedTemplate); // No template variables in invocation - use plain string -var result = await agent.RunAsync("What's the weather?", thread); +var result = await agent.RunAsync("What's the weather?"); Console.WriteLine(result); ``` diff --git a/README.md b/README.md index ee5a6ebd6571..02cab29b8f98 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Semantic Kernel +> [!IMPORTANT] +> Semantic Kernel is now [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel. Microsoft Agent Framework is now available at version 1.0 as a production-ready release: stable APIs, and a commitment to long-term support. Whether you're building a single assistant or orchestrating a fleet of specialized agents, Microsoft Agent Framework 1.0 gives you enterprise-grade multi-agent orchestration, multi-provider model support, and cross-runtime interoperability via A2A and MCP. +> +> Learn more about Semantic Kernel and Agent Framework here: [Semantic Kernel and Microsoft Agent Framework on the Agent Framework blog](https://devblogs.microsoft.com/agent-framework/semantic-kernel-and-microsoft-agent-framework/), and try out the [Semantic Kernel migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel). + **Build intelligent AI agents and multi-agent systems with this enterprise-ready orchestration framework** [![License: MIT](https://img.shields.io/github/license/microsoft/semantic-kernel)](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE) @@ -7,7 +12,6 @@ [![Nuget package](https://img.shields.io/nuget/vpre/Microsoft.SemanticKernel)](https://www.nuget.org/packages/Microsoft.SemanticKernel/) [![Discord](https://img.shields.io/discord/1063152441819942922?label=Discord&logo=discord&logoColor=white&color=d82679)](https://aka.ms/SKDiscord) - ## What is Semantic Kernel? Semantic Kernel is a model-agnostic SDK that empowers developers to build, orchestrate, and deploy AI agents and multi-agent systems. Whether you're building a simple chatbot or a complex multi-agent workflow, Semantic Kernel provides the tools you need with enterprise-grade reliability and flexibility. diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 95db60cb6f98..10568b08f85f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -24,8 +24,8 @@ - - + + @@ -50,10 +50,10 @@ - - - - + + + + @@ -65,7 +65,7 @@ - + @@ -73,10 +73,11 @@ - - - - + + + + + @@ -92,27 +93,30 @@ - - - - - - - + + + + + + + + + + - + - + - - - + + + @@ -120,10 +124,10 @@ - - + + - + @@ -132,14 +136,14 @@ - + - + @@ -152,7 +156,7 @@ - + @@ -168,7 +172,10 @@ - + + + + diff --git a/dotnet/README.md b/dotnet/README.md index b208a8f65335..f65bc4d5f49a 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,5 +1,10 @@ # Get Started with Semantic Kernel ⚡ +> [!IMPORTANT] +> Semantic Kernel is now [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel. Microsoft Agent Framework is now available at version 1.0 as a production-ready release: stable APIs, and a commitment to long-term support. Whether you're building a single assistant or orchestrating a fleet of specialized agents, Microsoft Agent Framework 1.0 gives you enterprise-grade multi-agent orchestration, multi-provider model support, and cross-runtime interoperability via A2A and MCP. +> +> Learn more about Semantic Kernel and Agent Framework here: [Semantic Kernel and Microsoft Agent Framework on the Agent Framework blog](https://devblogs.microsoft.com/agent-framework/semantic-kernel-and-microsoft-agent-framework/), and try out the [Semantic Kernel migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel). + ## OpenAI / Azure OpenAI API keys To run the LLM prompts and semantic functions in the examples below, make sure diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 016deb5eaf36..bb49ccbb2a4f 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,7 +1,7 @@ - 1.74.0 + 1.76.0 $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix) @@ -9,7 +9,7 @@ true - 1.73.0 + 1.75.0 $(NoWarn);CP0003 diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj index 64306d74be5a..6ba4a6f1efe4 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs index ac6c2764349f..e1bd615ff139 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step01_Concurrent/Program.cs @@ -79,13 +79,13 @@ async Task AFConcurrentAgentWorkflow() var spanishAgent = GetAFTranslationAgent("Spanish", client); var concurrentAgentWorkflow = AgentWorkflowBuilder.BuildConcurrent([frenchAgent, spanishAgent]); - await using StreamingRun run = await InProcessExecution.StreamAsync(concurrentAgentWorkflow, "Hello, world!"); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(concurrentAgentWorkflow, "Hello, world!"); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); string? lastExecutorId = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentRunUpdateEvent e) + if (evt is AgentResponseUpdateEvent e) { if (string.IsNullOrEmpty(e.Update.Text)) { diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj index 64306d74be5a..6ba4a6f1efe4 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs index e317ac0ff873..81507d67ff00 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step02_Sequential/Program.cs @@ -82,13 +82,13 @@ async Task AFSequentialAgentWorkflow() var sequentialAgentWorkflow = AgentWorkflowBuilder.BuildSequential( [frenchAgent, spanishAgent, englishAgent]); - await using StreamingRun run = await InProcessExecution.StreamAsync(sequentialAgentWorkflow, "Hello, world!"); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(sequentialAgentWorkflow, "Hello, world!"); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); string? lastExecutorId = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentRunUpdateEvent e) + if (evt is AgentResponseUpdateEvent e) { if (string.IsNullOrEmpty(e.Update.Text)) { diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj index 64306d74be5a..6ba4a6f1efe4 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs index f66fefe535bf..9d29efbd7fad 100644 --- a/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AgentOrchestrations/Step03_Handoff/Program.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable MAAIW001 // Experimental: HandoffWorkflowBuilder + using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; @@ -207,13 +209,13 @@ async Task AFHandoffAgentWorkflow() Console.WriteLine($"User: {query}"); messages.Add(new(ChatRole.User, query)); - await using var run = await InProcessExecution.StreamAsync(handoffAgentWorkflow, messages); + await using var run = await InProcessExecution.RunStreamingAsync(handoffAgentWorkflow, messages); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); string? lastExecutorId = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentRunUpdateEvent e) + if (evt is AgentResponseUpdateEvent e) { if (string.IsNullOrEmpty(e.Update.Text) && e.Update.Contents.Count == 0) { diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj index ae6b2961bf06..b48fbf454754 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs index 58e15a23c117..b608400c3312 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step01_Basics/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.SemanticKernel; @@ -67,7 +68,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -80,9 +81,9 @@ async Task SKAgent_As_AFAgentAsync() } // Clean up - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -91,29 +92,23 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); + // AF 1.0: Use AIProjectClient.AsAIAgent() from Microsoft.Agents.AI.Foundry + var projectClient = new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential()); - var agent = await azureAgentClient.CreateAIAgentAsync( + var agent = projectClient.AsAIAgent( deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); + instructions: "You are good at telling jokes.", + name: "GenerateStory"); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj index 8928edd44064..a8f443dc5012 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs index 8742cbcf61e1..cc586560740c 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step02_ToolCall/Program.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -85,7 +86,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { Tools = [AIFunctionFactory.Create(GetWeather)] }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -98,9 +99,9 @@ async Task SKAgent_As_AFAgentAsync() } // Clean up - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -109,26 +110,22 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); + var agent = new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential()) + .AsAIAgent(model: deploymentName, + instructions: "You are a helpful assistant", + tools: [AIFunctionFactory.Create(GetWeather)]); - var agent = await azureAgentClient.CreateAIAgentAsync(deploymentName, instructions: "Answer questions about the menu"); + var session = await agent.CreateSessionAsync(); + var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { Tools = [AIFunctionFactory.Create(GetWeather)] }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); + // No cleanup needed - non-hosted path doesn't create server-side resources. } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj index 7631ed8887f5..60aee8306b56 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs index 7b3d5a8245c7..12f82b250de6 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -90,7 +91,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var result = await agent.RunAsync(userInput, thread); Console.WriteLine(result); @@ -103,9 +104,9 @@ async Task SKAgent_As_AFAgentAsync() // Clean up var azureAgentClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -115,36 +116,29 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new PersistentAgentsClient(azureEndpoint, new AzureCliCredential())); + serviceCollection.AddSingleton((sp) => new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential())); serviceCollection.AddTransient((sp) => { - var azureAgentClient = sp.GetRequiredService(); - - return azureAgentClient.CreateAIAgent( - deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); + var client = sp.GetRequiredService(); + return client.AsAIAgent( + deploymentName, + instructions: "You are good at telling jokes.", + name: "GenerateStory"); }); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); - var result = await agent.RunAsync(userInput, thread); + var result = await agent.RunAsync(userInput, session); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread)) + await foreach (var update in agent.RunStreamingAsync(userInput, session)) { Console.Write(update); } - // Clean up - var azureAgentClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); + // No cleanup needed - non-hosted path doesn't create server-side resources. } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj index e56e323b72a4..a8f443dc5012 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj @@ -1,4 +1,4 @@ - + Exe @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs index 4ad372455740..11d0e2bd82e0 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs @@ -2,6 +2,7 @@ using System.Text; using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -76,7 +77,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var result = await agent.RunAsync(userInput, thread); Console.WriteLine(result); @@ -124,9 +125,9 @@ async Task SKAgent_As_AFAgentAsync() } // Clean up - if (thread is ChatClientAgentThread chatThread) + if (thread is ChatClientAgentSession chatSession) { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + await azureAgentClient.Threads.DeleteThreadAsync(chatSession.ConversationId); } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } @@ -135,59 +136,15 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); - var agent = await azureAgentClient.CreateAIAgentAsync(deploymentName, tools: [new CodeInterpreterToolDefinition()]); - var thread = agent.GetNewThread(); + // Note: Code interpreter via hosted APIs requires versioned Foundry Agents. + // This sample creates a basic agent without code interpreter capabilities. + var agent = new AIProjectClient(new Uri(azureEndpoint), new AzureCliCredential()) + .AsAIAgent(deploymentName, instructions: "You are a helpful assistant with code execution capabilities."); - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - // To capture the code interpreter input we need to break glass all the updates raw representations, to check for the RunStepDetailsUpdate type and - // get the CodeInterpreterInput property which contains the generated code. - // Note: Similar logic would needed for each individual update if used in the agent.RunStreamingAsync streaming API to aggregate or yield the generated code. - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } + var session = await agent.CreateSessionAsync(); - // Update the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } + var result = await agent.RunAsync(userInput, session); + Console.WriteLine(result); - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); + // No cleanup needed - non-hosted path doesn't create server-side resources. } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs index 76e190840b15..6c4889244d24 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step01_Basics/Program.cs @@ -6,7 +6,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; @@ -65,7 +65,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -83,9 +83,9 @@ async Task AFAgent() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs index 394415c09f18..f68c2e405b07 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step02_ToolCall/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; @@ -73,7 +73,7 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgent() { var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); + .AsAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); Console.WriteLine("\n=== AF Agent Response ===\n"); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs index fb682301b515..164b2e832c4e 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; @@ -72,7 +72,7 @@ async Task AFAgent() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new AzureOpenAIClient(new(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs index 5898050f5d7a..a6b0840b5091 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI/Program.cs @@ -9,7 +9,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Plugins.OpenApi; -using OpenAI; +using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -59,7 +59,7 @@ async Task AFAgent() new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: tools); + .AsAIAgent(instructions: "You are a helpful assistant", tools: tools); // Run the agent with the OpenAPI function tools. Console.WriteLine(await agent.RunAsync("Please list the names, colors and descriptions of all the labels available in the microsoft/agent-framework repository on github.")); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs deleted file mode 100644 index b0b6ca06bfd6..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - var agent = await assistantClient.CreateAIAgentAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs deleted file mode 100644 index 9566dd7f8ddc..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -[KernelFunction] -[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."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent agent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - await foreach (var result in agent.InvokeAsync(userInput, thread)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - skAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - var agent = await assistantClient.CreateAIAgentAsync(deploymentName, - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj deleted file mode 100644 index 8696e583426e..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 548f02348032..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddKernel().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(deploymentName, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - // Create a thread for the agent conversation. - var assistantsClient = serviceProvider.GetRequiredService(); - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddKernel().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(deploymentName, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var skAgent = serviceProvider.GetRequiredService(); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddTransient((sp) => - { - var assistantClient = sp.GetRequiredService(); - - return assistantClient.CreateAIAgent(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index 324de34731e9..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await SKAgent_As_AFAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - // Respond to user input - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgent() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = await assistantsClient.CreateAIAgentAsync(deploymentName, tools: [new HostedCodeInterpreterTool()]); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs index 5f3552b2beab..8637a035cf4a 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step01_Basics/Program.cs @@ -5,7 +5,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -66,7 +66,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -84,17 +84,17 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs index dc864675fbf9..5941a4de0097 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs @@ -7,7 +7,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI; using Microsoft.SemanticKernel.ChatCompletion; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -134,7 +134,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -180,10 +180,10 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(name: "Thinker", instructions: "You are good at thinking hard before answering."); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, name: "Thinker", instructions: "You are good at thinking hard before answering."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -194,7 +194,7 @@ async Task AFAgentAsync() } }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); // Retrieve the thinking as a full text block requires flattening multiple TextReasoningContents from multiple messages content lists. string assistantThinking = string.Join("\n", result.Messages @@ -207,7 +207,7 @@ async Task AFAgentAsync() Console.WriteLine($"Assistant: \n{assistantText}\n---\n"); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { var thinkingContents = update.Contents .OfType() diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs index 04b25861c54d..613b04ed914c 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -66,8 +66,8 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgentAsync() { var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); Console.WriteLine("\n=== AF Agent Response ===\n"); diff --git a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs index a7b612c0a277..dfaf6a6a712e 100644 --- a/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -71,8 +71,8 @@ async Task AFAgentAsync() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetResponsesClient().AsIChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs index 9734b8abb016..9e7189c7bcfe 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step01_Basics/Program.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Connectors.OpenAI; using OpenAI; +using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; @@ -64,7 +65,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -83,9 +84,9 @@ async Task AFAgentAsync() Console.WriteLine("\n=== AF Agent ===\n"); var agent = new OpenAIClient(apiKey).GetChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs index 00c687ca568e..333d7cb14afd 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step02_ToolCall/Program.cs @@ -6,6 +6,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using OpenAI; +using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; @@ -65,7 +66,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -86,7 +87,7 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgentAsync() { - var agent = new OpenAIClient(apiKey).GetChatClient(model).CreateAIAgent( + var agent = new OpenAIClient(apiKey).GetChatClient(model).AsAIAgent( instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs index 0d8890d9dc8b..49c614517fc1 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAI/Step03_DependencyInjection/Program.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using OpenAI; +using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; @@ -70,7 +71,7 @@ async Task AFAgentAsync() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new OpenAIClient(apiKey) .GetChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .AsAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs deleted file mode 100644 index 42e8c9bce711..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step01_Basics/Program.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var assistantsClient = new AssistantClient(apiKey); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -// Example of Semantic Kernel Agent code converted as an Agent Framework Agent -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var assistantsClient = new AssistantClient(apiKey); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - var afAgent = agent.AsAIAgent(); - - var thread = afAgent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await afAgent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in afAgent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AssistantClient(apiKey); - - var agent = await assistantClient.CreateAIAgentAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs deleted file mode 100644 index c50204a2f6b8..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -[KernelFunction] -[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."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AssistantClient(apiKey); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent agent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - await foreach (var result in agent.InvokeAsync(userInput, thread)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AssistantClient(apiKey); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - skAgent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AssistantClient(apiKey); - - var agent = await assistantClient.CreateAIAgentAsync(model, - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj deleted file mode 100644 index 8696e583426e..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 2a3497a7e5f0..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddKernel().AddOpenAIChatClient(model, apiKey); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(model, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - // Create a thread for the agent conversation. - var assistantsClient = serviceProvider.GetRequiredService(); - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddKernel().AddOpenAIChatClient(model, apiKey); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(model, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var skAgent = serviceProvider.GetRequiredService(); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddTransient((sp) => - { - var assistantClient = sp.GetRequiredService(); - - return assistantClient.CreateAIAgent(model, name: "Joker", instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj deleted file mode 100644 index a2b89580940c..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - enable - enable - $(NoWarn);CA1707;CA2007;VSTHRD111 - - - - - - - - - - - - - - - diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index b8c0db023c9d..000000000000 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -var assistantsClient = new AssistantClient(apiKey); - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await SKAgent_As_AFAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - // Respond to user input - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task SKAgent_As_AFAgentAsync() -{ - Console.WriteLine("\n=== SK Agent Converted as an AF Agent ===\n"); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent skAgent = new(assistant, assistantsClient); - - var agent = skAgent.AsAIAgent(); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = await assistantsClient.CreateAIAgentAsync(model, tools: [new HostedCodeInterpreterTool()]); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs index b130a3e2a0cc..c58d2fd1b89b 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step01_Basics/Program.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.Agents.OpenAI; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -63,7 +64,7 @@ async Task SKAgent_As_AFAgentAsync() var agent = skAgent.AsAIAgent(); - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); var result = await agent.RunAsync(userInput, thread, agentOptions); @@ -80,17 +81,17 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var agent = new OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); + var agent = new OpenAIClient(apiKey).GetResponsesClient() + .AsAIAgent(model: model, name: "Joker", instructions: "You are good at telling jokes."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); Console.WriteLine(result); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { Console.Write(update); } diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs index d7519abd347d..9face250635a 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs @@ -6,6 +6,7 @@ using Microsoft.SemanticKernel.Agents.OpenAI; using Microsoft.SemanticKernel.ChatCompletion; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -131,7 +132,7 @@ async Task SKAgent_As_AFAgentAsync() #pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var thread = agent.GetNewThread(); + var thread = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -176,10 +177,10 @@ async Task AFAgentAsync() { Console.WriteLine("\n=== AF Agent ===\n"); - var agent = new OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(model) - .CreateAIAgent(name: "Thinker", instructions: "You are at thinking hard before answering."); + var agent = new OpenAIClient(apiKey).GetResponsesClient() + .AsAIAgent(model: model, name: "Thinker", instructions: "You are at thinking hard before answering."); - var thread = agent.GetNewThread(); + var session = await agent.CreateSessionAsync(); var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000, @@ -190,7 +191,7 @@ async Task AFAgentAsync() } }); - var result = await agent.RunAsync(userInput, thread, agentOptions); + var result = await agent.RunAsync(userInput, session, agentOptions); // Retrieve the thinking as a full text block requires flattening multiple TextReasoningContents from multiple messages content lists. string assistantThinking = string.Join("\n", result.Messages @@ -203,7 +204,7 @@ async Task AFAgentAsync() Console.WriteLine($"Assistant: \n{assistantText}\n---\n"); Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) + await foreach (var update in agent.RunStreamingAsync(userInput, session, agentOptions)) { var thinkingContents = update.Contents .OfType() diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs index ff9fdae2d4d6..14fa1c601beb 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step03_ToolCall/Program.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -63,7 +64,8 @@ async Task SKAgent_As_AFAgentAsync() async Task AFAgentAsync() { - var agent = new OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(model).CreateAIAgent( + var agent = new OpenAIClient(apiKey).GetResponsesClient().AsAIAgent( + model: model, instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); diff --git a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs index 403ec70e6575..36245131e702 100644 --- a/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs +++ b/dotnet/samples/AgentFrameworkMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel.Agents.OpenAI; using OpenAI; +using OpenAI.Responses; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -65,8 +66,8 @@ async Task AFAgentAsync() var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient((sp) => new OpenAIClient(apiKey) - .GetResponsesClient().AsIChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); + .GetResponsesClient() + .AsAIAgent(model: model, name: "Joker", instructions: "You are good at telling jokes.")); await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); var agent = serviceProvider.GetRequiredService(); diff --git a/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs b/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs new file mode 100644 index 000000000000..3d790de41b65 --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/OpenAI_ChatCompletionExtraBody.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace ChatCompletion; + +#pragma warning disable SKEXP0010 // OpenAIPromptExecutionSettings.ExtraBody is experimental. + +/// +/// is an escape hatch that injects additional fields +/// into the request body sent to the OpenAI-compatible endpoint. Use it to pass vendor-specific or preview +/// parameters that are not modeled by (for example, +/// Qwen3's enable_thinking flag, ChatGLM thinking modes, or NVIDIA NIM custom knobs). +/// +/// Key syntax: +/// +/// A plain key (without leading $.) is treated as a literal top-level field name. +/// A key starting with $. is interpreted as a JSONPath expression and applied as a deep patch onto the request body. +/// +/// +public class OpenAI_ChatCompletionExtraBody(ITestOutputHelper output) : BaseTest(output) +{ + /// + /// Adds a flat top-level field to the outgoing chat completion request: + /// { "messages": [...], "model": "qwen-plus", "enable_thinking": false, ... } + /// + [Fact] + public async Task FlatFieldExampleAsync() + { + OpenAIChatCompletionService chatCompletionService = new( + TestConfiguration.OpenAI.ChatModelId, + TestConfiguration.OpenAI.ApiKey); + + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + // Required by Qwen3 open-source models for non-streaming calls. + ["enable_thinking"] = false, + }, + }; + + var chatHistory = new ChatHistory("You are a helpful assistant."); + chatHistory.AddUserMessage("Who are you?"); + + var reply = await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings); + + Console.WriteLine(reply.Content); + } + + /// + /// Uses a $.-prefixed JSONPath key to surgically patch a nested field in the request body + /// (for example, into thinking.enabled). This avoids having to specify the entire nested object. + /// + [Fact] + public async Task DeepPatchExampleAsync() + { + OpenAIChatCompletionService chatCompletionService = new( + TestConfiguration.OpenAI.ChatModelId, + TestConfiguration.OpenAI.ApiKey); + + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + // Emits { "thinking": { "enabled": false } } in the request body. + ["$.thinking.enabled"] = false, + }, + }; + + var chatHistory = new ChatHistory("You are a helpful assistant."); + chatHistory.AddUserMessage("Summarize the plot of Hamlet in one sentence."); + + var reply = await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings); + + Console.WriteLine(reply.Content); + } +} diff --git a/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj b/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj index 0b8e2c9c41a2..7fac79459d9a 100644 --- a/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj +++ b/dotnet/samples/Demos/BookingRestaurant/BookingRestaurant.csproj @@ -20,9 +20,9 @@ - - - + + + diff --git a/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj b/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj index 2b245bf45b41..e75746d4b69e 100644 --- a/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj +++ b/dotnet/samples/Demos/TelemetryWithAppInsights/TelemetryWithAppInsights.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs index ca7558cd6a81..6446dbde6070 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs @@ -27,7 +27,7 @@ public async Task UseCodeInterpreterToolWithAgent() // Respond to user input try { - await InvokeAgentAsync("Use code to determine the values in the Fibonacci sequence that that are less then the value of 101?"); + await InvokeAgentAsync("Use code to determine the values in the Fibonacci sequence that are less than the value of 101?"); } finally { diff --git a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs index 08407e0ae801..1408c5d21814 100644 --- a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs +++ b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs @@ -50,7 +50,7 @@ public SemanticKernelAIAgent( } /// - public override string Id => this._innerAgent.Id; + protected override string? IdCore => this._innerAgent.Id; /// public override string? Name => this._innerAgent.Name; @@ -59,19 +59,31 @@ public SemanticKernelAIAgent( public override string? Description => this._innerAgent.Description; /// - public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) - => new SemanticKernelAIAgentThread(this._threadDeserializationFactory(serializedThread, jsonSerializerOptions), this._threadSerializer); + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new SemanticKernelAIAgentSession(this._threadFactory())); /// - public override MAAI.AgentThread GetNewThread() => new SemanticKernelAIAgentThread(this._threadFactory(), this._threadSerializer); + protected override ValueTask SerializeSessionCoreAsync(MAAI.AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not SemanticKernelAIAgentSession typedSession) + { + throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used."); + } + + return new(this._threadSerializer(typedSession.InnerThread, jsonSerializerOptions)); + } + + /// + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new SemanticKernelAIAgentSession(this._threadDeserializationFactory(serializedState, jsonSerializerOptions))); /// - public override async Task RunAsync(IEnumerable messages, MAAI.AgentThread? thread = null, MAAI.AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override async Task RunCoreAsync(IEnumerable messages, MAAI.AgentSession? session = null, MAAI.AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - thread ??= this.GetNewThread(); - if (thread is not SemanticKernelAIAgentThread typedThread) + session ??= await this.CreateSessionCoreAsync(cancellationToken).ConfigureAwait(false); + if (session is not SemanticKernelAIAgentSession typedSession) { - throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); + throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used."); } List responseMessages = []; @@ -98,13 +110,14 @@ public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread, }; AgentResponseItem? lastResponseItem = null; - ChatMessage? lastResponseMessage = null; - await foreach (var responseItem in this._innerAgent.InvokeAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedThread.InnerThread, invokeOptions, cancellationToken).ConfigureAwait(false)) + await foreach (var responseItem in this._innerAgent.InvokeAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedSession.InnerThread, invokeOptions, cancellationToken).ConfigureAwait(false)) { lastResponseItem = responseItem; } - return new MAAI.AgentRunResponse(responseMessages) + var lastResponseMessage = lastResponseItem?.Message.ToChatMessage(); + + return new MAAI.AgentResponse(responseMessages) { AgentId = this._innerAgent.Id, RawRepresentation = lastResponseItem, @@ -114,23 +127,23 @@ public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread, } /// - public override async IAsyncEnumerable RunStreamingAsync( + protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, - MAAI.AgentThread? thread = null, + MAAI.AgentSession? session = null, MAAI.AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - thread ??= this.GetNewThread(); - if (thread is not SemanticKernelAIAgentThread typedThread) + session ??= await this.CreateSessionCoreAsync(cancellationToken).ConfigureAwait(false); + if (session is not SemanticKernelAIAgentSession typedSession) { - throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); + throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be used."); } - await foreach (var responseItem in this._innerAgent.InvokeStreamingAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedThread.InnerThread, cancellationToken: cancellationToken).ConfigureAwait(false)) + await foreach (var responseItem in this._innerAgent.InvokeStreamingAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedSession.InnerThread, cancellationToken: cancellationToken).ConfigureAwait(false)) { var update = responseItem.Message.ToChatResponseUpdate(); - yield return new MAAI.AgentRunResponseUpdate + yield return new MAAI.AgentResponseUpdate { AuthorName = update.AuthorName, AgentId = this._innerAgent.Id, diff --git a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentThread.cs b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentSession.cs similarity index 56% rename from dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentThread.cs rename to dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentSession.cs index 274a9a839cf0..86a8d6557adf 100644 --- a/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentThread.cs +++ b/dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgentSession.cs @@ -2,23 +2,17 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using MAAI = Microsoft.Agents.AI; namespace Microsoft.SemanticKernel.Agents; [Experimental("SKEXP0110")] -internal sealed class SemanticKernelAIAgentThread : MAAI.AgentThread +internal sealed class SemanticKernelAIAgentSession : MAAI.AgentSession { - private readonly Func _threadSerializer; - - internal SemanticKernelAIAgentThread(AgentThread thread, Func threadSerializer) + internal SemanticKernelAIAgentSession(AgentThread thread) { Throw.IfNull(thread); - Throw.IfNull(threadSerializer); - this.InnerThread = thread; - this._threadSerializer = threadSerializer; } /// @@ -26,10 +20,6 @@ internal SemanticKernelAIAgentThread(AgentThread thread, Func public AgentThread InnerThread { get; } - /// - public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) - => this._threadSerializer(this.InnerThread, jsonSerializerOptions); - /// public override object? GetService(Type serviceType, object? serviceKey = null) { diff --git a/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs b/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs index 5bdc214b4df7..b23c15dfcad1 100644 --- a/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs +++ b/dotnet/src/Agents/Bedrock/Extensions/BedrockAgentInvokeExtensions.cs @@ -214,7 +214,7 @@ private static SessionState CreateSessionStateWithFunctionResults(List { - { "TEXT", new ContentBody() { Body = FunctionCallsProcessor.ProcessFunctionResult(functionResult.Result ?? string.Empty) } } + { "TEXT", new ContentBody() { Body = GetFunctionResultAsString(functionResult.Result) } } } } }; @@ -222,4 +222,20 @@ private static SessionState CreateSessionStateWithFunctionResults(List + /// Processes a function result and returns a string representation. + /// Bedrock does not support multimodal tool results, so ImageContent returns an error message. + /// + private static string GetFunctionResultAsString(object? result) + { + var processed = FunctionCallsProcessor.ProcessFunctionResult(result ?? string.Empty); + + if (processed is ImageContent) + { + return FunctionCallsProcessor.ImageContentNotSupportedErrorMessage; + } + + return (string?)processed ?? string.Empty; + } } diff --git a/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs b/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs index 008e781fafa8..866eac8d88c5 100644 --- a/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs +++ b/dotnet/src/Agents/OpenAI/Internal/AssistantMessageFactory.cs @@ -71,8 +71,24 @@ public static IEnumerable GetMessageContents(ChatMessageContent else if (content is FunctionResultContent resultContent && resultContent.Result != null && !hasTextContent) { // Only convert a function result when text-content is not already present - yield return MessageContent.FromText(FunctionCallsProcessor.ProcessFunctionResult(resultContent.Result)); + yield return MessageContent.FromText(GetFunctionResultAsString(resultContent.Result)); } } } + + /// + /// Processes a function result and returns a string representation. + /// OpenAI Assistants do not support multimodal tool results, so ImageContent returns an error message. + /// + private static string GetFunctionResultAsString(object result) + { + var processed = FunctionCallsProcessor.ProcessFunctionResult(result); + + if (processed is ImageContent) + { + return FunctionCallsProcessor.ImageContentNotSupportedErrorMessage; + } + + return (string?)processed ?? string.Empty; + } } diff --git a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs index f9fc29120fc3..2cbddfddc721 100644 --- a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs +++ b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs @@ -93,7 +93,7 @@ await functionProcessor.InvokeFunctionCallsAsync( agent.GetKernel(options), isStreaming: false, cancellationToken).ConfigureAwait(false); - var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, fr.Result?.ToString() ?? string.Empty)).ToList(); + var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, GetFunctionResultAsString(fr.Result))).ToList(); // If store is enabled we only need to send the function output items if (agent.StoreEnabled) @@ -267,7 +267,7 @@ await functionProcessor.InvokeFunctionCallsAsync( agent.GetKernel(options), isStreaming: true, cancellationToken).ConfigureAwait(false); - var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, fr.Result?.ToString() ?? string.Empty)).ToList(); + var functionOutputItems = functionResults.Select(fr => ResponseItem.CreateFunctionCallOutputItem(fr.CallId, GetFunctionResultAsString(fr.Result))).ToList(); // If store is enabled we only need to send the function output items if (agent.StoreEnabled) @@ -318,6 +318,22 @@ private static void ThrowIfIncompleteOrFailed(OpenAIResponseAgent agent, Respons } } + /// + /// Processes a function result and returns a string representation. + /// The OpenAI Responses API does not support multimodal tool results, so ImageContent returns an error message. + /// + internal static string GetFunctionResultAsString(object? result) + { + var processed = FunctionCallsProcessor.ProcessFunctionResult(result ?? string.Empty); + + if (processed is ImageContent) + { + return FunctionCallsProcessor.ImageContentNotSupportedErrorMessage; + } + + return (string?)processed ?? string.Empty; + } + /// POCO representing function calling info. /// Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo(FunctionCallResponseItem item) diff --git a/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs index f85ee5ca0fee..45d587d3e7b8 100644 --- a/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/A2A/A2AAgentExtensionsTests.cs @@ -3,11 +3,11 @@ using System; using System.Net.Http; using System.Text.Json; +using System.Threading.Tasks; using A2A; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.A2A; using Xunit; - namespace SemanticKernel.Agents.UnitTests.A2A; public sealed class A2AAgentExtensionsTests @@ -40,7 +40,7 @@ public void AsAIAgent_WithNullA2AAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange using var httpClient = new HttpClient(); @@ -50,17 +50,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = a2aAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange using var httpClient = new HttpClient(); @@ -71,17 +71,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = a2aAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange using var httpClient = new HttpClient(); @@ -93,18 +93,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = a2aAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange using var httpClient = new HttpClient(); @@ -116,10 +116,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = a2aAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentThreadTests.cs b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentSessionTests.cs similarity index 51% rename from dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentThreadTests.cs rename to dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentSessionTests.cs index 8f730a8e22ef..d23fcc8d838e 100644 --- a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentThreadTests.cs +++ b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentSessionTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel; @@ -11,76 +10,33 @@ namespace SemanticKernel.Agents.UnitTests.AIAgent; -public sealed class SemanticKernelAIAgentThreadTests +public sealed class SemanticKernelAIAgentSessionTests { [Fact] public void Constructor_InitializesProperties() { // Arrange var threadMock = new Mock(); - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) => default; // Act - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Assert Assert.Equal(threadMock.Object, adapter.InnerThread); } - [Fact] - public void Serialize_CallsThreadSerializer() - { - // Arrange - var threadMock = new Mock(); - var serializerCallCount = 0; - var expectedJsonElement = JsonElement.Parse("{\"test\": \"value\"}"); - - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) - { - serializerCallCount++; - Assert.Same(threadMock.Object, t); - return expectedJsonElement; - } - - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); - - // Act - var result = adapter.Serialize(); - - // Assert - Assert.Equal(1, serializerCallCount); - Assert.Equal(expectedJsonElement.ToString(), result.ToString()); - } - - [Fact] - public void Serialize_WithJsonSerializerOptions_PassesOptionsToSerializer() - { - // Arrange - var threadMock = new Mock(); - var expectedOptions = new JsonSerializerOptions(); - JsonSerializerOptions? capturedOptions = null; - - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) - { - capturedOptions = o; - return default; - } - - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); + // AF 1.0: Serialize_CallsThreadSerializer test removed - serialization moved to agent. + // See SemanticKernelAIAgentTests for serialization coverage. - // Act - adapter.Serialize(expectedOptions); - - // Assert - Assert.Same(expectedOptions, capturedOptions); - } + // AF 1.0: Serialize() moved from AgentSession to AIAgent.SerializeSessionCoreAsync(). + // These tests are covered by SemanticKernelAIAgentTests instead. [Fact] public void GetService_WithAgentThreadType_ReturnsInnerThread() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(AgentThread)); @@ -94,7 +50,7 @@ public void GetService_WithAgentThreadTypeAndServiceKey_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); var serviceKey = new object(); // Act @@ -109,7 +65,7 @@ public void GetService_WithNonAgentThreadType_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(string)); @@ -123,56 +79,29 @@ public void GetService_WithNullType_ThrowsArgumentNullException() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act & Assert Assert.Throws(() => adapter.GetService(null!)); } - [Fact] - public void Serialize_WithNullOptions_CallsSerializerWithNull() - { - // Arrange - var threadMock = new Mock(); - JsonSerializerOptions? capturedOptions = new(); - - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) - { - capturedOptions = o; - return default; - } - - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, ThreadSerializer); - - // Act - adapter.Serialize(null); - - // Assert - Assert.Null(capturedOptions); - } + // AF 1.0: Serialize_WithNullOptions test removed - serialization moved to agent. [Fact] public void Constructor_WithNullThread_ThrowsArgumentNullException() { // Arrange & Act - JsonElement ThreadSerializer(AgentThread t, JsonSerializerOptions? o) => default; - Assert.Throws(() => new SemanticKernelAIAgentThread(null!, ThreadSerializer)); + Assert.Throws(() => new SemanticKernelAIAgentSession(null!)); } - [Fact] - public void Constructor_WithNullSerializer_ThrowsArgumentNullException() - { - // Arrange & Act - var threadMock = new Mock(); - Assert.Throws(() => new SemanticKernelAIAgentThread(threadMock.Object, null!)); - } + // Constructor_WithNullSerializer test removed: serializer is no longer stored on the session. [Fact] public void GetService_WithBaseClassType_ReturnsInnerThread() { // Arrange var concreteThread = new TestAgentThread(); - var adapter = new SemanticKernelAIAgentThread(concreteThread, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(concreteThread); // Act var result = adapter.GetService(typeof(AgentThread)); @@ -186,7 +115,7 @@ public void GetService_WithDerivedType_ReturnsInnerThreadWhenMatches() { // Arrange var concreteThread = new TestAgentThread(); - var adapter = new SemanticKernelAIAgentThread(concreteThread, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(concreteThread); // Act var result = adapter.GetService(typeof(TestAgentThread)); @@ -200,7 +129,7 @@ public void GetService_WithIncompatibleDerivedType_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(TestAgentThread)); @@ -214,7 +143,7 @@ public void GetService_WithInterfaceType_ReturnsNull() { // Arrange var threadMock = new Mock(); - var adapter = new SemanticKernelAIAgentThread(threadMock.Object, (t, o) => default); + var adapter = new SemanticKernelAIAgentSession(threadMock.Object); // Act var result = adapter.GetService(typeof(IServiceProvider)); diff --git a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs index ebb271a4cef7..ed178c4be09d 100644 --- a/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs +++ b/dotnet/src/Agents/UnitTests/AIAgent/SemanticKernelAIAgentTests.cs @@ -84,7 +84,7 @@ public void Constructor_WithNullThreadSerializer_ThrowsArgumentNullException() } [Fact] - public void DeserializeThread_ReturnsSemanticKernelAIAgentThread() + public async Task DeserializeThread_ReturnsSemanticKernelAIAgentSession() { // Arrange var agentMock = new Mock(); @@ -95,14 +95,14 @@ public void DeserializeThread_ReturnsSemanticKernelAIAgentThread() var json = JsonElement.Parse("{}"); // Act - var result = adapter.DeserializeThread(json); + var result = await adapter.DeserializeSessionAsync(json); // Assert - Assert.IsType(result); + Assert.IsType(result); } [Fact] - public void GetNewThread_ReturnsSemanticKernelAIAgentThread() + public async Task GetNewThread_ReturnsSemanticKernelAIAgentSession() { // Arrange var agentMock = new Mock(); @@ -111,15 +111,15 @@ public void GetNewThread_ReturnsSemanticKernelAIAgentThread() var adapter = new SemanticKernelAIAgent(agentMock.Object, () => expectedThread, (e, o) => expectedThread, ThreadSerializer); // Act - var result = adapter.GetNewThread(); + var result = await adapter.CreateSessionAsync(); // Assert - Assert.IsType(result); - Assert.Equal(expectedThread, ((SemanticKernelAIAgentThread)result).InnerThread); + Assert.IsType(result); + Assert.Equal(expectedThread, ((SemanticKernelAIAgentSession)result).InnerThread); } [Fact] - public void DeserializeThread_CallsDeserializationFactory() + public async Task DeserializeThread_CallsDeserializationFactory() { // Arrange var agentMock = new Mock(); @@ -136,15 +136,15 @@ AgentThread DeserializationFactory(JsonElement e, JsonSerializerOptions? o) var json = JsonElement.Parse("{}"); // Act - var result = adapter.DeserializeThread(json); + var result = await adapter.DeserializeSessionAsync(json); // Assert Assert.Equal(1, factoryCallCount); - Assert.IsType(result); + Assert.IsType(result); } [Fact] - public void GetNewThread_CallsThreadFactory() + public async Task GetNewThread_CallsThreadFactory() { // Arrange var agentMock = new Mock(); @@ -160,11 +160,11 @@ AgentThread ThreadFactory() var adapter = new SemanticKernelAIAgent(agentMock.Object, ThreadFactory, (e, o) => expectedThread, (t, o) => default); // Act - var result = adapter.GetNewThread(); + var result = await adapter.CreateSessionAsync(); // Assert Assert.Equal(1, factoryCallCount); - Assert.IsType(result); + Assert.IsType(result); } [Fact] @@ -211,13 +211,13 @@ async IAsyncEnumerable> MockInvokeAsync(IC yield return new AgentResponseItem(message, innerThread); } - var thread = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var thread = new SemanticKernelAIAgentSession(innerThread); // Act var result = await adapter.RunAsync("Input text", thread); // Assert - Assert.IsType(result); + Assert.IsType(result); Assert.Equal("Final response", result.Text); agentMock.Verify(a => a.InvokeAsync( It.Is>(x => x.First().Content == "Input text"), @@ -246,13 +246,13 @@ async IAsyncEnumerable> GetAsyncE yield return new AgentResponseItem(new StreamingChatMessageContent(AuthorRole.Assistant, "Final response"), innerThread); } - var thread = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var thread = new SemanticKernelAIAgentSession(innerThread); // Act var results = await adapter.RunStreamingAsync("Input text", thread).ToListAsync(); // Assert - Assert.IsType(results.First()); + Assert.IsType(results.First()); Assert.Equal("Final response", results.First().Text); agentMock.Verify(a => a.InvokeStreamingAsync( It.Is>(x => x.First().Content == "Input text"), @@ -288,13 +288,13 @@ async IAsyncEnumerable> GetEnumerableWithD yield return new AgentResponseItem(final, thread); } - var threadWrapper = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var threadWrapper = new SemanticKernelAIAgentSession(innerThread); // Act var response = await adapter.RunAsync("input", threadWrapper); // Assert - // Use reflection to inspect Messages collection inside AgentRunResponse + // Use reflection to inspect Messages collection inside AgentResponse var messages = response.Messages; var contents = messages.First().Contents; Assert.Single(contents); // Duplicate text content should have been removed @@ -327,7 +327,7 @@ async IAsyncEnumerable> GetEnumerableWithN yield return new AgentResponseItem(final, thread); } - var threadWrapper = new SemanticKernelAIAgentThread(innerThread, (t, o) => default); + var threadWrapper = new SemanticKernelAIAgentSession(innerThread); // Act var response = await adapter.RunAsync("input", threadWrapper); diff --git a/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs index 158dd689e7c2..191b475cdab5 100644 --- a/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/AgentExtensionsTests.cs @@ -2,10 +2,10 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Moq; using Xunit; - namespace SemanticKernel.Agents.UnitTests; public sealed class AgentExtensionsTests @@ -77,7 +77,7 @@ public void AsAIAgent_WithNullThreadSerializer_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_WithValidFactories_CreatesWorkingAdapter() + public async Task AsAIAgent_WithValidFactories_CreatesWorkingAdapter() { // Arrange var agentMock = new Mock(); @@ -95,17 +95,17 @@ AgentThread ThreadFactory() // Act var result = agentMock.Object.AsAIAgent(ThreadFactory, ThreadDeserializationFactory, ThreadSerializer); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); Assert.Equal(1, factoryCallCount); - Assert.IsType(thread); - Assert.Same(expectedThread, ((SemanticKernelAIAgentThread)thread).InnerThread); + Assert.IsType(thread); + Assert.Same(expectedThread, ((SemanticKernelAIAgentSession)thread).InnerThread); } [Fact] - public void AsAIAgent_WithDeserializationFactory_CreatesWorkingAdapter() + public async Task AsAIAgent_WithDeserializationFactory_CreatesWorkingAdapter() { // Arrange var agentMock = new Mock(); @@ -125,12 +125,12 @@ AgentThread ThreadDeserializationFactory(JsonElement e, JsonSerializerOptions? o // Act var result = agentMock.Object.AsAIAgent(ThreadFactory, ThreadDeserializationFactory, ThreadSerializer); var json = JsonElement.Parse("{}"); - var thread = result.DeserializeThread(json); + var thread = await result.DeserializeSessionAsync(json); // Assert Assert.NotNull(thread); Assert.Equal(1, deserializationCallCount); - Assert.IsType(thread); - Assert.Same(expectedThread, ((SemanticKernelAIAgentThread)thread).InnerThread); + Assert.IsType(thread); + Assert.Same(expectedThread, ((SemanticKernelAIAgentSession)thread).InnerThread); } } diff --git a/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs index 03dbf1c80b5d..aaf62c7fc921 100644 --- a/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/AzureAI/AzureAIAgentExtensionsTests.cs @@ -3,12 +3,12 @@ using System; using System.ClientModel.Primitives; using System.Text.Json; +using System.Threading.Tasks; using Azure.AI.Agents.Persistent; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.AzureAI; using Moq; using Xunit; - namespace SemanticKernel.Agents.UnitTests.AzureAI; public sealed class AzureAIAgentExtensionsTests @@ -49,24 +49,24 @@ public void AsAIAgent_WithNullAzureAIAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { var clientMock = new Mock(); var azureAIAgent = new AzureAIAgent(s_agentMetadata, clientMock.Object); // Act var result = azureAIAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var clientMock = new Mock(); @@ -75,17 +75,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = azureAIAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var clientMock = new Mock(); @@ -96,18 +96,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = azureAIAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var clientMock = new Mock(); @@ -118,10 +118,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = azureAIAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs index fcd68bf90a76..a7871194aeb3 100644 --- a/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/Bedrock/Extensions.cs/BedrockAgentExtensionsTests.cs @@ -59,7 +59,7 @@ public void AsAIAgent_WithNullBedrockAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -67,17 +67,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = bedrockAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -86,17 +86,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = bedrockAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -106,17 +106,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = bedrockAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var (mockClient, mockRuntimeClient) = this.CreateMockClients(); @@ -126,10 +126,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = bedrockAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs index 73185ac6d69f..aa666cad688f 100644 --- a/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/Copilot/CopilotStudioAgentExtensionsTests.cs @@ -2,12 +2,12 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.Agents.CopilotStudio.Client; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.Copilot; using Moq; using Xunit; - namespace SemanticKernel.Agents.UnitTests.Copilot; public sealed class CopilotStudioAgentExtensionsTests @@ -38,7 +38,7 @@ public void AsAIAgent_WithNullCopilotStudioAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -46,17 +46,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = copilotStudioAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -65,17 +65,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = copilotStudioAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -85,17 +85,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = copilotStudioAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var clientMock = new Mock(null, null, null, null); @@ -105,10 +105,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = copilotStudioAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs index 31bae18429f5..e47675307e9a 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentExtensionsTests.cs @@ -2,10 +2,10 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; using Xunit; - namespace SemanticKernel.Agents.UnitTests.Core; public sealed class ChatCompletionAgentExtensionsTests @@ -39,7 +39,7 @@ public void AsAIAgent_WithNullChatCompletionAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -50,17 +50,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = chatCompletionAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullChatHistory_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullChatHistory_CreatesNewThread() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -72,17 +72,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullChatHistory_CreatesNe // Act var result = chatCompletionAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidChatHistory_CreatesThreadWithHistory() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidChatHistory_CreatesThreadWithHistory() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -98,19 +98,19 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidChatHistory_CreatesT // Act var result = chatCompletionAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); var chatHistoryThread = (ChatHistoryAgentThread)threadAdapter.InnerThread; Assert.Equal(2, chatHistoryThread.ChatHistory.Count); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesChatHistory() + public async Task AsAIAgent_ThreadSerializer_SerializesChatHistory() { // Arrange var chatCompletionAgent = new ChatCompletionAgent() @@ -126,10 +126,10 @@ public void AsAIAgent_ThreadSerializer_SerializesChatHistory() var jsonElement = JsonSerializer.SerializeToElement(chatHistory); var result = chatCompletionAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.Array, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs index 85d843465b3b..eebf29e8dffe 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/Internal/AssistantMessageFactoryTests.cs @@ -5,6 +5,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents.OpenAI.Internal; using Microsoft.SemanticKernel.ChatCompletion; + using OpenAI.Assistants; using Xunit; @@ -207,4 +208,28 @@ public void VerifyAssistantMessageAdapterGetMessageWithAll() Assert.NotNull(contents); Assert.Equal(3, contents.Length); } + + /// + /// Verify that ImageContent in FunctionResultContent returns error message + /// since OpenAI Assistants do not support multimodal tool results. + /// + [Fact] + public void VerifyAssistantMessageAdapterGetMessageWithImageContentInFunctionResult() + { + // Arrange: Create a FunctionResultContent containing ImageContent + var imageData = new ReadOnlyMemory([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes + var imageContent = new ImageContent(imageData, "image/png"); + var functionResultContent = new FunctionResultContent("TestFunction", "TestPlugin", "call-id", imageContent); + ChatMessageContent message = new(AuthorRole.Tool, items: [functionResultContent]); + + // Act + MessageContent[] contents = AssistantMessageFactory.GetMessageContents(message).ToArray(); + + // Assert: Should return error message since OpenAI Assistants don't support multimodal tool results + Assert.NotNull(contents); + Assert.Single(contents); + Assert.NotNull(contents.Single().Text); + // Expected error message from FunctionCallsProcessor.ImageContentNotSupportedErrorMessage + Assert.Equal("Error: This model does not support image content in tool results.", contents.Single().Text); + } } diff --git a/dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs new file mode 100644 index 000000000000..8043250638df --- /dev/null +++ b/dotnet/src/Agents/UnitTests/OpenAI/Internal/ResponseThreadActionsTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents.OpenAI.Internal; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.OpenAI.Internal; + +/// +/// Unit tests for . +/// +public class ResponseThreadActionsTests +{ + /// + /// Verify that returns the + /// shared ImageContentNotSupportedErrorMessage when the function result is an + /// , since the OpenAI Responses API does not support multimodal tool results. + /// + [Fact] + public void VerifyResponseThreadActionsGetFunctionResultAsStringReturnsErrorMessageForImageContent() + { + // Arrange: Create an ImageContent with binary data + var imageData = new ReadOnlyMemory([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes + var imageContent = new ImageContent(imageData, "image/png"); + + // Act + string result = ResponseThreadActions.GetFunctionResultAsString(imageContent); + + // Assert + Assert.Equal("Error: This model does not support image content in tool results.", result); + } + + /// + /// Verify that returns the + /// original string verbatim when the function result is a string. + /// + [Fact] + public void VerifyResponseThreadActionsGetFunctionResultAsStringReturnsStringVerbatim() + { + // Arrange + const string Expected = "tool result text"; + + // Act + string result = ResponseThreadActions.GetFunctionResultAsString(Expected); + + // Assert + Assert.Equal(Expected, result); + } + + /// + /// Verify that returns + /// when the function result is . + /// + [Fact] + public void VerifyResponseThreadActionsGetFunctionResultAsStringReturnsEmptyForNull() + { + // Act + string result = ResponseThreadActions.GetFunctionResultAsString(null); + + // Assert + Assert.Equal(string.Empty, result); + } +} diff --git a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs index bd6493907b28..7c62e0037550 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentExtensionsTests.cs @@ -3,12 +3,12 @@ using System; using System.ClientModel.Primitives; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.OpenAI; using Moq; using OpenAI.Assistants; using Xunit; - namespace SemanticKernel.Agents.UnitTests.OpenAI; public sealed class OpenAIAssistantAgentExtensionsTests @@ -54,7 +54,7 @@ public void AsAIAgent_WithNullOpenAIAssistantAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactory() + public async Task AsAIAgent_CreatesWorkingThreadFactory() { // Arrange var clientMock = new Mock(); @@ -62,17 +62,17 @@ public void AsAIAgent_CreatesWorkingThreadFactory() // Act var result = assistantAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var clientMock = new Mock(); @@ -81,17 +81,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = assistantAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var clientMock = new Mock(); @@ -101,18 +101,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = assistantAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var clientMock = new Mock(); @@ -122,10 +122,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = assistantAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); diff --git a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs index 8f124b33a49f..197e333c240d 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIResponseAgentExtensionsTests.cs @@ -3,12 +3,12 @@ using System; using System.ClientModel; using System.Text.Json; +using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.Agents.OpenAI; using Microsoft.SemanticKernel.ChatCompletion; using OpenAI.Responses; using Xunit; - namespace SemanticKernel.Agents.UnitTests.OpenAI; public sealed class OpenAIResponseAgentExtensionsTests @@ -39,7 +39,7 @@ public void AsAIAgent_WithNullOpenAIResponseAgent_ThrowsArgumentNullException() } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactoryStoreTrue() + public async Task AsAIAgent_CreatesWorkingThreadFactoryStoreTrue() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -50,17 +50,17 @@ public void AsAIAgent_CreatesWorkingThreadFactoryStoreTrue() // Act var result = responseAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_CreatesWorkingThreadFactoryStoreFalse() + public async Task AsAIAgent_CreatesWorkingThreadFactoryStoreFalse() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -71,17 +71,17 @@ public void AsAIAgent_CreatesWorkingThreadFactoryStoreFalse() // Act var result = responseAgent.AsAIAgent(); - var thread = result.GetNewThread(); + var thread = await result.CreateSessionAsync(); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -93,17 +93,17 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThr // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() + public async Task AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThreadWithId() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -116,18 +116,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; Assert.IsType(threadAdapter.InnerThread); Assert.Equal(threadId, threadAdapter.InnerThread.Id); } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesThreadId() + public async Task AsAIAgent_ThreadSerializer_SerializesThreadId() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -140,10 +140,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId); var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.String, serializedElement.ValueKind); @@ -151,7 +151,7 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId() } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWithEmptyChatHistory() + public async Task AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWithEmptyChatHistory() { var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); var responseAgent = new OpenAIResponseAgent(responseClient); @@ -159,18 +159,18 @@ public void AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWit // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; var chatHistoryAgentThread = Assert.IsType(threadAdapter.InnerThread); Assert.Empty(chatHistoryAgentThread.ChatHistory); } [Fact] - public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThreadWithChatHistory() + public async Task AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThreadWithChatHistory() { var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); var responseAgent = new OpenAIResponseAgent(responseClient); @@ -179,12 +179,12 @@ public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThread // Act var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Assert Assert.NotNull(thread); - Assert.IsType(thread); - var threadAdapter = (SemanticKernelAIAgentThread)thread; + Assert.IsType(thread); + var threadAdapter = (SemanticKernelAIAgentSession)thread; var chatHistoryAgentThread = Assert.IsType(threadAdapter.InnerThread); Assert.Single(chatHistoryAgentThread.ChatHistory); var firstMessage = chatHistoryAgentThread.ChatHistory[0]; @@ -193,7 +193,7 @@ public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThread } [Fact] - public void AsAIAgent_ThreadSerializer_SerializesChatHistory() + public async Task AsAIAgent_ThreadSerializer_SerializesChatHistory() { // Arrange var responseClient = new ResponsesClient(new ApiKeyCredential("apikey")); @@ -202,10 +202,10 @@ public void AsAIAgent_ThreadSerializer_SerializesChatHistory() var jsonElement = JsonSerializer.SerializeToElement(expectedChatHistory); var result = responseAgent.AsAIAgent(); - var thread = result.DeserializeThread(jsonElement); + var thread = await result.DeserializeSessionAsync(jsonElement); // Act - var serializedElement = thread.Serialize(); + var serializedElement = await result.SerializeSessionAsync(thread); // Assert Assert.Equal(JsonValueKind.Array, serializedElement.ValueKind); diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs index 7d814645d929..25dbf088986f 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs @@ -59,6 +59,20 @@ public async Task FunctionUsageMetricsLoggingHasAllNeededData() [Fact] public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() { + // Arrange: Set up the response and create the function FIRST so we can filter + // measurements by this specific function's name (avoids parallel test contamination). + this._multiMessageHandlerStub.ResponsesToReturn.Add( + new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } + ); + + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(this._mockLoggerFactory.Object); + builder.AddAzureOpenAIChatCompletion(deploymentName: "model", endpoint: "https://localhost", apiKey: "apiKey", httpClient: this._httpClient); + var kernel = builder.Build(); + + var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); + var expectedFunctionName = kernelFunction.Name; + // Set up a MeterListener to capture the measurements using MeterListener listener = new(); var isPublished = false; @@ -81,10 +95,23 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - if (instrument.Name is "semantic_kernel.function.invocation.token_usage.prompt" or - "semantic_kernel.function.invocation.token_usage.completion") + if (instrument.Name is not ("semantic_kernel.function.invocation.token_usage.prompt" or + "semantic_kernel.function.invocation.token_usage.completion")) { - measurements[instrument.Name].Add(measurement); + return; + } + + // Filter by function name tag to ignore measurements emitted by other tests + // that may run in parallel against the same global static histogram. + foreach (var tag in tags) + { + if (tag.Key == "semantic_kernel.function.name" && + tag.Value is string fnName && + fnName == expectedFunctionName) + { + measurements[instrument.Name].Add(measurement); + return; + } } }); @@ -100,17 +127,6 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.Start(); // Start the listener to begin collecting data - this._multiMessageHandlerStub.ResponsesToReturn.Add( - new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } - ); - - var builder = Kernel.CreateBuilder(); - builder.Services.AddSingleton(this._mockLoggerFactory.Object); - builder.AddAzureOpenAIChatCompletion(deploymentName: "model", endpoint: "https://localhost", apiKey: "apiKey", httpClient: this._httpClient); - var kernel = builder.Build(); - - var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); - // Act & Assert var result = await kernel.InvokeAsync(kernelFunction); diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs new file mode 100644 index 000000000000..60410fc79885 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionExtraBodyTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Services; + +/// +/// Unit tests verifying that is honored by +/// the classic path. +/// +public sealed class AzureOpenAIChatCompletionExtraBodyTests : IDisposable +{ + private readonly MultipleHttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly ChatHistory _chatHistory = [new ChatMessageContent(AuthorRole.User, "test")]; + + public AzureOpenAIChatCompletionExtraBodyTests() + { + this._messageHandlerStub = new MultipleHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + [Fact] + public async Task ExtraBodyFlatKeyAppearsAtTopLevelAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")), + }); + var settings = new AzureOpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContents[0]!); + Assert.True(body.TryGetProperty("enable_thinking", out var value)); + Assert.False(value.GetBoolean()); + } + + [Fact] + public async Task ExtraBodyOverridesFirstClassPropertyAsync() + { + // Arrange - last-write-wins. + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")), + }); + var settings = new AzureOpenAIPromptExecutionSettings + { + Temperature = 0.7, + ExtraBody = new Dictionary + { + ["temperature"] = 0.0, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContents[0]!); + Assert.Equal(0.0, body.GetProperty("temperature").GetDouble()); + } + + [Fact] + public async Task ExtraBodyJsonPathPrefixDoesDeepPatchAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")), + }); + var settings = new AzureOpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.thinking.enabled"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContents[0]!); + Assert.False(body.GetProperty("thinking").GetProperty("enabled").GetBoolean()); + } + + private static JsonElement ParseRequestBody(byte[] requestContent) + { + var json = Encoding.UTF8.GetString(requestContent); + return JsonDocument.Parse(json).RootElement; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs index cfb6df60ce22..ae4cd0494916 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs @@ -131,6 +131,8 @@ protected override ChatCompletionOptions CreateChatCompletionOptions( } } + OpenAIPromptExecutionSettings.ApplyExtraBody(options, executionSettings); + return options; } diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs index 027195a6482a..aa5f40b1206d 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationTests.cs @@ -565,6 +565,7 @@ public void ItThrowsOnLocationUrlInjectionAttempt(string maliciousLocation) [InlineData("us-central1-a")] [InlineData("northamerica-northeast1")] [InlineData("australia-southeast1")] + [InlineData("global")] public void ItAcceptsValidHostnameSegments(string validLocation) { // Arrange @@ -590,6 +591,52 @@ public void ItAcceptsValidHostnameSegments(string validLocation) Assert.Null(exception); } + [Fact] + public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync() + { + // Arrange + var client = new GeminiChatCompletionClient( + httpClient: this._httpClient, + modelId: "fake-model", + apiVersion: VertexAIVersion.V1, + bearerTokenProvider: () => new ValueTask("fake-key"), + location: "global", + projectId: "fake-project-id"); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/global/", requestUri, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldUseRegionalEndpointWhenLocationIsRegionalAsync() + { + // Arrange + var client = new GeminiChatCompletionClient( + httpClient: this._httpClient, + modelId: "fake-model", + apiVersion: VertexAIVersion.V1, + bearerTokenProvider: () => new ValueTask("fake-key"), + location: "us-central1", + projectId: "fake-project-id"); + var chatHistory = CreateSampleChatHistory(); + + // Act + await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://us-central1-aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/us-central1/", requestUri, StringComparison.Ordinal); + } + private sealed class BearerTokenGenerator() { private int _index = 0; diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs index dd28b46ddebe..2f6c443efd3e 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiCountingTokensTests.cs @@ -184,6 +184,7 @@ public void ItThrowsOnLocationUrlInjectionAttempt(string maliciousLocation) [InlineData("us-central1-a")] [InlineData("northamerica-northeast1")] [InlineData("australia-southeast1")] + [InlineData("global")] public void ItAcceptsValidHostnameSegments(string validLocation) { // Arrange @@ -209,6 +210,50 @@ public void ItAcceptsValidHostnameSegments(string validLocation) Assert.Null(exception); } + [Fact] + public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync() + { + // Arrange + var client = new GeminiTokenCounterClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "global", + projectId: "fake-project-id"); + + // Act + await client.CountTokensAsync("fake-text"); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/global/", requestUri, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldUseRegionalEndpointWhenLocationIsRegionalAsync() + { + // Arrange + var client = new GeminiTokenCounterClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "us-central1", + projectId: "fake-project-id"); + + // Act + await client.CountTokensAsync("fake-text"); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://us-central1-aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/us-central1/", requestUri, StringComparison.Ordinal); + } + private sealed class BearerTokenGenerator() { private int _index = 0; diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs index c1492b79ed64..fefd1ab0cb61 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs @@ -781,6 +781,71 @@ public void FromChatHistoryMultiTurnConversationPreservesAllRoles() Assert.Equal("assistant-message-2", request.Contents[3].Parts![0].Text); } + [Fact] + public void FromChatHistoryImageContentInToolResultCreatesInlineDataPart() + { + // Arrange + ChatHistory chatHistory = []; + var imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes + var imageContent = new ImageContent(imageBytes, "image/png"); + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => imageContent); + var toolCall = new GeminiFunctionToolCall(new GeminiPart.FunctionCallPart { FunctionName = "capture-screenshot" }); + GeminiFunctionToolResult toolCallResult = new(toolCall, new FunctionResult(kernelFunction, imageContent)); + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Tool, string.Empty, "modelId", toolCallResult)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var part = request.Contents[0].Parts![0]; + Assert.NotNull(part.FunctionResponse); + Assert.Equal("capture-screenshot", part.FunctionResponse.FunctionName); + Assert.NotNull(part.FunctionResponse.Parts); + Assert.Single(part.FunctionResponse.Parts); + Assert.NotNull(part.FunctionResponse.Parts[0].InlineData); + Assert.Equal("image/png", part.FunctionResponse.Parts[0].InlineData!.MimeType); + Assert.Equal(Convert.ToBase64String(imageBytes), part.FunctionResponse.Parts[0].InlineData!.InlineData); + } + + [Fact] + public void FromChatHistoryImageContentWithoutDataThrowsInvalidOperationException() + { + // Arrange + ChatHistory chatHistory = []; + var imageContent = new ImageContent(new Uri("https://example.com/image.png")) { MimeType = "image/png" }; + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => imageContent); + var toolCall = new GeminiFunctionToolCall(new GeminiPart.FunctionCallPart { FunctionName = "capture-screenshot" }); + GeminiFunctionToolResult toolCallResult = new(toolCall, new FunctionResult(kernelFunction, imageContent)); + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Tool, string.Empty, "modelId", toolCallResult)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act & Assert + var exception = Assert.Throws( + () => GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings)); + Assert.Equal("ImageContent in function result must contain binary data.", exception.Message); + } + + [Fact] + public void FromChatHistoryImageContentWithoutMimeTypeThrowsInvalidOperationException() + { + // Arrange + ChatHistory chatHistory = []; + ReadOnlyMemory imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; + var imageContent = new ImageContent(imageBytes, mimeType: null); // No MimeType + var kernelFunction = KernelFunctionFactory.CreateFromMethod(() => imageContent); + var toolCall = new GeminiFunctionToolCall(new GeminiPart.FunctionCallPart { FunctionName = "capture-screenshot" }); + GeminiFunctionToolResult toolCallResult = new(toolCall, new FunctionResult(kernelFunction, imageContent)); + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Tool, string.Empty, "modelId", toolCallResult)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act & Assert + var exception = Assert.Throws( + () => GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings)); + Assert.Equal("Image content MimeType is empty.", exception.Message); + } + [Fact] public void FromChatHistoryToolCallsWithThoughtSignatureIncludesSignatureInRequest() { diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs index 7b644512feda..7928428448ef 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIClientEmbeddingsGenerationTests.cs @@ -180,6 +180,7 @@ public void ItThrowsOnLocationUrlInjectionAttempt(string maliciousLocation) [InlineData("us-central1-a")] [InlineData("northamerica-northeast1")] [InlineData("australia-southeast1")] + [InlineData("global")] public void ItAcceptsValidHostnameSegments(string validLocation) { // Arrange @@ -205,6 +206,52 @@ public void ItAcceptsValidHostnameSegments(string validLocation) Assert.Null(exception); } + [Fact] + public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync() + { + // Arrange + var client = new VertexAIEmbeddingClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "global", + projectId: "fake-project-id"); + IList data = ["sample data"]; + + // Act + await client.GenerateEmbeddingsAsync(data); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/global/", requestUri, StringComparison.Ordinal); + } + + [Fact] + public async Task ShouldUseRegionalEndpointWhenLocationIsRegionalAsync() + { + // Arrange + var client = new VertexAIEmbeddingClient( + httpClient: this._httpClient, + modelId: "fake-model", + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "us-central1", + projectId: "fake-project-id"); + IList data = ["sample data"]; + + // Act + await client.GenerateEmbeddingsAsync(data); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + var requestUri = this._messageHandlerStub.RequestUri.ToString(); + Assert.StartsWith("https://us-central1-aiplatform.googleapis.com/", requestUri); + Assert.Contains("/locations/us-central1/", requestUri, StringComparison.Ordinal); + } + private VertexAIEmbeddingClient CreateEmbeddingsClient( string modelId = "fake-model", string? bearerKey = "fake-key") diff --git a/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs b/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs index ed31204ea67e..10be44ef45a9 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/ClientBase.cs @@ -122,4 +122,14 @@ protected static string GetApiVersionSubLink(VertexAIVersion apiVersion) VertexAIVersion.V1_Beta => "v1beta1", _ => throw new NotSupportedException($"Vertex API version {apiVersion} is not supported.") }; + + /// + /// Gets the Vertex AI endpoint base URI for the given location. + /// The global location uses https://aiplatform.googleapis.com while + /// regional locations use https://{location}-aiplatform.googleapis.com. + /// + protected static string GetVertexAIBaseUri(string location) + => string.Equals(location, "global", StringComparison.OrdinalIgnoreCase) + ? "https://aiplatform.googleapis.com" + : $"https://{location}-aiplatform.googleapis.com"; } diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs index 3c3501622b74..e0138a8e9ce3 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs @@ -143,10 +143,11 @@ public GeminiChatCompletionClient( Verify.NotNullOrWhiteSpace(projectId); string versionSubLink = GetApiVersionSubLink(apiVersion); + string baseUri = GetVertexAIBaseUri(location); this._modelId = modelId; - this._chatGenerationEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:generateContent"); - this._chatStreamingEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:streamGenerateContent?alt=sse"); + this._chatGenerationEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:generateContent"); + this._chatStreamingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:streamGenerateContent?alt=sse"); } /// diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs index 057bd8bd86b0..2d50868e9ef7 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiTokenCounterClient.cs @@ -74,9 +74,10 @@ public GeminiTokenCounterClient( Verify.NotNullOrWhiteSpace(projectId); string versionSubLink = GetApiVersionSubLink(apiVersion); + string baseUri = GetVertexAIBaseUri(location); this._modelId = modelId; - this._tokenCountingEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:countTokens"); + this._tokenCountingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._modelId}:countTokens"); } /// diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs index d49c196da744..4ef4c71c097a 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs @@ -185,6 +185,14 @@ internal sealed class FunctionResponsePart [JsonRequired] public FunctionResponseEntity Response { get; set; } = null!; + /// + /// Optional. Nested parts for multimodal function responses (Gemini 3+ only). + /// Contains inlineData with image/binary data as part of tool results. + /// + [JsonPropertyName("parts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FunctionResponsePartContent[]? Parts { get; set; } + internal sealed class FunctionResponseEntity { [JsonConstructor] @@ -202,5 +210,16 @@ public FunctionResponseEntity(object? response) [JsonRequired] public JsonNode Arguments { get; set; } = null!; } + + /// + /// Represents a part within a Gemini function response (for multimodal content). + /// Used in Gemini 3+ to include images/binary data as part of tool results. + /// + internal sealed class FunctionResponsePartContent + { + [JsonPropertyName("inlineData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public InlineDataPart? InlineData { get; set; } + } } } diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs index cd15974a886c..ffa2af9edcd3 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs @@ -23,6 +23,13 @@ internal sealed class GeminiRequest } }; + /// + /// Synthetic envelope used as the functionResponse.response body when emitting a multimodal + /// (image) tool result. The actual image data is carried in functionResponse.parts[].inlineData; + /// the envelope keeps the required response field present and gives the model a short hint. + /// + private static readonly object s_imageFunctionResponseEnvelope = new { status = "success", message = "Image data attached" }; + [JsonPropertyName("contents")] public IList Contents { get; set; } = null!; @@ -194,14 +201,24 @@ private static List CreateGeminiParts(ChatMessageContent content) case GeminiChatMessageContent { CalledToolResults: not null } contentWithCalledTools: // Add all function responses as separate parts in a single message parts.AddRange(contentWithCalledTools.CalledToolResults.Select(toolResult => - new GeminiPart + { + var resultValue = toolResult.FunctionResult.GetValue(); + + // Handle ImageContent for multimodal tool results (Gemini 3+ only) + if (resultValue is ImageContent imageContent) + { + return CreateImageFunctionResponsePart(toolResult.FullyQualifiedName, imageContent); + } + + return new GeminiPart { FunctionResponse = new GeminiPart.FunctionResponsePart { FunctionName = toolResult.FullyQualifiedName, - Response = new(toolResult.FunctionResult.GetValue()) + Response = new(resultValue) } - })); + }; + })); break; case GeminiChatMessageContent { ToolCalls: not null } contentWithToolCalls: parts.AddRange(contentWithToolCalls.ToolCalls.Select(toolCall => @@ -302,6 +319,37 @@ private static string GetMimeTypeFromImageContent(ImageContent imageContent) ?? throw new InvalidOperationException("Image content MimeType is empty."); } + /// + /// Creates a GeminiPart with FunctionResponse containing multimodal image data (Gemini 3+ only). + /// + private static GeminiPart CreateImageFunctionResponsePart(string functionName, ImageContent imageContent) + { + if (imageContent.Data is not { IsEmpty: false }) + { + throw new InvalidOperationException("ImageContent in function result must contain binary data."); + } + + return new GeminiPart + { + FunctionResponse = new GeminiPart.FunctionResponsePart + { + FunctionName = functionName, + Response = new(s_imageFunctionResponseEnvelope), + Parts = + [ + new GeminiPart.FunctionResponsePart.FunctionResponsePartContent + { + InlineData = new GeminiPart.InlineDataPart + { + MimeType = GetMimeTypeFromImageContent(imageContent), + InlineData = Convert.ToBase64String(imageContent.Data.Value.ToArray()) + } + } + ] + } + }; + } + private static GeminiPart CreateGeminiPartFromAudio(AudioContent audioContent) { // Binary data takes precedence over URI. diff --git a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs index e7b1c5343b61..cb59e0087481 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs @@ -51,9 +51,10 @@ public VertexAIEmbeddingClient( Verify.NotNullOrWhiteSpace(projectId); string versionSubLink = GetApiVersionSubLink(apiVersion); + string baseUri = GetVertexAIBaseUri(location); this._embeddingModelId = modelId; - this._embeddingEndpoint = new Uri($"https://{location}-aiplatform.googleapis.com/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:predict"); + this._embeddingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:predict"); this._dimensions = dimensions; } diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs index 03125bf0a3ae..2786d4ab9261 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs @@ -60,6 +60,20 @@ public async Task FunctionUsageMetricsLoggingHasAllNeededData() [Fact] public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() { + // Arrange: Set up the response and create the function FIRST so we can filter + // measurements by this specific function's name (avoids parallel test contamination). + this._multiMessageHandlerStub.ResponsesToReturn.Add( + new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } + ); + + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(this._mockLoggerFactory.Object); + builder.AddOpenAIChatCompletion(modelId: "model", apiKey: "apiKey", httpClient: this._httpClient); + var kernel = builder.Build(); + + var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); + var expectedFunctionName = kernelFunction.Name; + // Set up a MeterListener to capture the measurements using MeterListener listener = new(); var isPublished = false; @@ -82,10 +96,23 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { - if (instrument.Name is "semantic_kernel.function.invocation.token_usage.prompt" or - "semantic_kernel.function.invocation.token_usage.completion") + if (instrument.Name is not ("semantic_kernel.function.invocation.token_usage.prompt" or + "semantic_kernel.function.invocation.token_usage.completion")) { - measurements[instrument.Name].Add(measurement); + return; + } + + // Filter by function name tag to ignore measurements emitted by other tests + // that may run in parallel against the same global static histogram. + foreach (var tag in tags) + { + if (tag.Key == "semantic_kernel.function.name" && + tag.Value is string fnName && + fnName == expectedFunctionName) + { + measurements[instrument.Name].Add(measurement); + return; + } } }); @@ -101,17 +128,6 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() listener.Start(); // Start the listener to begin collecting data - this._multiMessageHandlerStub.ResponsesToReturn.Add( - new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) } - ); - - var builder = Kernel.CreateBuilder(); - builder.Services.AddSingleton(this._mockLoggerFactory.Object); - builder.AddOpenAIChatCompletion(modelId: "model", apiKey: "apiKey", httpClient: this._httpClient); - var kernel = builder.Build(); - - var kernelFunction = KernelFunctionFactory.CreateFromPrompt("prompt", loggerFactory: this._mockLoggerFactory.Object); - // Act & Assert var result = await kernel.InvokeAsync(kernelFunction); diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs new file mode 100644 index 000000000000..95f0b409ed0b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; +using ChatMessageContent = Microsoft.SemanticKernel.ChatMessageContent; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Unit tests for the property and its application to +/// outgoing chat completion requests. +/// +public sealed class OpenAIChatCompletionExtraBodyTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly ChatHistory _chatHistory = [new ChatMessageContent(AuthorRole.User, "test")]; + + public OpenAIChatCompletionExtraBodyTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub + { + ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse), + }, + }; + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + [Fact] + public async Task ExtraBodyFlatKeyAppearsAtTopLevelAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("enable_thinking", out var value)); + Assert.False(value.GetBoolean()); + } + + [Fact] + public async Task ExtraBodyStreamingFlatKeyAppearsAtTopLevelAsync() + { + // Arrange + var streamBytes = Encoding.UTF8.GetBytes(ChatCompletionStreamingResponse); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new System.IO.MemoryStream(streamBytes)), + }; + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + + // Act + await foreach (var _ in service.GetStreamingChatMessageContentsAsync(this._chatHistory, settings)) + { + } + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("enable_thinking", out var value)); + Assert.False(value.GetBoolean()); + } + + [Fact] + public async Task ExtraBodyOverridesFirstClassPropertyAsync() + { + // Arrange - last-write-wins; ExtraBody value applied via JsonPatch overrides the strongly-typed property. + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + Temperature = 0.7, + ExtraBody = new Dictionary + { + ["temperature"] = 0.0, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.Equal(0.0, body.GetProperty("temperature").GetDouble()); + } + + [Fact] + public async Task ExtraBodyNestedDictionaryEmitsNestedJsonObjectAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["thinking"] = new Dictionary { ["enabled"] = false, ["budget"] = 100 }, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + var thinking = body.GetProperty("thinking"); + Assert.False(thinking.GetProperty("enabled").GetBoolean()); + Assert.Equal(100, thinking.GetProperty("budget").GetInt32()); + } + + [Fact] + public async Task ExtraBodyJsonPathPrefixDoesDeepPatchAsync() + { + // Arrange - $.-prefixed key is interpreted as a JSONPath patch and creates the nested structure. + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.thinking.enabled"] = false, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.False(body.GetProperty("thinking").GetProperty("enabled").GetBoolean()); + } + + [Fact] + public async Task ExtraBodyLiteralDottedKeyEmitsLiteralFieldAsync() + { + // Arrange - keys without $. prefix are literal, even when they contain dots. + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["weird.key"] = "literal-value", + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("weird.key", out var value)); + Assert.Equal("literal-value", value.GetString()); + } + + [Fact] + public async Task ExtraBodyNullValueEmitsJsonNullAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["nullable_field"] = null, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.True(body.TryGetProperty("nullable_field", out var value)); + Assert.Equal(JsonValueKind.Null, value.ValueKind); + } + + [Fact] + public void FromExecutionSettingsRoundTripPreservesExtraBody() + { + // Arrange - deserializing through the base type (e.g. via PromptTemplateConfig) should preserve extra_body. + var jsonSettings = new PromptExecutionSettings + { + ExtensionData = new Dictionary + { + ["temperature"] = 0.5, + ["extra_body"] = new Dictionary { ["enable_thinking"] = false }, + }, + }; + + // Act + var settings = OpenAIPromptExecutionSettings.FromExecutionSettings(jsonSettings); + + // Assert + Assert.NotNull(settings.ExtraBody); + Assert.True(settings.ExtraBody.ContainsKey("enable_thinking")); + } + + [Fact] + public void CloneCopiesExtraBodyShallowly() + { + // Arrange + var original = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary { ["a"] = 1 }, + }; + + // Act + var clone = (OpenAIPromptExecutionSettings)original.Clone(); + clone.ExtraBody!["b"] = 2; + + // Assert - top-level shallow copy: adding to clone does not affect original. + Assert.False(original.ExtraBody!.ContainsKey("b")); + Assert.True(clone.ExtraBody.ContainsKey("a")); + Assert.True(clone.ExtraBody.ContainsKey("b")); + } + + [Fact] + public void FreezeMakesExtraBodyReadOnly() + { + // Arrange + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary { ["a"] = 1 }, + }; + + // Act + settings.Freeze(); + + // Assert + Assert.Throws(() => settings.ExtraBody!["b"] = 2); + } + + [Fact] + public void ToChatOptionsSetsRawRepresentationFactoryAndCleansAdditionalProperties() + { + // Arrange - exercises the IChatClient path: PrepareChatOptionsForRequest must + // wire ChatOptions.RawRepresentationFactory and remove the redundant extra_body entry from AdditionalProperties. + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + ["$.thinking.enabled"] = false, + }, + }; + + // Act + var chatOptions = settings.ToChatOptions(kernel: null); + + // Assert + Assert.NotNull(chatOptions); + Assert.NotNull(chatOptions!.RawRepresentationFactory); + Assert.False(chatOptions.AdditionalProperties?.ContainsKey("extra_body") ?? false); + + // Verify the factory produces a ChatCompletionOptions that serializes the patched fields. + var rawObj = chatOptions.RawRepresentationFactory.Invoke(null!); + var raw = Assert.IsType(rawObj); + var serialized = global::System.ClientModel.Primitives.ModelReaderWriter.Write(raw).ToString(); + var json = JsonDocument.Parse(serialized).RootElement; + Assert.False(json.GetProperty("enable_thinking").GetBoolean()); + Assert.False(json.GetProperty("thinking").GetProperty("enabled").GetBoolean()); + } + + [Fact] + public async Task IChatClientPathPreservesStronglyTypedSettingsAlongsideExtraBodyAsync() + { + // Arrange - end-to-end IChatClient path: build an IChatClient via OpenAI SDK and send a request + // through M.E.AI. Verify both the strongly-typed property (Temperature) and the ExtraBody patch + // (enable_thinking) appear in the outgoing HTTP request body. Per M.E.AI ChatOptions.RawRepresentationFactory + // contract, the OpenAI bridge mutates the returned raw options with values from the strongly-typed + // ChatOptions properties before serialization. + var openAIClient = new global::OpenAI.OpenAIClient( + new global::System.ClientModel.ApiKeyCredential("NOKEY"), + new global::OpenAI.OpenAIClientOptions { Transport = new global::System.ClientModel.Primitives.HttpClientPipelineTransport(this._httpClient) }); + global::Microsoft.Extensions.AI.IChatClient chatClient = openAIClient.GetChatClient("gpt-4o").AsIChatClient(); + + var settings = new OpenAIPromptExecutionSettings + { + Temperature = 0.7, + MaxTokens = 256, + ExtraBody = new Dictionary + { + ["enable_thinking"] = false, + }, + }; + var chatOptions = settings.ToChatOptions(kernel: null); + + // Act + var messages = new[] { new global::Microsoft.Extensions.AI.ChatMessage(global::Microsoft.Extensions.AI.ChatRole.User, "hi") }; + await chatClient.GetResponseAsync(messages, chatOptions); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + Assert.Equal(0.7, body.GetProperty("temperature").GetDouble()); + Assert.Equal(256, body.GetProperty("max_completion_tokens").GetInt32()); + Assert.False(body.GetProperty("enable_thinking").GetBoolean()); + } + + private static JsonElement ParseRequestBody(byte[] requestContent) + { + var json = Encoding.UTF8.GetString(requestContent); + return JsonDocument.Parse(json).RootElement; + } + + private const string ChatCompletionResponse = """ + { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "Hello!" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + } + """; + + private const string ChatCompletionStreamingResponse = + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n"; +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs index 397c9bb0e39d..57b7700a0595 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs @@ -1662,6 +1662,51 @@ public async Task ItSendsEmptyStringWhenAssistantMessageContentIsNull() Assert.Equal(string.Empty, assistantMessageContent); } + [Fact] + public async Task ItSendsImageContentNotSupportedErrorWhenToolResultIsImageContentAsync() + { + // Arrange + var chatCompletion = new OpenAIChatCompletionService(modelId: "any", apiKey: "NOKEY", httpClient: this._httpClient); + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse) + }; + + List assistantToolCalls = [ChatToolCall.CreateFunctionToolCall("call-id", "GetImage", BinaryData.FromString("{}"))]; + + var imageBytes = new ReadOnlyMemory([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes + var imageContent = new ImageContent(imageBytes, "image/png"); + + var chatHistory = new ChatHistory() + { + new ChatMessageContent(role: AuthorRole.User, content: "Show me the image", modelId: "any"), + new ChatMessageContent(role: AuthorRole.Assistant, content: null, modelId: "any", metadata: new Dictionary + { + ["ChatResponseMessage.FunctionToolCalls"] = assistantToolCalls + }), + new ChatMessageContent(role: AuthorRole.Tool, content: null, modelId: "any") + { + Items = [new FunctionResultContent("GetImage", "ImagePlugin", "call-id", imageContent)] + }, + }; + + // Act + await chatCompletion.GetChatMessageContentsAsync(chatHistory, this._executionSettings); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + Assert.NotNull(actualRequestContent); + + var requestContent = JsonElement.Parse(actualRequestContent); + var messages = requestContent.GetProperty("messages").EnumerateArray().ToList(); + + var toolMessage = messages.First(message => message.GetProperty("role").GetString() == "tool"); + var toolMessageContent = toolMessage.GetProperty("content").GetString(); + + // OpenAI does not support multimodal tool results - expect the standard error message + Assert.Equal("Error: This model does not support image content in tool results.", toolMessageContent); + } + [Theory] [MemberData(nameof(WebSearchOptionsData))] public async Task ItCreatesCorrectWebSearchOptionsAsync(object webSearchOptions, string expectedJson) diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs index 3387601ed189..88ace29aff4d 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs @@ -548,6 +548,8 @@ protected virtual ChatCompletionOptions CreateChatCompletionOptions( } } + OpenAIPromptExecutionSettings.ApplyExtraBody(options, executionSettings); + return options; } @@ -765,9 +767,16 @@ private static List CreateRequestMessages(ChatMessageContent messag continue; } - var stringResult = FunctionCalling.FunctionCallsProcessor.ProcessFunctionResult(resultContent.Result ?? string.Empty); + var result = FunctionCalling.FunctionCallsProcessor.ProcessFunctionResult(resultContent.Result ?? string.Empty); + + // OpenAI does not support multimodal tool results - return error message for ImageContent + if (result is ImageContent) + { + toolMessages.Add(new ToolChatMessage(resultContent.CallId, FunctionCalling.FunctionCallsProcessor.ImageContentNotSupportedErrorMessage)); + continue; + } - toolMessages.Add(new ToolChatMessage(resultContent.CallId, stringResult ?? string.Empty)); + toolMessages.Add(new ToolChatMessage(resultContent.CallId, (string?)result ?? string.Empty)); } if (toolMessages is not null) diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs index 5824fe412f84..402e33f95175 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs @@ -447,6 +447,53 @@ public object? Audio } } + /// + /// Gets or sets a dictionary of additional fields to include in the request body sent to the OpenAI-compatible endpoint. + /// + /// + /// + /// This is an escape hatch for vendor-specific or preview parameters that are not modeled by + /// (for example, Qwen3's enable_thinking or ChatGLM thinking-mode flags). + /// + /// + /// Key syntax (hybrid): + /// + /// A plain key (without a leading $.) is treated as a literal top-level field name. + /// For example, ExtraBody["enable_thinking"] = false emits { "enable_thinking": false }. Keys containing + /// dots, brackets, or other special characters are bracket-quoted automatically and remain literal. + /// A key starting with $. is interpreted as a JSONPath expression and applied as a deep patch + /// onto the request body, allowing surgical edits of nested objects and arrays produced by the SDK + /// (for example, ExtraBody["$.stream_options.include_usage"] = true or ExtraBody["$.thinking.enabled"] = false). + /// + /// + /// + /// Collisions: if a key targets the same JSON property as a strongly-typed setting (for example, + /// ExtraBody["temperature"] = 0.0 with set to 0.7), the + /// value wins because it is applied during request serialization (last-write-wins). + /// + /// + /// Removal is not supported: setting a value to emits a JSON null; it does not remove + /// an SDK-injected field. Use a delegating HTTP handler if you need to remove fields from the outgoing body. + /// + /// + /// Round-trip behavior: when populated via YAML or JSON PromptTemplateConfig deserialization, values arrive + /// as instances. Read accordingly. + /// + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("extra_body")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? ExtraBody + { + get => this._extraBody; + + set + { + this.ThrowIfFrozen(); + this._extraBody = value; + } + } + /// public override void Freeze() { @@ -471,6 +518,11 @@ public override void Freeze() { this._metadata = new ReadOnlyDictionary(this._metadata); } + + if (this._extraBody is not null) + { + this._extraBody = new ReadOnlyDictionary(this._extraBody); + } } /// @@ -543,6 +595,7 @@ public static OpenAIPromptExecutionSettings FromExecutionSettings(PromptExecutio WebSearchOptions = this.WebSearchOptions, Modalities = this.Modalities, Audio = this.Audio, + ExtraBody = this.ExtraBody is not null ? new Dictionary(this.ExtraBody) : null, }; } @@ -563,6 +616,75 @@ protected override ChatHistory PrepareChatHistoryForRequest(ChatHistory chatHist return chatHistory; } + /// + protected override void PrepareChatOptionsForRequest(Microsoft.Extensions.AI.ChatOptions options) + { + base.PrepareChatOptionsForRequest(options); + + if (this._extraBody is null || this._extraBody.Count == 0) + { + return; + } + + var snapshot = new List>(this._extraBody); + + options.RawRepresentationFactory = (_) => + { + var raw = new ChatCompletionOptions(); + foreach (var kvp in snapshot) + { + ApplyExtraBodyEntry(raw, kvp.Key, kvp.Value); + } + return raw; + }; + + // The base ToChatOptions catch-all writes ExtraBody into AdditionalProperties via JSON round-trip. + // The factory above is the canonical carrier, so remove the duplicate here. + options.AdditionalProperties?.Remove("extra_body"); + } + + /// + /// Applies a single entry to the supplied using its + /// JsonPatch facility. Plain keys become bracket-quoted top-level fields; keys starting with $. are + /// passed through as raw JSONPath patches. + /// + internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string key, object? value) + { + string path = key.StartsWith("$.", StringComparison.Ordinal) + ? key + : $"$[{JsonSerializer.Serialize(key)}]"; + + byte[] pathBytes = System.Text.Encoding.UTF8.GetBytes(path); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + if (value is null) + { + options.Patch.SetNull(pathBytes); + return; + } + + // Serialize to raw JSON bytes so any value type (primitive, JsonElement, dictionary, complex object) is supported uniformly. + byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value, value.GetType()); + options.Patch.Set(pathBytes, BinaryData.FromBytes(jsonBytes)); +#pragma warning restore SCME0001 + } + + /// + /// Applies all entries from . to . + /// + internal static void ApplyExtraBody(ChatCompletionOptions options, OpenAIPromptExecutionSettings settings) + { + if (settings._extraBody is null || settings._extraBody.Count == 0) + { + return; + } + + foreach (var kvp in settings._extraBody) + { + ApplyExtraBodyEntry(options, kvp.Key, kvp.Value); + } + } + #region private ================================================================================ private object? _webSearchOptions; @@ -586,6 +708,7 @@ protected override ChatHistory PrepareChatHistoryForRequest(ChatHistory chatHist private IDictionary? _metadata; private object? _responseModalities; private object? _audioOptions; + private IDictionary? _extraBody; #endregion } diff --git a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs new file mode 100644 index 000000000000..16a23f6100bb --- /dev/null +++ b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; + +namespace Microsoft.SemanticKernel.Plugins.Grpc; + +/// +/// gRPC function execution parameters. +/// +[Experimental("SKEXP0040")] +public class GrpcFunctionExecutionParameters +{ + /// + /// HttpClient to use for sending gRPC requests. + /// + public HttpClient? HttpClient { get; set; } + + /// + /// Developer-provided address override for the gRPC channel. + /// When set, this address is used instead of the one from the .proto document. + /// This value is controlled by the developer, not by the LLM. + /// + public Uri? AddressOverride { get; set; } + + /// + /// Gets or sets the allowed gRPC server base addresses. + /// If set, only requests to addresses that start with one of these base URIs will be permitted. + /// This helps prevent Server-Side Request Forgery (SSRF) attacks. + /// If null, no base address restriction is applied (scheme validation still applies). + /// + public IReadOnlyList? AllowedAddresses { get; set; } + + /// + /// Gets or sets the allowed URI schemes for gRPC server addresses. + /// If null or empty, only https is permitted. + /// + public IReadOnlyList? AllowedSchemes { get; set; } +} diff --git a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs index 2b2b7488db6a..6d7331b05f90 100644 --- a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs +++ b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs @@ -29,13 +29,15 @@ public static class GrpcKernelExtensions /// The containing services, plugins, and other state for use throughout the operation. /// Directory containing the plugin directory. /// Name of the directory containing the selected plugin. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin ImportPluginFromGrpcDirectory( this Kernel kernel, string parentDirectory, - string pluginDirectoryName) + string pluginDirectoryName, + GrpcFunctionExecutionParameters? executionParameters = null) { - KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName); + KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName, executionParameters); kernel.Plugins.Add(plugin); return plugin; } @@ -46,13 +48,15 @@ public static KernelPlugin ImportPluginFromGrpcDirectory( /// The containing services, plugins, and other state for use throughout the operation. /// File path to .proto document. /// Name of the plugin to register. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin ImportPluginFromGrpcFile( this Kernel kernel, string filePath, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { - KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName); + KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName, executionParameters); kernel.Plugins.Add(plugin); return plugin; } @@ -63,13 +67,15 @@ public static KernelPlugin ImportPluginFromGrpcFile( /// The containing services, plugins, and other state for use throughout the operation. /// .proto document stream. /// Plugin name. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin ImportPluginFromGrpc( this Kernel kernel, Stream documentStream, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { - KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName); + KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName, executionParameters); kernel.Plugins.Add(plugin); return plugin; } @@ -80,11 +86,13 @@ public static KernelPlugin ImportPluginFromGrpc( /// The containing services, plugins, and other state for use throughout the operation. /// Directory containing the plugin directory. /// Name of the directory containing the selected plugin. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin CreatePluginFromGrpcDirectory( this Kernel kernel, string parentDirectory, - string pluginDirectoryName) + string pluginDirectoryName, + GrpcFunctionExecutionParameters? executionParameters = null) { const string ProtoFile = "grpc.proto"; @@ -107,7 +115,7 @@ public static KernelPlugin CreatePluginFromGrpcDirectory( using var stream = File.OpenRead(filePath); - return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName); + return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName, executionParameters); } /// @@ -116,11 +124,13 @@ public static KernelPlugin CreatePluginFromGrpcDirectory( /// The containing services, plugins, and other state for use throughout the operation. /// File path to .proto document. /// Name of the plugin to register. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin CreatePluginFromGrpcFile( this Kernel kernel, string filePath, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { if (!File.Exists(filePath)) { @@ -135,7 +145,7 @@ public static KernelPlugin CreatePluginFromGrpcFile( using var stream = File.OpenRead(filePath); - return kernel.CreatePluginFromGrpc(stream, pluginName); + return kernel.CreatePluginFromGrpc(stream, pluginName, executionParameters); } /// @@ -144,11 +154,13 @@ public static KernelPlugin CreatePluginFromGrpcFile( /// The containing services, plugins, and other state for use throughout the operation. /// .proto document stream. /// Plugin name. + /// Optional gRPC function execution parameters. /// A list of all the prompt functions representing the plugin. public static KernelPlugin CreatePluginFromGrpc( this Kernel kernel, Stream documentStream, - string pluginName) + string pluginName, + GrpcFunctionExecutionParameters? executionParameters = null) { Verify.NotNull(kernel); KernelVerify.ValidPluginName(pluginName, kernel.Plugins); @@ -162,9 +174,13 @@ public static KernelPlugin CreatePluginFromGrpc( ILoggerFactory loggerFactory = kernel.LoggerFactory; - using var client = HttpClientProvider.GetHttpClient(kernel.Services.GetService()); + var client = HttpClientProvider.GetHttpClient(executionParameters?.HttpClient ?? kernel.Services.GetService()); - var runner = new GrpcOperationRunner(client); + var runner = new GrpcOperationRunner( + client, + executionParameters?.AddressOverride, + executionParameters?.AllowedAddresses, + executionParameters?.AllowedSchemes); ILogger logger = loggerFactory.CreateLogger(typeof(GrpcKernelExtensions)) ?? NullLogger.Instance; foreach (var operation in operations) diff --git a/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs b/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs index c4726e649d3d..57e32fac58de 100644 --- a/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs +++ b/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs @@ -22,16 +22,53 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc; /// /// Runs gRPC operation runner. /// -internal sealed class GrpcOperationRunner(HttpClient httpClient) +internal sealed class GrpcOperationRunner { /// Serialization options that use a camel casing naming policy. private static readonly JsonSerializerOptions s_camelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// Deserialization options that use case-insensitive property names. private static readonly JsonSerializerOptions s_propertyCaseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true }; + + private static readonly IReadOnlyList s_defaultAllowedSchemes = ["https"]; + /// /// An instance of the HttpClient class. /// - private readonly HttpClient _httpClient = httpClient; + private readonly HttpClient _httpClient; + + /// + /// Developer-provided address override for the gRPC channel. + /// + private readonly Uri? _addressOverride; + + /// + /// Allowed gRPC server base addresses for SSRF protection. + /// + private readonly IReadOnlyList? _allowedAddresses; + + /// + /// Allowed URI schemes for gRPC server addresses. + /// + private readonly IReadOnlyList _allowedSchemes; + + /// + /// Creates an instance of a class. + /// + /// The HttpClient to use for sending gRPC requests. + /// Optional developer-provided address override. + /// Optional allowed base addresses for SSRF protection. + /// Optional allowed URI schemes. Defaults to https only. + internal GrpcOperationRunner( + HttpClient httpClient, + Uri? addressOverride = null, + IReadOnlyList? allowedAddresses = null, + IReadOnlyList? allowedSchemes = null) + { + this._httpClient = httpClient; + this._addressOverride = addressOverride; + this._allowedAddresses = allowedAddresses; + this._allowedSchemes = allowedSchemes is { Count: > 0 } ? allowedSchemes : s_defaultAllowedSchemes; + } /// /// Runs a gRPC operation. @@ -47,7 +84,7 @@ public async Task RunAsync(GrpcOperation operation, KernelArguments var stringArgument = CastToStringArguments(arguments, operation); - var address = this.GetAddress(operation, stringArgument); + var address = this.GetAddress(operation); var channelOptions = new GrpcChannelOptions { HttpClient = this._httpClient, DisposeHttpClient = false }; @@ -118,11 +155,16 @@ private static JsonObject ConvertResponse(object response, Type responseType) /// Returns address of a channel that provides connection to a gRPC server. /// /// The gRPC operation. - /// The gRPC operation arguments. /// The channel address. - private string GetAddress(GrpcOperation operation, Dictionary arguments) + private string GetAddress(GrpcOperation operation) { - if (!arguments.TryGetValue(GrpcOperation.AddressArgumentName, out string? address)) + string? address; + + if (this._addressOverride is not null) + { + address = this._addressOverride.AbsoluteUri; + } + else { address = operation.Address; } @@ -132,6 +174,48 @@ private string GetAddress(GrpcOperation operation, Dictionary ar throw new KernelException($"No address provided for the '{operation.Name}' gRPC operation."); } + if (!Uri.TryCreate(address, UriKind.Absolute, out var addressUri)) + { + throw new KernelException($"The address '{address}' for the '{operation.Name}' gRPC operation is not a valid absolute URI."); + } + + // Validate scheme + if (!this._allowedSchemes.Contains(addressUri.Scheme, StringComparer.OrdinalIgnoreCase)) + { + throw new KernelException($"The URI scheme '{addressUri.Scheme}' is not allowed for the '{operation.Name}' gRPC operation. Allowed schemes: {string.Join(", ", this._allowedSchemes)}."); + } + + // Validate against allowed addresses + if (this._allowedAddresses is { Count: > 0 }) + { + bool isAllowed = false; + foreach (var allowedAddress in this._allowedAddresses) + { + string allowedUri = allowedAddress.AbsoluteUri; + + if (addressUri.AbsoluteUri.StartsWith(allowedUri, StringComparison.OrdinalIgnoreCase)) + { + // If the allowed URI already ends at a boundary (e.g., trailing '/'), + // or the full URIs match exactly, no further check is needed. + // Otherwise, ensure the next character is a path boundary to prevent + // prefix bypasses (e.g., allowed "https://host/grpc" should not match "https://host/grpcevil"). + int prefixLength = allowedUri.Length; + if (prefixLength >= addressUri.AbsoluteUri.Length || + allowedUri[prefixLength - 1] is '/' || + addressUri.AbsoluteUri[prefixLength] is '/' or '?' or '#') + { + isAllowed = true; + break; + } + } + } + + if (!isAllowed) + { + throw new KernelException($"The address '{address}' is not allowed for the '{operation.Name}' gRPC operation. The address must match one of the allowed base addresses."); + } + } + return address!; } diff --git a/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs b/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs index ee5f25c17c90..321312c5680c 100644 --- a/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs +++ b/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs @@ -9,11 +9,6 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc.Model; /// internal sealed class GrpcOperation { - /// - /// Name of 'address' argument used as override for the address provided by gRPC operation. - /// - internal const string AddressArgumentName = "address"; - /// /// Name of 'payload' argument that represents gRPC operation request message. /// @@ -90,12 +85,6 @@ public string FullServiceName /// The list of parameters. internal static List CreateParameters() => [ - // Register the "address" parameter so that it's possible to override it if needed. - new(GrpcOperation.AddressArgumentName) - { - Description = "Address for gRPC channel to use.", - }, - // Register the "payload" parameter to be used as gRPC operation request message. new(GrpcOperation.PayloadArgumentName) { diff --git a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs index c2a5919514c4..5bfa5a8a1ff2 100644 --- a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs +++ b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs @@ -308,6 +308,8 @@ private string BuildPath(string pathTemplate, IDictionary argum pathTemplate = pathTemplate.Replace($"{{{parameter.Name}}}", HttpUtility.UrlEncode(serializer.Invoke(parameter, node))); } + ValidatePathSegments(pathTemplate); + return pathTemplate; } @@ -364,19 +366,19 @@ private Uri GetServerUrl(Uri? serverUrlOverride, Uri? apiHostUrl, IDictionary + /// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal. + /// ".." navigates up one path segment, enabling traversal to unintended endpoints. + /// "." refers to the current directory — harmless but unexpected, so rejected to prevent misuse. + /// + /// The path to validate. + private static void ValidatePathSegments(string path) + { + var segments = path.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + if (segments[i] == "." || segments[i] == "..") + { + throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal."); + } + } + } + private IDictionary _extensions = s_emptyDictionary; private readonly Freezable _freezable = new(); private string? _description; diff --git a/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs b/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs index 1e564e339a6e..001ea6bbc6d6 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs @@ -24,7 +24,7 @@ public GrpcOperationExtensionsTests() } [Fact] - public void ThereShouldBeAddressParameter() + public void ThereShouldNotBeAddressParameter() { // Act var parameters = GrpcOperation.CreateParameters(); @@ -34,8 +34,7 @@ public void ThereShouldBeAddressParameter() Assert.NotEmpty(parameters); var addressParameter = parameters.SingleOrDefault(p => p.Name == "address"); - Assert.NotNull(addressParameter); - Assert.Equal("Address for gRPC channel to use.", addressParameter.Description); + Assert.Null(addressParameter); } [Fact] diff --git a/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs index 756ab5ce22fe..8ecdfe10ee0a 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs @@ -73,7 +73,7 @@ public async Task ShouldUseAddressProvidedInGrpcOperationAsync() } [Fact] - public async Task ShouldUseAddressOverrideFromArgumentsAsync() + public async Task ShouldIgnoreAddressFromArgumentsAsync() { // Arrange this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); @@ -96,15 +96,278 @@ public async Task ShouldUseAddressOverrideFromArgumentsAsync() var arguments = new KernelArguments { { "payload", JsonSerializer.Serialize(new { name = "author" }) }, - { "address", "https://fake-random-test-host-from-args" } + { "address", "https://evil-host-from-llm" } }; // Act var result = await sut.RunAsync(operation, arguments); - // Assert + // Assert - LLM-supplied address should be ignored, operation address should be used Assert.NotNull(this._httpMessageHandlerStub.RequestUri); - Assert.Equal("https://fake-random-test-host-from-args/greet.Greeter/SayHello", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + Assert.Equal("https://fake-random-test-host/greet.Greeter/SayHello", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + } + + [Fact] + public async Task ShouldUseAddressOverrideFromParametersAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var addressOverride = new Uri("https://developer-override-host"); + var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://fake-random-test-host" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - developer-provided override takes precedence + Assert.NotNull(this._httpMessageHandlerStub.RequestUri); + Assert.StartsWith("https://developer-override-host/", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + } + + [Fact] + public async Task ShouldRejectAddressNotInAllowlistAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://allowed-host.com/") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://not-allowed-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("not allowed", ex.Message); + } + + [Fact] + public async Task ShouldAllowAddressInAllowlistAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://fake-random-test-host/") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://fake-random-test-host" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - should succeed + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldAllowAddressWithSubPathWhenAllowlistHasTrailingSlashAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://fake-random-test-host/grpc/") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://fake-random-test-host/grpc/v1" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - trailing slash in allowlist should allow sub-paths + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldAllowHttpWhenCustomSchemesPermitItAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0); + this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]); + this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc"); + this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0"); + + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedSchemes = new[] { "https", "http" }; + var sut = new GrpcOperationRunner(this._httpClient, allowedSchemes: allowedSchemes); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "http://localhost" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert - http should be allowed when custom schemes permit it + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldRejectNonHttpsSchemeByDefaultAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var sut = new GrpcOperationRunner(this._httpClient); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "http://insecure-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ShouldRejectAddressThatSharesPrefixButIsNotAtPathBoundaryAsync() + { + // Arrange - allowlist without trailing slash, address extends the path segment + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://api.example.com/grpc") }; + var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://api.example.com/grpcevil" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert - "grpcevil" should NOT match "grpc" since it's not at a path boundary + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("not allowed", ex.Message); + } + + [Fact] + public async Task ShouldRejectAddressOverrideNotInAllowlistAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var allowedAddresses = new[] { new Uri("https://allowed-host.com/") }; + var addressOverride = new Uri("https://evil-host.com/"); + var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride, allowedAddresses: allowedAddresses); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://allowed-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert - AddressOverride should also be validated against allowlist + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("not allowed", ex.Message); + } + + [Fact] + public async Task ShouldRejectAddressOverrideWithDisallowedSchemeAsync() + { + // Arrange + var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]); + var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]); + + var addressOverride = new Uri("http://insecure-host.com/"); + var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride); + + var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata) + { + Package = "greet", + Address = "https://safe-host.com" + }; + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { name = "author" }) } + }; + + // Act & Assert - AddressOverride with http should be rejected when only https is allowed + var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs index c90869bc8912..9b17ae442731 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs @@ -1315,4 +1315,182 @@ public void ItShouldFreezeModifiableProperties() Assert.Throws(() => sut.Extensions.Add("x-fake", "fake_value")); } + + [Fact] + public void ItShouldEncodeServerVariableValuesFromArguments() + { + // Arrange — variable value contains path-manipulation characters + var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]); + var sut = new RestApiOperation( + id: "fake_id", + servers: [ + new RestApiServer("https://example.com/{version}", new Dictionary { { "version", version } }), + ], + path: "/items", + method: HttpMethod.Get, + description: "fake_description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary() { { "version", "v2/../admin" } }; + + // Act + var url = sut.BuildOperationUrl(arguments); + + // Assert — reserved separators (/) must be percent-encoded so they are not interpreted as path delimiters + Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString); + } + + [Fact] + public void ItShouldPreventServerVariableInjectionWithSpecialCharacters() + { + // Arrange — variable value contains path traversal and query string injection + var host = new RestApiServerVariable("api.example.com"); + var sut = new RestApiOperation( + id: "fake_id", + servers: [ + new RestApiServer("https://{host}/api", new Dictionary { { "host", host } }), + ], + path: "/data", + method: HttpMethod.Get, + description: "fake_description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary() { { "host", "evil.com/hijack?q=1#" } }; + + // Act & Assert — encoding turns /, ?, # into percent-encoded sequences (%2F, %3F, %23), + // which prevents them from being interpreted as structural URI delimiters. + // The Uri constructor rejects the resulting hostname, which is the desired outcome. + Assert.ThrowsAny(() => sut.BuildOperationUrl(arguments)); + } + + [Fact] + public void ItShouldRejectDotSegmentInPathParameter() + { + // Arrange — path parameter value is ".." (dot-segment traversal) + var parameters = new List { + new( + name: "id", + type: "string", + isRequired: true, + expand: false, + location: RestApiParameterLocation.Path, + style: RestApiParameterStyle.Simple) + }; + + var sut = new RestApiOperation( + id: "fake_id", + servers: [new RestApiServer("https://example.com/api")], + path: "/resources/{id}/details", + method: HttpMethod.Get, + description: "fake_description", + parameters: parameters, + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary { { "id", ".." } }; + + // Act & Assert — dot-segments must be rejected + var ex = Assert.Throws(() => sut.BuildOperationUrl(arguments)); + Assert.Contains("dot-segment", ex.Message); + } + + [Fact] + public void ItShouldRejectSingleDotSegmentInPathParameter() + { + // Arrange — path parameter value is "." (single-dot segment) + var parameters = new List { + new( + name: "id", + type: "string", + isRequired: true, + expand: false, + location: RestApiParameterLocation.Path, + style: RestApiParameterStyle.Simple) + }; + + var sut = new RestApiOperation( + id: "fake_id", + servers: [new RestApiServer("https://example.com/api")], + path: "/resources/{id}/details", + method: HttpMethod.Get, + description: "fake_description", + parameters: parameters, + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary { { "id", "." } }; + + // Act & Assert — single-dot segments must also be rejected + var ex = Assert.Throws(() => sut.BuildOperationUrl(arguments)); + Assert.Contains("dot-segment", ex.Message); + } + + [Fact] + public void ItShouldAllowDotsInNonSegmentPathParameterValues() + { + // Arrange — path parameter contains dots but is NOT a dot-segment (e.g., "file.txt") + var parameters = new List { + new( + name: "filename", + type: "string", + isRequired: true, + expand: false, + location: RestApiParameterLocation.Path, + style: RestApiParameterStyle.Simple) + }; + + var sut = new RestApiOperation( + id: "fake_id", + servers: [new RestApiServer("https://example.com/api")], + path: "/files/{filename}", + method: HttpMethod.Get, + description: "fake_description", + parameters: parameters, + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary { { "filename", "report.v2.txt" } }; + + // Act + var url = sut.BuildOperationUrl(arguments); + + // Assert — dots within normal filenames should work fine + Assert.Equal("https://example.com/api/files/report.v2.txt", url.OriginalString); + } + + [Fact] + public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName() + { + // Arrange — variable uses ArgumentName and the argument contains path-manipulation characters + var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]) { ArgumentName = "alt_version" }; + var sut = new RestApiOperation( + id: "fake_id", + servers: [ + new RestApiServer("https://example.com/{version}", new Dictionary { { "version", version } }), + ], + path: "/items", + method: HttpMethod.Get, + description: "fake_description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new Dictionary() { { "alt_version", "v2/../admin" } }; + + // Act + var url = sut.BuildOperationUrl(arguments); + + // Assert — reserved separators must be percent-encoded even when looked up via ArgumentName + Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString); + } } diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs index 41aaf3797e26..950955ca2ebf 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/SemanticKernelAIAgentConformance/SemanticKernelAIAgentTests.cs @@ -18,16 +18,16 @@ public abstract class SemanticKernelAIAgentTests(Func createAgentF public virtual async Task ConvertAndRunAgentAsync() { var aiagent = this.Fixture.AIAgent; - var thread = aiagent.GetNewThread(); + var session = await aiagent.CreateSessionAsync(); - var result = await aiagent.RunAsync("What is the capital of France?", thread); + var result = await aiagent.RunAsync("What is the capital of France?", session); Assert.Contains("Paris", result.Text, StringComparison.OrdinalIgnoreCase); - var serialisedTreadJsonElement = thread.Serialize(); + var serialisedSessionJsonElement = await aiagent.SerializeSessionAsync(session); - var deserializedThread = aiagent.DeserializeThread(serialisedTreadJsonElement); + var deserializedSession = await aiagent.DeserializeSessionAsync(serialisedSessionJsonElement); - var secondResult = await aiagent.RunAsync("And Austria?", deserializedThread); + var secondResult = await aiagent.RunAsync("And Austria?", deserializedSession); Assert.Contains("Vienna", secondResult.Text, StringComparison.OrdinalIgnoreCase); } diff --git a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs index ddca98c7c053..06c943637be0 100644 --- a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs +++ b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs @@ -42,6 +42,11 @@ internal sealed class FunctionCallsProcessor /// private const int MaxInflightAutoInvokes = 128; + /// + /// Error message returned when a connector does not support ImageContent in tool results. + /// + public const string ImageContentNotSupportedErrorMessage = "Error: This model does not support image content in tool results."; + /// /// The maximum number of function auto-invokes that can be made in a single user request. /// @@ -340,7 +345,7 @@ private static bool TryValidateFunctionCall( return false; } - private record struct FunctionResultContext(AutoFunctionInvocationContext Context, FunctionCallContent FunctionCall, string? Result, string? ErrorMessage); + private record struct FunctionResultContext(AutoFunctionInvocationContext Context, FunctionCallContent FunctionCall, object? Result, string? ErrorMessage); private async Task ExecuteFunctionCallAsync( AutoFunctionInvocationContext invocationContext, @@ -377,8 +382,8 @@ await this.OnAutoFunctionInvocationAsync( } // Apply any changes from the auto function invocation filters context to final result. - string stringResult = ProcessFunctionResult(invocationContext.Result.GetValue() ?? string.Empty); - return new FunctionResultContext(invocationContext, functionCall, stringResult, null); + object result = ProcessFunctionResult(invocationContext.Result.GetValue() ?? string.Empty); + return new FunctionResultContext(invocationContext, functionCall, result, null); } /// @@ -388,7 +393,8 @@ await this.OnAutoFunctionInvocationAsync( /// The function result context. private void AddFunctionCallResultToChatHistory(ChatHistory chatHistory, FunctionResultContext resultContext) { - var message = new ChatMessageContent(role: AuthorRole.Tool, content: resultContext.Result); + // When Result is ImageContent, Content will be null - the actual result is in FunctionResultContent.Result + var message = new ChatMessageContent(role: AuthorRole.Tool, content: resultContext.Result as string); message.Items.Add(this.GenerateResultContent(resultContext)); chatHistory.Add(message); } @@ -419,9 +425,9 @@ private FunctionResultContent GenerateResultContent(FunctionResultContext result /// Creates a instance. /// /// The function call content. - /// The function result, if available + /// The function result, if available. Can be string or ImageContent. /// An error message. - private FunctionResultContent GenerateResultContent(FunctionCallContent functionCall, string? result, string? errorMessage) + private FunctionResultContent GenerateResultContent(FunctionCallContent functionCall, object? result, string? errorMessage) { // Log any error if (errorMessage is not null) @@ -429,6 +435,7 @@ private FunctionResultContent GenerateResultContent(FunctionCallContent function this._logger.LogFunctionCallRequestFailure(functionCall, errorMessage); } + // FunctionResultContent.Result is object? - pass through string or ImageContent directly return new FunctionResultContent(functionCall.FunctionName, functionCall.PluginName, functionCall.Id, result ?? errorMessage ?? string.Empty); } @@ -478,17 +485,31 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( } /// - /// Processes the function result. + /// Processes the function result into the form a connector should serialize into a tool/function response. /// /// The result of the function call. - /// A string representation of the function result. - public static string ProcessFunctionResult(object functionResult) + /// + /// One of: + /// + /// The original when is a string. + /// The original instance when is an , so multimodal-capable connectors (e.g., Gemini 3+) can attach it natively. Connectors that do not support multimodal tool results must detect this case and substitute . + /// A JSON-serialized representation of any other type (with and short-circuited to their text form). + /// + /// + public static object ProcessFunctionResult(object functionResult) { if (functionResult is string stringResult) { return stringResult; } + // Preserve ImageContent for connectors that support multimodal tool results (e.g., Gemini 3+, Anthropic) + // Connectors that don't support this should check for ImageContent and return an appropriate error message. + if (functionResult is ImageContent) + { + return functionResult; + } + // This is an optimization to use ChatMessageContent content directly // without unnecessary serialization of the whole message content class. if (functionResult is ChatMessageContent chatMessageContent) diff --git a/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs b/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs index f0f3a1fd37c8..2cc87da09a48 100644 --- a/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Document/DocumentPlugin.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -87,14 +88,16 @@ public async Task ReadTextAsync( [Description("Path to the file to read")] string filePath, CancellationToken cancellationToken = default) { - this._logger.LogDebug("Reading text from {0}", filePath); + var canonicalPath = CanonicalizePath(filePath); - if (!this.IsFilePathAllowed(filePath)) + this._logger.LogDebug("Reading text from {0}", canonicalPath); + + if (!this.IsFilePathAllowed(canonicalPath)) { throw new InvalidOperationException("Reading from the provided location is not allowed."); } - using var stream = await this._fileSystemConnector.GetFileContentStreamAsync(filePath, cancellationToken).ConfigureAwait(false); + using var stream = await this._fileSystemConnector.GetFileContentStreamAsync(canonicalPath, cancellationToken).ConfigureAwait(false); return this._documentConnector.ReadText(stream); } @@ -107,25 +110,27 @@ public async Task AppendTextAsync( [Description("Destination file path")] string filePath, CancellationToken cancellationToken = default) { - if (!this.IsFilePathAllowed(filePath)) + var canonicalPath = CanonicalizePath(filePath); + + if (!this.IsFilePathAllowed(canonicalPath)) { throw new InvalidOperationException("Writing to the provided location is not allowed."); } // If the document already exists, open it. If not, create it. - if (await this._fileSystemConnector.FileExistsAsync(filePath, cancellationToken).ConfigureAwait(false)) + if (await this._fileSystemConnector.FileExistsAsync(canonicalPath, cancellationToken).ConfigureAwait(false)) { - this._logger.LogDebug("Writing text to file {0}", filePath); - using Stream stream = await this._fileSystemConnector.GetWriteableFileStreamAsync(filePath, cancellationToken).ConfigureAwait(false); + this._logger.LogDebug("Writing text to file {0}", canonicalPath); + using Stream stream = await this._fileSystemConnector.GetWriteableFileStreamAsync(canonicalPath, cancellationToken).ConfigureAwait(false); this._documentConnector.AppendText(stream, text); } else { - this._logger.LogDebug("File does not exist. Creating file at {0}", filePath); - using Stream stream = await this._fileSystemConnector.CreateFileAsync(filePath, cancellationToken).ConfigureAwait(false); + this._logger.LogDebug("File does not exist. Creating file at {0}", canonicalPath); + using Stream stream = await this._fileSystemConnector.CreateFileAsync(canonicalPath, cancellationToken).ConfigureAwait(false); this._documentConnector.Initialize(stream); - this._logger.LogDebug("Writing text to {0}", filePath); + this._logger.LogDebug("Writing text to {0}", canonicalPath); this._documentConnector.AppendText(stream, text); } } @@ -134,11 +139,10 @@ public async Task AppendTextAsync( private HashSet? _allowedDirectories = []; /// - /// If a list of allowed directories has been provided, the directory of the provided filePath is checked - /// to verify it is in the allowed directory list. Paths are canonicalized before comparison. - /// Subdirectories of allowed directories are also permitted. + /// Expands environment variables and resolves the path to its canonical form. + /// This must be called before validation to prevent validate/use mismatches. /// - private bool IsFilePathAllowed(string path) + private static string CanonicalizePath(string path) { Verify.NotNullOrWhiteSpace(path); @@ -147,11 +151,37 @@ private bool IsFilePathAllowed(string path) throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); } - string? directoryPath = Path.GetDirectoryName(path); + // Expand environment variables first, then canonicalize — so that + // validation and I/O operate on the same resolved path. + var expanded = Environment.ExpandEnvironmentVariables(path); + + // Re-check after expansion: an env var could have expanded to a UNC + // or extended-path prefix (e.g., %NETSHARE% → \\server\share). + if (expanded.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + + return Path.GetFullPath(expanded); + } + + // Use case-insensitive comparison on Windows (case-insensitive FS), case-sensitive on Linux/macOS. + private static readonly StringComparison s_pathComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// Checks whether a canonicalized file path falls within one of the allowed directories. + /// Subdirectories of allowed directories are also permitted. + /// + private bool IsFilePathAllowed(string canonicalPath) + { + string? directoryPath = Path.GetDirectoryName(canonicalPath); if (string.IsNullOrEmpty(directoryPath)) { - throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(path)); + throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(canonicalPath)); } if (this._allowedDirectories is null || this._allowedDirectories.Count == 0) @@ -159,19 +189,17 @@ private bool IsFilePathAllowed(string path) return false; } - var canonicalDir = Path.GetFullPath(directoryPath); - foreach (var allowedDirectory in this._allowedDirectories) { var canonicalAllowed = Path.GetFullPath(allowedDirectory); var separator = Path.DirectorySeparatorChar.ToString(); - if (!canonicalAllowed.EndsWith(separator, StringComparison.OrdinalIgnoreCase)) + if (!canonicalAllowed.EndsWith(separator, s_pathComparison)) { canonicalAllowed += separator; } - if (canonicalDir.StartsWith(canonicalAllowed, StringComparison.OrdinalIgnoreCase) - || (canonicalDir + separator).Equals(canonicalAllowed, StringComparison.OrdinalIgnoreCase)) + if (directoryPath.StartsWith(canonicalAllowed, s_pathComparison) + || (directoryPath + separator).Equals(canonicalAllowed, s_pathComparison)) { return true; } diff --git a/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs b/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs index fd708eb24af1..2fef0ae8db2d 100644 --- a/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs +++ b/dotnet/src/Plugins/Plugins.Document/FileSystem/LocalFileSystemConnector.cs @@ -32,7 +32,7 @@ public Task GetFileContentStreamAsync(string filePath, CancellationToken { try { - return Task.FromResult(File.Open(Environment.ExpandEnvironmentVariables(filePath), FileMode.Open, FileAccess.Read)); + return Task.FromResult(File.Open(filePath, FileMode.Open, FileAccess.Read)); } catch (Exception e) { @@ -58,7 +58,7 @@ public Task GetWriteableFileStreamAsync(string filePath, CancellationTok { try { - return Task.FromResult(File.Open(Environment.ExpandEnvironmentVariables(filePath), FileMode.Open, FileAccess.ReadWrite)); + return Task.FromResult(File.Open(filePath, FileMode.Open, FileAccess.ReadWrite)); } catch (Exception e) { @@ -82,7 +82,7 @@ public Task CreateFileAsync(string filePath, CancellationToken cancellat { try { - return Task.FromResult(File.Create(Environment.ExpandEnvironmentVariables(filePath))); + return Task.FromResult(File.Create(filePath)); } catch (Exception e) { @@ -95,7 +95,7 @@ public Task FileExistsAsync(string filePath, CancellationToken cancellatio { try { - return Task.FromResult(File.Exists(Environment.ExpandEnvironmentVariables(filePath))); + return Task.FromResult(File.Exists(filePath)); } catch (Exception e) { diff --git a/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs b/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs index de8660092fe4..17caeb44227d 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -14,10 +16,27 @@ namespace Microsoft.SemanticKernel.Plugins.MsGraph; /// /// Cloud drive plugin (e.g. OneDrive). /// +/// +/// +/// This plugin is secure by default. , , +/// , and must be explicitly configured +/// before file upload, read, or share-link operations are permitted. +/// By default, all paths are denied. +/// +/// +/// When exposing this plugin to an LLM via auto function calling, ensure that +/// , , , +/// and are restricted to trusted values only. +/// +/// public sealed class CloudDrivePlugin { private readonly ICloudDriveConnector _connector; private readonly ILogger _logger; + private HashSet _allowedUploadDirectories = []; + private HashSet _allowedSharePaths = []; + private HashSet _allowedReadPaths = []; + private HashSet _allowedUploadDestinationPaths = []; /// /// Initializes a new instance of the class. @@ -32,6 +51,71 @@ public CloudDrivePlugin(ICloudDriveConnector connector, ILoggerFactory? loggerFa this._logger = loggerFactory?.CreateLogger(typeof(CloudDrivePlugin)) ?? NullLogger.Instance; } + /// + /// List of allowed local directories from which files may be uploaded. Subdirectories of allowed directories are also permitted. + /// + /// + /// Defaults to an empty collection (no directories allowed). Must be explicitly populated + /// with trusted directory paths before any file upload operations will succeed. + /// Paths are canonicalized before validation to prevent directory traversal. + /// + public IEnumerable AllowedUploadDirectories + { + get => this._allowedUploadDirectories; + set => this._allowedUploadDirectories = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + /// List of allowed remote directory prefixes for which sharing links may be created. + /// A file is permitted if its parent directory starts with (or equals) any entry in this list. + /// Subdirectories of allowed paths are also permitted. + /// + /// + /// Defaults to an empty collection (no paths allowed). Must be explicitly populated + /// with trusted remote directory paths before any share-link operations will succeed. + /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison. + /// Matching is case-insensitive (OneDrive paths are case-insensitive). + /// + public IEnumerable AllowedSharePaths + { + get => this._allowedSharePaths; + set => this._allowedSharePaths = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + /// List of allowed remote directory prefixes from which file contents may be read. + /// A file is permitted if its parent directory starts with (or equals) any entry in this list. + /// Subdirectories of allowed paths are also permitted. + /// + /// + /// Defaults to an empty collection (no paths allowed). Must be explicitly populated + /// with trusted remote directory paths before any read operations will succeed. + /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison. + /// Matching is case-insensitive (OneDrive paths are case-insensitive). + /// + public IEnumerable AllowedReadPaths + { + get => this._allowedReadPaths; + set => this._allowedReadPaths = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + /// List of allowed remote directory prefixes to which files may be uploaded. + /// A destination is permitted if its parent directory starts with (or equals) any entry in this list. + /// Subdirectories of allowed paths are also permitted. + /// + /// + /// Defaults to an empty collection (no paths allowed). Must be explicitly populated + /// with trusted remote directory paths before any upload-destination operations will succeed. + /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison. + /// Matching is case-insensitive (OneDrive paths are case-insensitive). + /// + public IEnumerable AllowedUploadDestinationPaths + { + get => this._allowedUploadDestinationPaths; + set => this._allowedUploadDestinationPaths = value is null ? [] : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + /// /// Get the contents of a file stored in a cloud drive. /// @@ -44,6 +128,14 @@ public CloudDrivePlugin(ICloudDriveConnector connector, ILoggerFactory? loggerFa CancellationToken cancellationToken = default) { this._logger.LogDebug("Getting file content for '{0}'", filePath); + + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); + + if (!this.IsAllowedRemotePath(filePath, this._allowedReadPaths)) + { + throw new InvalidOperationException("Reading from the provided path is not allowed. Configure 'AllowedReadPaths' with trusted remote paths to enable reading."); + } + Stream? fileContentStream = await this._connector.GetFileContentStreamAsync(filePath, cancellationToken).ConfigureAwait(false); if (fileContentStream is null) @@ -77,10 +169,24 @@ public async Task UploadFileAsync( throw new ArgumentException("Variable was null or whitespace", nameof(destinationPath)); } - this._logger.LogDebug("Uploading file '{0}'", filePath); + if (!this.IsAllowedRemotePath(destinationPath, this._allowedUploadDestinationPaths)) + { + throw new InvalidOperationException("Uploading to the provided remote destination is not allowed. Configure 'AllowedUploadDestinationPaths' with trusted remote paths to enable uploads."); + } + + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); + + var canonicalPath = CanonicalizePath(filePath); + + if (!this.IsUploadPathAllowed(canonicalPath)) + { + throw new InvalidOperationException("Uploading from the provided location is not allowed. Configure 'AllowedUploadDirectories' with trusted directory paths to enable uploads."); + } + + this._logger.LogDebug("Uploading file '{0}'", canonicalPath); // TODO Add support for large file uploads (i.e. upload sessions) - await this._connector.UploadSmallFileAsync(filePath, destinationPath, cancellationToken).ConfigureAwait(false); + await this._connector.UploadSmallFileAsync(canonicalPath, destinationPath, cancellationToken).ConfigureAwait(false); } /// @@ -95,9 +201,161 @@ public async Task CreateLinkAsync( CancellationToken cancellationToken = default) { this._logger.LogDebug("Creating link for '{0}'", filePath); - const string Type = "view"; // TODO expose this as an SK variable - const string Scope = "anonymous"; // TODO expose this as an SK variable + const string Type = "view"; + const string Scope = "organization"; + + Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); + + if (!this.IsAllowedRemotePath(filePath, this._allowedSharePaths)) + { + throw new InvalidOperationException("Creating a share link for the provided path is not allowed. Configure 'AllowedSharePaths' with trusted remote paths to enable sharing."); + } return await this._connector.CreateShareLinkAsync(filePath, Type, Scope, cancellationToken).ConfigureAwait(false); } + + #region private + // Use case-insensitive comparison on Windows (case-insensitive FS), case-sensitive on Linux/macOS. + private static readonly StringComparison s_pathComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// Checks whether the provided remote path falls within one of the allowed remote directory prefixes. + /// Paths are normalized with forward slashes, dot-segments are collapsed, + /// and compared case-insensitively (OneDrive paths are case-insensitive). + /// Subdirectories of allowed paths are permitted. + /// + private bool IsAllowedRemotePath(string path, HashSet allowedPaths) + { + Ensure.NotNullOrWhitespace(path, nameof(path)); + + if (allowedPaths.Count == 0) + { + return false; + } + + // Normalize to forward slashes and collapse dot-segments to prevent traversal bypass. + var normalizedPath = NormalizeRemotePath(path); + + foreach (var allowedPath in allowedPaths) + { + var normalizedAllowed = NormalizeRemotePath(allowedPath); + if (!normalizedAllowed.EndsWith("/", StringComparison.Ordinal)) + { + normalizedAllowed += "/"; + } + + var normalizedDir = normalizedPath; + int lastSlash = normalizedDir.LastIndexOf('/'); + if (lastSlash >= 0) + { + normalizedDir = normalizedDir.Substring(0, lastSlash); + } + + if ((normalizedDir + "/").StartsWith(normalizedAllowed, StringComparison.OrdinalIgnoreCase) + || (normalizedDir + "/").Equals(normalizedAllowed, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Normalizes a remote path by replacing backslashes with forward slashes + /// and collapsing "." and ".." segments to prevent traversal bypass. + /// + private static string NormalizeRemotePath(string path) + { + var normalizedPath = path.Replace('\\', '/'); + + // Collapse ".." and "." segments to prevent traversal bypass. + var segments = normalizedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var stack = new List(); + foreach (var segment in segments) + { + if (segment == ".." && stack.Count > 0) + { + stack.RemoveAt(stack.Count - 1); + } + else if (segment != "." && segment != "..") + { + stack.Add(segment); + } + } + + return "/" + string.Join("/", stack); + } + + /// + /// Expands environment variables and resolves the path to its canonical form. + /// This must be called before validation to prevent validate/use mismatches. + /// + private static string CanonicalizePath(string path) + { + Ensure.NotNullOrWhitespace(path, nameof(path)); + + if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("//", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + + // Expand environment variables first, then canonicalize — so that + // validation and I/O operate on the same resolved path. + var expanded = Environment.ExpandEnvironmentVariables(path); + + // Re-check after expansion: an env var could have expanded to a UNC + // or extended-path prefix (e.g., %NETSHARE% → \\server\share). + if (expanded.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase) || + expanded.StartsWith("//", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + + return Path.GetFullPath(expanded); + } + + /// + /// Checks whether a canonicalized file path falls within one of the allowed upload directories. + /// Subdirectories of allowed directories are also permitted. + /// + private bool IsUploadPathAllowed(string canonicalPath) + { + Ensure.NotNullOrWhitespace(canonicalPath, nameof(canonicalPath)); + + string? directoryPath = Path.GetDirectoryName(canonicalPath); + + if (string.IsNullOrEmpty(directoryPath)) + { + throw new ArgumentException("Invalid file path, a fully qualified file location must be specified.", nameof(canonicalPath)); + } + + if (this._allowedUploadDirectories.Count == 0) + { + return false; + } + + foreach (var allowedDirectory in this._allowedUploadDirectories) + { + var canonicalAllowed = Path.GetFullPath(allowedDirectory); + var separator = Path.DirectorySeparatorChar.ToString(); + if (!canonicalAllowed.EndsWith(separator, s_pathComparison)) + { + canonicalAllowed += separator; + } + + if (directoryPath.StartsWith(canonicalAllowed, s_pathComparison) + || (directoryPath + separator).Equals(canonicalAllowed, s_pathComparison)) + { + return true; + } + } + + return false; + } + #endregion } diff --git a/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs b/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs index 74971ff7d82a..262e7453944e 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/Connectors/OneDriveConnector.cs @@ -114,7 +114,7 @@ public async Task UploadSmallFileAsync(string filePath, string destinationPath, } /// - public async Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "anonymous", + public async Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "organization", CancellationToken cancellationToken = default) { Ensure.NotNullOrWhitespace(filePath, nameof(filePath)); diff --git a/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs b/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs index a54d46464ae1..e603ff68cca7 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs +++ b/dotnet/src/Plugins/Plugins.MsGraph/ICloudDriveConnector.cs @@ -19,7 +19,7 @@ public interface ICloudDriveConnector /// Scope of the link. /// The to monitor for cancellation requests. The default is . /// Shareable link. - Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "anonymous", CancellationToken cancellationToken = default); + Task CreateShareLinkAsync(string filePath, string type = "view", string scope = "organization", CancellationToken cancellationToken = default); /// /// Get the content of a file. diff --git a/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj b/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj index cda3a63176f2..9310761a978c 100644 --- a/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj +++ b/dotnet/src/Plugins/Plugins.MsGraph/Plugins.MsGraph.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs index d68b9833847a..1308485da8e8 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Document/DocumentPluginTests.cs @@ -239,15 +239,137 @@ public async Task ItAllowsSubdirectoriesOfAllowedFoldersAsync() [Fact] public async Task ItDeniesRelativePathsAsync() { - // Arrange + // Arrange — use a unique subfolder so this test is deterministic regardless of CWD + var allowedFolder = Path.Combine(Path.GetTempPath(), "unique-allowed-" + Guid.NewGuid().ToString("N")[..8]); var fileSystemConnectorMock = new Mock(); var documentConnectorMock = new Mock(); var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) { - AllowedDirectories = [Path.GetTempPath()] + AllowedDirectories = [allowedFolder] }; - // Act & Assert — relative paths are caught by the "fully qualified" check - await Assert.ThrowsAsync(async () => await target.ReadTextAsync("myfile.docx")); + // Act & Assert — relative paths resolve to CWD after canonicalization, + // which will be outside the allowed directories + await Assert.ThrowsAsync(async () => await target.ReadTextAsync("myfile.docx")); + await Assert.ThrowsAsync(async () => await target.AppendTextAsync("text", "myfile.docx")); + } + + [Fact] + public async Task ItDeniesEnvVarExpansionBypassOnReadAsync() + { + // Arrange — use a test-specific env var that expands to a value containing + // a path separator + ".." which creates a traversal after expansion. + var allowedFolder = Path.Combine(Path.GetTempPath(), "allowed-sandbox"); + var envVarName = "SK_TEST_EXPAND_" + Guid.NewGuid().ToString("N")[..8]; + + try + { + // The env var value starts with a separator + ".." so that after expansion + // the path becomes: allowed-sandbox/..elsewheresecret.docx + Environment.SetEnvironmentVariable(envVarName, + $"{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}elsewhere"); + var maliciousPath = Path.Combine(allowedFolder, $"%{envVarName}%", "secret.docx"); + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert — the path should be denied because env vars are expanded + // before validation, so the canonical path lands outside the allowed directory. + await Assert.ThrowsAsync(async () => await target.ReadTextAsync(maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } + } + + [Fact] + public async Task ItDeniesEnvVarExpansionBypassOnWriteAsync() + { + // Arrange — same pattern as read test, for the write path. + var allowedFolder = Path.Combine(Path.GetTempPath(), "allowed-sandbox"); + var envVarName = "SK_TEST_EXPAND_W_" + Guid.NewGuid().ToString("N")[..8]; + + try + { + Environment.SetEnvironmentVariable(envVarName, + $"{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}elsewhere"); + var maliciousPath = Path.Combine(allowedFolder, $"%{envVarName}%", "secret.docx"); + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert + await Assert.ThrowsAsync(async () => await target.AppendTextAsync("text", maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } + } + + [Fact] + public async Task ItDeniesEnvVarExpansionToAbsolutePathAsync() + { + // Arrange — env var that expands to an absolute path outside the sandbox + var allowedFolder = Path.Combine(Path.GetTempPath(), "sandbox"); + var envVarName = "SK_TEST_ABS_" + Guid.NewGuid().ToString("N")[..8]; + var outsidePath = Path.Combine(Path.GetTempPath(), "outside"); + + try + { + Environment.SetEnvironmentVariable(envVarName, outsidePath); + var maliciousPath = Path.Combine($"%{envVarName}%", "secret.docx"); + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert — after env-var expansion, the path resolves outside the sandbox + await Assert.ThrowsAsync(async () => await target.ReadTextAsync(maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } + } + + [Fact] + public async Task ItDeniesUncPathsIntroducedViaEnvVarExpansionAsync() + { + // Arrange — env var that expands to a UNC path + var allowedFolder = Path.Combine(Path.GetTempPath(), "sandbox"); + var envVarName = "SK_TEST_UNC_" + Guid.NewGuid().ToString("N")[..8]; + + try + { + Environment.SetEnvironmentVariable(envVarName, @"\\evil-server\share"); + var maliciousPath = $"%{envVarName}%{Path.DirectorySeparatorChar}secret.docx"; + + var fileSystemConnectorMock = new Mock(); + var documentConnectorMock = new Mock(); + var target = new DocumentPlugin(documentConnectorMock.Object, fileSystemConnectorMock.Object) + { + AllowedDirectories = [allowedFolder] + }; + + // Act & Assert — expanded path is UNC, should be rejected + await Assert.ThrowsAsync(async () => await target.ReadTextAsync(maliciousPath)); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, null); + } } } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs index ee15a1a92725..b8ce26d1bed4 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -17,16 +18,17 @@ public class CloudDrivePluginTests public async Task UploadSmallFileAsyncSucceedsAsync() { // Arrange - string anyFilePath = Guid.NewGuid().ToString(); + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); Mock connectorMock = new(); connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - CloudDrivePlugin target = new(connectorMock.Object); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; // Act - await target.UploadFileAsync(anyFilePath, Guid.NewGuid().ToString()); + await target.UploadFileAsync(anyFilePath, "/remote.txt"); // Assert connectorMock.VerifyAll(); @@ -36,14 +38,14 @@ public async Task UploadSmallFileAsyncSucceedsAsync() public async Task CreateLinkAsyncSucceedsAsync() { // Arrange - string anyFilePath = Guid.NewGuid().ToString(); + string anyFilePath = "/Documents/report.docx"; string anyLink = Guid.NewGuid().ToString(); Mock connectorMock = new(); - connectorMock.Setup(c => c.CreateShareLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + connectorMock.Setup(c => c.CreateShareLinkAsync(anyFilePath, "view", "organization", It.IsAny())) .ReturnsAsync(anyLink); - CloudDrivePlugin target = new(connectorMock.Object); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents"] }; // Act string actual = await target.CreateLinkAsync(anyFilePath); @@ -53,19 +55,84 @@ public async Task CreateLinkAsyncSucceedsAsync() connectorMock.VerifyAll(); } + [Fact] + public async Task CreateLinkAsyncUsesOrganizationScopeAsync() + { + // Arrange + string anyFilePath = "/Documents/report.docx"; + string anyLink = Guid.NewGuid().ToString(); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.CreateShareLinkAsync(anyFilePath, "view", "organization", It.IsAny())) + .ReturnsAsync(anyLink); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents"] }; + + // Act + await target.CreateLinkAsync(anyFilePath); + + // Assert — verify "organization" scope was passed, not "anonymous" + connectorMock.Verify(c => c.CreateShareLinkAsync(anyFilePath, "view", "organization", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreateLinkAsyncDeniesAllPathsByDefaultAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object); + + // Act & Assert — default config denies all share paths + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Documents/secret.docx")); + } + + [Fact] + public async Task CreateLinkAsyncDeniesPathsOutsideAllowedAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents/Public"] }; + + // Act & Assert — path outside allowed share paths is denied + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Confidential/secret.docx")); + } + + [Fact] + public async Task CreateLinkAsyncAllowsSubdirectoriesOfAllowedSharePathsAsync() + { + // Arrange + string filePath = "/Documents/Public/Reports/Q1/summary.docx"; + string anyLink = Guid.NewGuid().ToString(); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.CreateShareLinkAsync(filePath, "view", "organization", It.IsAny())) + .ReturnsAsync(anyLink); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents/Public"] }; + + // Act — subdirectory of allowed share path should succeed + string actual = await target.CreateLinkAsync(filePath); + + // Assert + Assert.Equal(anyLink, actual); + connectorMock.VerifyAll(); + } + [Fact] public async Task GetFileContentAsyncSucceedsAsync() { - string anyFilePath = Guid.NewGuid().ToString(); + string anyFilePath = "/Documents/report.txt"; string expectedContent = Guid.NewGuid().ToString(); using MemoryStream expectedStream = new(Encoding.UTF8.GetBytes(expectedContent)); // Arrange Mock connectorMock = new(); - connectorMock.Setup(c => c.GetFileContentStreamAsync(It.IsAny(), It.IsAny())) + connectorMock.Setup(c => c.GetFileContentStreamAsync(anyFilePath, It.IsAny())) .ReturnsAsync(expectedStream); - CloudDrivePlugin target = new(connectorMock.Object); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents"] }; // Act string? actual = await target.GetFileContentAsync(anyFilePath); @@ -74,4 +141,440 @@ public async Task GetFileContentAsyncSucceedsAsync() Assert.Equal(expectedContent, actual); connectorMock.VerifyAll(); } + + [Fact] + public async Task ItDeniesAllPathsByDefaultAsync() + { + // Arrange + string filePath = Path.Combine(Path.GetTempPath(), "somefile.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object); + + // Act & Assert — default config denies all paths + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(filePath, "/remote.txt")); + } + + [Fact] + public async Task ItDeniesPathTraversalAsync() + { + // Arrange + var allowedDir = Path.Combine(Path.GetTempPath(), "allowed-folder"); + var traversalPath = Path.Combine(allowedDir, "..", "outside-folder", "secret.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(traversalPath, "/remote.txt")); + } + + [Fact] + public async Task ItDeniesUncPathsAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — UNC paths are rejected (ArgumentException on Windows, InvalidOperationException on Linux + // where the path is canonicalized differently and fails the allowlist check instead) + await Assert.ThrowsAnyAsync(async () => + await target.UploadFileAsync("\\\\UNC\\server\\folder\\file.txt", "/remote.txt")); + } + + [Fact] + public async Task ItDeniesDisallowedDirectoriesAsync() + { + // Arrange + var allowedDir = Path.Combine(Path.GetTempPath(), "allowed"); + var disallowedPath = Path.Combine(Path.GetTempPath(), "disallowed", "file.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(disallowedPath, "/remote.txt")); + } + + [Fact] + public async Task ItAllowsSubdirectoriesOfAllowedDirectoriesAsync() + { + // Arrange + var allowedDir = Path.GetTempPath(); + var subDirPath = Path.Combine(allowedDir, "subdir", "nested", "file.txt"); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + + // Act — subdirectory of allowed folder should succeed + await target.UploadFileAsync(subDirPath, "/remote.txt"); + + // Assert + connectorMock.VerifyAll(); + } + + [Fact] + public async Task ItExpandsEnvironmentVariablesAndValidatesAsync() + { + // Arrange — set a dedicated test env var to avoid platform-specific assumptions + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var envVarName = "SK_TEST_UPLOAD_DIR"; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + try + { + Environment.SetEnvironmentVariable(envVarName, tempDir); + var envVarPath = Path.Combine($"%{envVarName}%", "testfile.txt"); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [tempDir], AllowedUploadDestinationPaths = ["/"] }; + + // Act — env var should be expanded and path should be allowed + await target.UploadFileAsync(envVarPath, "/remote.txt"); + + // Assert + connectorMock.VerifyAll(); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task ItDeniesExpandedEnvironmentVariablePathsOutsideAllowedAsync() + { + // Arrange — set a dedicated test env var; allow a subdirectory but env var expands outside it + var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var allowedDir = Path.Combine(tempDir, "specific-allowed"); + var envVarName = "SK_TEST_UPLOAD_DIR"; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + try + { + Environment.SetEnvironmentVariable(envVarName, tempDir); + var envVarPath = Path.Combine($"%{envVarName}%", "outside-file.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — expanded path is outside allowed directory + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(envVarPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task ItRespectsPlatformCaseSensitivityAsync() + { + // Arrange — use differently-cased allowed dir vs file path + var allowedDir = Path.Combine(Path.GetTempPath(), "AllowedFolder"); + var filePath = Path.Combine(Path.GetTempPath(), "allowedfolder", "file.txt"); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows: case-insensitive FS — differently-cased path should be allowed + await target.UploadFileAsync(filePath, "/remote.txt"); + connectorMock.VerifyAll(); + } + else + { + // Linux/macOS: case-sensitive FS — differently-cased path should be denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(filePath, "/remote.txt")); + } + } + + [Fact] + public async Task ItDeniesEnvVarExpansionToUncPathAsync() + { + // Arrange — env var that expands to a UNC path should be rejected + var envVarName = "SK_TEST_UNC_" + Guid.NewGuid().ToString("N")[..8]; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + + try + { + Environment.SetEnvironmentVariable(envVarName, "\\\\server\\share"); + var maliciousPath = Path.Combine($"%{envVarName}%", "secret.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — UNC path after env-var expansion should be rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(maliciousPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task CreateLinkAsyncDeniesPathTraversalAsync() + { + // Arrange — path with ".." segments that would bypass the allowed path prefix + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents"] }; + + // Act & Assert — traversal path should be denied even though it string-starts-with /Documents + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Documents/../Confidential/secret.docx")); + } + + [Fact] + public async Task CreateLinkAsyncDeniesPathTraversalWithSubdirAsync() + { + // Arrange — traversal from an allowed subdirectory to an unauthorized location + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedSharePaths = ["/Documents/Public"] }; + + // Act & Assert — traversal should be denied + await Assert.ThrowsAsync(async () => + await target.CreateLinkAsync("/Documents/Public/../Confidential/secret.docx")); + } + + [Fact] + public async Task ItDeniesForwardSlashUncPathsAsync() + { + // Arrange — forward-slash UNC paths should also be rejected + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — forward-slash UNC path is rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync("//server/share/file.txt", "/remote.txt")); + } + + [Fact] + public async Task ItDeniesEnvVarExpansionToForwardSlashUncPathAsync() + { + // Arrange — env var that expands to a forward-slash UNC path should be rejected + var envVarName = "SK_TEST_FWDUNC_" + Guid.NewGuid().ToString("N")[..8]; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + + try + { + Environment.SetEnvironmentVariable(envVarName, "//server/share"); + var maliciousPath = $"%{envVarName}%/secret.txt"; + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [Path.GetTempPath()], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — forward-slash UNC path after env-var expansion should be rejected + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(maliciousPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task ItDeniesEnvVarTraversalBypassAsync() + { + // Arrange — env var that expands to a traversal path + var allowedDir = Path.Combine(Path.GetTempPath(), "allowed-sandbox"); + var envVarName = "SK_TEST_TRAV_" + Guid.NewGuid().ToString("N")[..8]; + var originalValue = Environment.GetEnvironmentVariable(envVarName); + + try + { + Environment.SetEnvironmentVariable(envVarName, + $"{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}elsewhere"); + var maliciousPath = Path.Combine(allowedDir, $"%{envVarName}%", "secret.txt"); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir], AllowedUploadDestinationPaths = ["/"] }; + + // Act & Assert — after env-var expansion, the canonical path lands outside the allowed directory + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(maliciousPath, "/remote.txt")); + } + finally + { + Environment.SetEnvironmentVariable(envVarName, originalValue); + } + } + + [Fact] + public async Task GetFileContentAsyncDeniesAllPathsByDefaultAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object); + + // Act & Assert — default config denies all read paths + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Documents/secret.docx")); + } + + [Fact] + public async Task GetFileContentAsyncDeniesPathsOutsideAllowedAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents/Public"] }; + + // Act & Assert — path outside allowed read paths is denied + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Confidential/secret.docx")); + } + + [Fact] + public async Task GetFileContentAsyncDeniesPathTraversalAsync() + { + // Arrange + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents"] }; + + // Act & Assert — traversal path should be denied + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Documents/../Confidential/secret.docx")); + } + + [Fact] + public async Task GetFileContentAsyncAllowsSubdirectoriesOfAllowedReadPathsAsync() + { + // Arrange + string filePath = "/Documents/Public/Reports/Q1/summary.docx"; + string expectedContent = "file content"; + using MemoryStream expectedStream = new(Encoding.UTF8.GetBytes(expectedContent)); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.GetFileContentStreamAsync(filePath, It.IsAny())) + .ReturnsAsync(expectedStream); + + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents/Public"] }; + + // Act + string? actual = await target.GetFileContentAsync(filePath); + + // Assert + Assert.Equal(expectedContent, actual); + connectorMock.VerifyAll(); + } + + [Fact] + public async Task GetFileContentAsyncDoesNotCallConnectorWhenDeniedAsync() + { + // Arrange + Mock connectorMock = new(MockBehavior.Strict); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedReadPaths = ["/Documents"] }; + + // Act & Assert — connector should never be called for denied paths + await Assert.ThrowsAsync(async () => + await target.GetFileContentAsync("/Confidential/secret.docx")); + connectorMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task UploadFileAsyncDeniesDisallowedDestinationPathAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [allowedDir], + AllowedUploadDestinationPaths = ["/Documents/Uploads"] + }; + + // Act & Assert — destination outside allowed paths is denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(anyFilePath, "/Confidential/secret.docx")); + } + + [Fact] + public async Task UploadFileAsyncDeniesAllDestinationPathsByDefaultAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) { AllowedUploadDirectories = [allowedDir] }; + + // Act & Assert — default config denies all destination paths + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(anyFilePath, "/remote.txt")); + } + + [Fact] + public async Task UploadFileAsyncAllowsDestinationInAllowedPathAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.UploadSmallFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [allowedDir], + AllowedUploadDestinationPaths = ["/Documents"] + }; + + // Act + await target.UploadFileAsync(anyFilePath, "/Documents/Uploads/report.txt"); + + // Assert + connectorMock.VerifyAll(); + } + + [Fact] + public async Task UploadFileAsyncDeniesDestinationPathTraversalAsync() + { + // Arrange + string allowedDir = Path.GetTempPath(); + string anyFilePath = Path.Combine(allowedDir, Guid.NewGuid().ToString()); + + Mock connectorMock = new(); + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [allowedDir], + AllowedUploadDestinationPaths = ["/Documents"] + }; + + // Act & Assert — traversal in destination path should be denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(anyFilePath, "/Documents/../Confidential/secret.docx")); + } + + [Fact] + public async Task UploadFileAsyncDoesNotCallConnectorWhenDestinationDeniedAsync() + { + // Arrange + Mock connectorMock = new(MockBehavior.Strict); + CloudDrivePlugin target = new(connectorMock.Object) + { + AllowedUploadDirectories = [Path.GetTempPath()], + AllowedUploadDestinationPaths = ["/Documents"] + }; + + // Act & Assert — connector should never be called when destination is denied + await Assert.ThrowsAsync(async () => + await target.UploadFileAsync(Path.Combine(Path.GetTempPath(), "file.txt"), "/Confidential/secret.docx")); + connectorMock.VerifyNoOtherCalls(); + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs index 992651fda137..f83a11544b0f 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.TextGeneration; @@ -183,6 +184,21 @@ protected void ThrowIfFrozen() /// Prepared chat history. internal ChatHistory ChatClientPrepareChatHistoryForRequest(ChatHistory chatHistory) => this.PrepareChatHistoryForRequest(chatHistory); + /// + /// When some derived requires post-processing of the produced + /// (for example, setting ), + /// this method can be overridden. Called after the standard ToChatOptions conversion completes. + /// + /// The instance to prepare. + protected virtual void PrepareChatOptionsForRequest(ChatOptions options) { } + + /// + /// Internal bridge used by PromptExecutionSettingsExtensions.ToChatOptions without exposing + /// the protected publicly. + /// + /// Target to prepare. + internal void ChatClientPrepareChatOptionsForRequest(ChatOptions options) => this.PrepareChatOptionsForRequest(options); + #region private ================================================================================ private string? _modelId; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs index 59274f537261..3c2accd5942b 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs @@ -23,6 +23,10 @@ public static class PromptExecutionSettingsExtensions return null; } + // Preserve the original (potentially derived) settings instance so derived hooks can run after the + // base-type roundtrip below replaces 'settings' with a base PromptExecutionSettings. + PromptExecutionSettings derivedSettings = settings; + if (settings.GetType() != typeof(PromptExecutionSettings)) { var originalFunctionChoiceBehavior = settings.FunctionChoiceBehavior; @@ -176,16 +180,16 @@ public static class PromptExecutionSettingsExtensions options.AllowMultipleToolCalls = autoChoiceBehavior.Options?.AllowParallelCalls; } else - if (settings.FunctionChoiceBehavior is NoneFunctionChoiceBehavior noneFunctionChoiceBehavior) - { - options.ToolMode = ChatToolMode.None; - } - else - if (settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior requiredFunctionChoiceBehavior) - { - options.ToolMode = ChatToolMode.RequireAny; - options.AllowMultipleToolCalls = requiredFunctionChoiceBehavior.Options?.AllowParallelCalls; - } + if (settings.FunctionChoiceBehavior is NoneFunctionChoiceBehavior noneFunctionChoiceBehavior) + { + options.ToolMode = ChatToolMode.None; + } + else + if (settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior requiredFunctionChoiceBehavior) + { + options.ToolMode = ChatToolMode.RequireAny; + options.AllowMultipleToolCalls = requiredFunctionChoiceBehavior.Options?.AllowParallelCalls; + } options.Tools = []; foreach (var function in functions) @@ -196,6 +200,11 @@ public static class PromptExecutionSettingsExtensions } } + // Allow derived PromptExecutionSettings to perform post-processing on the produced ChatOptions + // (for example, setting ChatOptions.RawRepresentationFactory). Invoke on the original derived instance + // since 'settings' may have been replaced with a base-type clone above. + derivedSettings.ChatClientPrepareChatOptionsForRequest(options); + // Enables usage of AutoFunctionInvocationFilters return kernel is null ? options diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs index 42c4b7f6e6a9..83374fe87002 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs @@ -161,7 +161,22 @@ private static void LogFunctionResultValueInternal(this ILogger logger, string? } catch (NotSupportedException ex) { - s_logFunctionResultValue(logger, pluginName, functionName, "Failed to log function result value", ex); + // Fall back to ToString() when JSON serialization isn't supported for this type + // (e.g. Microsoft.Extensions.AI.TextContent is not registered in AbstractionsJsonContext) + try + { + var toStringValue = resultValue?.Value?.ToString() ?? string.Empty; + s_logFunctionResultValue(logger, pluginName, functionName, toStringValue, null); + } + catch (Exception toStringEx) + { + s_logFunctionResultValue( + logger, + pluginName, + functionName, + "Failed to log function result value", + new AggregateException(ex, toStringEx)); + } } } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs index ec2642fa12e1..24daeccb6372 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; @@ -10,7 +11,7 @@ namespace SemanticKernel.UnitTests.Functions; -public class KernelFunctionLogMessagesTests +public partial class KernelFunctionLogMessagesTests { [Theory] [InlineData(typeof(string))] @@ -48,9 +49,46 @@ public void ItShouldLogFunctionResultOfAnyType(Type resultType) It.IsAny>())); } + [Fact] + public void ItShouldFallBackToToStringWhenJsonSerializationIsNotSupported() + { + // Arrange + var logger = new Mock(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + // TypeNotInJsonContext cannot be cast to string and is not registered in the restricted JSON context + var unserializableValue = new TypeNotInJsonContext(); + var functionResult = new FunctionResult(KernelFunctionFactory.CreateFromMethod(() => { }), unserializableValue); + + // Use a restricted JsonSerializerOptions that knows about object but not TypeNotInJsonContext, + // simulating the AOT scenario where AbstractionsJsonContext is used and an unregistered + // MEAI type (e.g. Microsoft.Extensions.AI.TextContent) is returned from an MCP tool. + var restrictedOptions = RestrictedJsonContext.Default.Options; + + // Act + logger.Object.LogFunctionResultValue("p1", "f1", functionResult, restrictedOptions); + + // Assert - ToString() fallback should have been used, not the error message + logger.Verify(l => l.Log( + LogLevel.Trace, + 0, + It.Is((o, _) => o.ToString() == "Function p1-f1 result: TypeNotInJsonContext()"), + null, + It.IsAny>())); + } + private sealed class User { [JsonPropertyName("name")] public string? Name { get; set; } } + + private sealed class TypeNotInJsonContext + { + public override string ToString() => "TypeNotInJsonContext()"; + } + + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(object))] + private sealed partial class RestrictedJsonContext : JsonSerializerContext { } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs index e1258d124c6a..1fc60b79e880 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Utilities/AIConnectors/FunctionCallsProcessorTests.cs @@ -855,6 +855,24 @@ public void ItShouldSerializeFunctionResultsWithStringProperties() Assert.Equal("{\"Text\":\"テスト\"}", result); } + [Fact] + public void ItShouldPreserveImageContentWithoutSerialization() + { + // Arrange + var imageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes + var functionResult = new ImageContent(imageData, "image/png"); + + // Act + var result = FunctionCallsProcessor.ProcessFunctionResult(functionResult); + + // Assert + Assert.IsType(result); + var imageResult = (ImageContent)result; + Assert.Equal("image/png", imageResult.MimeType); + Assert.NotNull(imageResult.Data); + Assert.Equal(imageData, imageResult.Data.Value.ToArray()); + } + [Fact] public async Task ItShouldPassPromptExecutionSettingsToAutoFunctionInvocationFilterAsync() { diff --git a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj index 52b98d55b40e..0d6bca948aaf 100644 --- a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj +++ b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoDB.csproj @@ -30,6 +30,8 @@ + + diff --git a/dotnet/src/VectorData/MongoDB/MongoDB.csproj b/dotnet/src/VectorData/MongoDB/MongoDB.csproj index fa6369f2e179..4bc2786c1161 100644 --- a/dotnet/src/VectorData/MongoDB/MongoDB.csproj +++ b/dotnet/src/VectorData/MongoDB/MongoDB.csproj @@ -30,6 +30,8 @@ + + diff --git a/python/README.md b/python/README.md index 128982456b6e..6dd080b09e7f 100644 --- a/python/README.md +++ b/python/README.md @@ -1,5 +1,10 @@ # Get Started with Semantic Kernel Python +> [!IMPORTANT] +> Semantic Kernel is now [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel. Microsoft Agent Framework is now available at version 1.0 as a production-ready release: stable APIs, and a commitment to long-term support. Whether you're building a single assistant or orchestrating a fleet of specialized agents, Microsoft Agent Framework 1.0 gives you enterprise-grade multi-agent orchestration, multi-provider model support, and cross-runtime interoperability via A2A and MCP. +> +> Learn more about Semantic Kernel and Agent Framework here: [Semantic Kernel and Microsoft Agent Framework on the Agent Framework blog](https://devblogs.microsoft.com/agent-framework/semantic-kernel-and-microsoft-agent-framework/), and try out the [Semantic Kernel migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel). + Highlights - Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems - Multi-Agent Systems: Model workflows and collaboration between AI specialists @@ -126,6 +131,7 @@ from semantic_kernel.agents import ChatCompletionAgent from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion, OpenAIChatPromptExecutionSettings from semantic_kernel.functions import kernel_function, KernelArguments + class MenuPlugin: @kernel_function(description="Provides a list of specials from the menu.") def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]: @@ -141,11 +147,13 @@ class MenuPlugin: ) -> Annotated[str, "Returns the price of the menu item."]: return "$9.99" + class MenuItem(BaseModel): # Used for structured outputs price: float name: str + async def main(): # Configure structured outputs format settings = OpenAIChatPromptExecutionSettings() @@ -157,7 +165,7 @@ async def main(): name="SK-Assistant", instructions="You are a helpful assistant.", plugins=[MenuPlugin()], - arguments=KernelArguments(settings) + arguments=KernelArguments(settings), ) response = await agent.get_response("What is the price of the soup special?") @@ -166,7 +174,8 @@ async def main(): # Output: # The price of the Clam Chowder, which is the soup special, is $9.99. -asyncio.run(main()) + +asyncio.run(main()) ``` You can explore additional getting started agent samples [here](https://github.com/microsoft/semantic-kernel/tree/main/python/samples/getting_started_with_agents). @@ -181,6 +190,7 @@ from semantic_kernel.agents import ChatCompletionAgent, GroupChatOrchestration, from semantic_kernel.agents.runtime import InProcessRuntime from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion + def get_agents(): return [ ChatCompletionAgent( @@ -195,6 +205,7 @@ def get_agents(): ), ] + async def main(): agents = get_agents() group_chat = GroupChatOrchestration( @@ -215,6 +226,7 @@ async def main(): await runtime.stop_when_idle() + if __name__ == "__main__": asyncio.run(main()) ``` diff --git a/python/pyproject.toml b/python/pyproject.toml index 1c2dd95d3038..8a0deef93a72 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "azure-ai-agents >= 1.2.0b3", "aiohttp ~= 3.8", "cloudevents ~=1.0", - "pydantic >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.13", + "pydantic >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.14", "pydantic-settings ~= 2.0", "defusedxml ~= 0.7", # azure identity @@ -68,12 +68,12 @@ autogen = [ "autogen-agentchat >= 0.2, <0.4" ] aws = [ - "boto3>=1.36.4,<1.41.0", + "boto3>=1.36.4,<1.43.0", ] azure = [ "azure-ai-inference >= 1.0.0b6", "azure-core-tracing-opentelemetry >= 1.0.0b11", - "azure-search-documents >= 11.6.0b4", + "azure-search-documents >= 11.6.0b4, < 12.0.0", "azure-cosmos ~= 4.7" ] chroma = [ @@ -87,8 +87,8 @@ faiss = [ "faiss-cpu>=1.10.0" ] google = [ - "google-cloud-aiplatform ~= 1.114.0", - "google-genai ~= 1.51.0" + "google-cloud-aiplatform>=1.114,<1.134", + "google-genai >= 1.51,< 1.75" ] hugging_face = [ "transformers[torch] ~= 4.28", @@ -103,7 +103,7 @@ milvus = [ "milvus >= 2.3,<2.3.8; platform_system != 'Windows'" ] mistralai = [ - "mistralai >= 1.2,< 2.0" + "mistralai >= 1.2,< 2.4.6" ] mongo = [ "pymongo >= 4.8.0, < 4.16", @@ -116,8 +116,9 @@ ollama = [ "ollama ~= 0.4" ] onnx = [ - # Pinning due to uv tag-resolution issues on macOS. - "onnxruntime==1.22.1", + # onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10. + "onnxruntime==1.22.1; python_version == '3.10'", + "onnxruntime>=1.24.3; python_version > '3.10'", "onnxruntime-genai==0.9.0" ] oracledb = [ diff --git a/python/semantic_kernel/__init__.py b/python/semantic_kernel/__init__.py index 79b0f3f3ccf8..090bd7d0cd47 100644 --- a/python/semantic_kernel/__init__.py +++ b/python/semantic_kernel/__init__.py @@ -2,7 +2,7 @@ from semantic_kernel.kernel import Kernel -__version__ = "1.41.3" +__version__ = "1.42.0" DEFAULT_RC_VERSION = f"{__version__}-rc9" diff --git a/python/semantic_kernel/connectors/azure_ai_search.py b/python/semantic_kernel/connectors/azure_ai_search.py index 8a469b6aa2b2..f01bef97a96f 100644 --- a/python/semantic_kernel/connectors/azure_ai_search.py +++ b/python/semantic_kernel/connectors/azure_ai_search.py @@ -7,7 +7,7 @@ from collections.abc import Sequence from typing import Any, ClassVar, Final, Generic, TypeVar -from azure.core.credentials import AzureKeyCredential, TokenCredential +from azure.core.credentials import AzureKeyCredential from azure.core.credentials_async import AsyncTokenCredential from azure.search.documents.aio import SearchClient from azure.search.documents.indexes.aio import SearchIndexClient @@ -149,23 +149,47 @@ class AzureAISearchSettings(KernelBaseSettings): def _get_search_client( - search_index_client: SearchIndexClient, collection_name: str | None, **kwargs: Any + endpoint: str, + collection_name: str | None, + credential: "AzureKeyCredential | AsyncTokenCredential", + **kwargs: Any, ) -> SearchClient: """Create a search client for a collection.""" if not collection_name: raise VectorStoreInitializationException("Collection name is required to create a search client.") try: - return SearchClient(search_index_client._endpoint, collection_name, search_index_client._credential, **kwargs) + return SearchClient(endpoint, collection_name, credential, **kwargs) except ValueError as exc: raise VectorStoreInitializationException( f"Failed to create Azure Cognitive Search client for collection {collection_name}." ) from exc +def _resolve_credential( + azure_ai_search_settings: AzureAISearchSettings, + azure_credential: AzureKeyCredential | None = None, + token_credential: "AsyncTokenCredential | None" = None, +) -> "AzureKeyCredential | AsyncTokenCredential": + """Resolve the credential to use for Azure AI Search. + + Args: + azure_ai_search_settings: Azure AI Search settings. + azure_credential: Optional Azure credentials (default: {None}). + token_credential: Optional Token credential (default: {None}). + """ + if azure_credential: + return azure_credential + if token_credential: + return token_credential + if azure_ai_search_settings.api_key: + return AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value()) + raise ServiceInitializationError("Error: missing Azure AI Search client credentials.") + + def _get_search_index_client( azure_ai_search_settings: AzureAISearchSettings, azure_credential: AzureKeyCredential | None = None, - token_credential: "AsyncTokenCredential | TokenCredential | None" = None, + token_credential: "AsyncTokenCredential | None" = None, ) -> SearchIndexClient: """Return a client for Azure AI Search. @@ -174,20 +198,11 @@ def _get_search_index_client( azure_credential: Optional Azure credentials (default: {None}). token_credential: Optional Token credential (default: {None}). """ - # Credentials - credential: "AzureKeyCredential | AsyncTokenCredential | TokenCredential | None" = None - if azure_credential: - credential = azure_credential - elif token_credential: - credential = token_credential - elif azure_ai_search_settings.api_key: - credential = AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value()) - else: - raise ServiceInitializationError("Error: missing Azure AI Search client credentials.") + credential = _resolve_credential(azure_ai_search_settings, azure_credential, token_credential) return SearchIndexClient( endpoint=str(azure_ai_search_settings.endpoint), - credential=credential, # type: ignore + credential=credential, headers=prepend_semantic_kernel_to_user_agent({}) if APP_INFO else None, ) @@ -286,6 +301,8 @@ class AzureAISearchCollection( search_client: SearchClient search_index_client: SearchIndexClient + search_endpoint: str | None = None + search_credential: Any = None supported_key_types: ClassVar[set[str] | None] = {"str"} supported_vector_types: ClassVar[set[str] | None] = {"float", "int"} supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR, SearchType.KEYWORD_HYBRID} @@ -299,6 +316,7 @@ def __init__( search_index_client: SearchIndexClient | None = None, search_client: SearchClient | None = None, embedding_generator: "EmbeddingGeneratorBase | None" = None, + search_credential: "AzureKeyCredential | AsyncTokenCredential | None" = None, **kwargs: Any, ) -> None: """Initializes a new instance of the AzureAISearchCollection class. @@ -319,13 +337,16 @@ def __init__( used for creating and deleting indexes. search_client: The search client for interacting with Azure AI Search, used for record operations. + search_credential: The credential used to authenticate with Azure AI Search. + If not provided, it will be resolved from azure_credentials, token_credentials, + or api_key in kwargs/environment. embedding_generator: The embedding generator, optional. **kwargs: Additional keyword arguments, including: The same keyword arguments used for AzureAISearchVectorStore: - search_endpoint: str | None = None, + search_endpoint: The endpoint of the Azure AI Search service, optional. api_key: str | None = None, azure_credentials: AzureKeyCredential | None = None, - token_credentials: AsyncTokenCredential | TokenCredential | None = None, + token_credentials: AsyncTokenCredential | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None @@ -343,6 +364,8 @@ def __init__( collection_name=collection_name, search_client=search_client, search_index_client=search_index_client, + search_endpoint=kwargs.get("search_endpoint"), + search_credential=search_credential, managed_search_index_client=False, managed_client=False, embedding_generator=embedding_generator, @@ -360,14 +383,24 @@ def __init__( ) except ValidationError as exc: raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc + endpoint = str(azure_ai_search_settings.endpoint) + credential = search_credential or _resolve_credential( + azure_ai_search_settings, + azure_credential=kwargs.get("azure_credentials"), + token_credential=kwargs.get("token_credentials"), + ) super().__init__( record_type=record_type, definition=definition, collection_name=azure_ai_search_settings.index_name, search_client=_get_search_client( - search_index_client=search_index_client, collection_name=azure_ai_search_settings.index_name + endpoint=endpoint, + collection_name=azure_ai_search_settings.index_name, + credential=credential, ), search_index_client=search_index_client, + search_endpoint=endpoint, + search_credential=credential, managed_search_index_client=False, embedding_generator=embedding_generator, ) @@ -383,6 +416,12 @@ def __init__( ) except ValidationError as exc: raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc + endpoint = str(azure_ai_search_settings.endpoint) + credential = search_credential or _resolve_credential( + azure_ai_search_settings, + azure_credential=kwargs.get("azure_credentials"), + token_credential=kwargs.get("token_credentials"), + ) search_index_client = _get_search_index_client( azure_ai_search_settings=azure_ai_search_settings, azure_credential=kwargs.get("azure_credentials"), @@ -393,10 +432,13 @@ def __init__( definition=definition, collection_name=azure_ai_search_settings.index_name, search_client=_get_search_client( - search_index_client=search_index_client, - collection_name=azure_ai_search_settings.index_name, # type: ignore + endpoint=endpoint, + collection_name=azure_ai_search_settings.index_name, + credential=credential, ), search_index_client=search_index_client, + search_endpoint=endpoint, + search_credential=credential, embedding_generator=embedding_generator, ) @@ -711,13 +753,15 @@ class AzureAISearchStore(VectorStore): """Azure AI Search store implementation.""" search_index_client: SearchIndexClient + search_endpoint: str | None = None + search_credential: Any = None def __init__( self, search_endpoint: str | None = None, api_key: str | None = None, azure_credentials: "AzureKeyCredential | None" = None, - token_credentials: "AsyncTokenCredential | TokenCredential | None" = None, + token_credentials: "AsyncTokenCredential | None" = None, search_index_client: SearchIndexClient | None = None, embedding_generator: "EmbeddingGeneratorBase | None" = None, env_file_path: str | None = None, @@ -725,6 +769,8 @@ def __init__( ) -> None: """Initializes a new instance of the AzureAISearchStore class.""" managed_client: bool = False + endpoint: str | None = None + credential: AzureKeyCredential | AsyncTokenCredential | None = None if not search_index_client: try: azure_ai_search_settings = AzureAISearchSettings( @@ -735,15 +781,26 @@ def __init__( ) except ValidationError as exc: raise VectorStoreInitializationException("Failed to create Azure AI Search settings.") from exc + endpoint = str(azure_ai_search_settings.endpoint) + credential = _resolve_credential( + azure_ai_search_settings, + azure_credential=azure_credentials, + token_credential=token_credentials, + ) search_index_client = _get_search_index_client( azure_ai_search_settings=azure_ai_search_settings, azure_credential=azure_credentials, token_credential=token_credentials, ) managed_client = True + else: + endpoint = search_endpoint + credential = azure_credentials or token_credentials or (AzureKeyCredential(api_key) if api_key else None) super().__init__( search_index_client=search_index_client, + search_endpoint=endpoint, + search_credential=credential, managed_client=managed_client, embedding_generator=embedding_generator, ) @@ -777,6 +834,8 @@ def get_collection( search_index_client=self.search_index_client, search_client=search_client, embedding_generator=embedding_generator or self.embedding_generator, + search_credential=self.search_credential, + search_endpoint=self.search_endpoint, **kwargs, ) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 5f31886b28fa..6d7f8d2e182d 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -6,7 +6,7 @@ import re import sys from abc import abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, _AsyncGeneratorContextManager from datetime import timedelta from functools import partial @@ -59,6 +59,8 @@ # region: Helpers +SamplingConsentCallback = Callable[[str, types.CreateMessageRequestParams], Awaitable[bool]] + LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { "debug": logging.DEBUG, "info": logging.INFO, @@ -243,8 +245,23 @@ def __init__( session: ClientSession | None = None, kernel: Kernel | None = None, request_timeout: int | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, ) -> None: - """Initialize the MCP Plugin Base.""" + """Initialize the MCP Plugin Base. + + Args: + name: The name of the plugin. + description: The description of the plugin. + load_tools: Whether to load tools from the MCP server. + load_prompts: Whether to load prompts from the MCP server. + session: The session to use for the MCP connection. + kernel: The kernel instance with one or more Chat Completion clients. + request_timeout: The default timeout used for all requests. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. + """ self.name = name self.description = description self.load_tools_flag = load_tools @@ -253,6 +270,9 @@ def __init__( self.session = session self.kernel = kernel or None self.request_timeout = request_timeout + self.sampling_consent_callback = sampling_consent_callback + self._sampling_auto_approved_warning_logged = False + self._mcp_reserved_attribute_names: set[str] | None = None self._current_task: asyncio.Task | None = None self._stop_event: asyncio.Event | None = None @@ -361,9 +381,24 @@ async def sampling_callback( This function is called when the MCP server needs to get a message completed. - This is a simple version of this function, it can be overridden to allow more complex sampling. - It get's added to the session at initialization time, so overriding it is the best way to do this. + If a sampling consent callback is configured, it is called before forwarding the request to the configured + chat completion service. Returning False denies the request. If no callback is configured, requests are + auto-approved and a warning is logged. """ + if self.sampling_consent_callback is None: + if not self._sampling_auto_approved_warning_logged: + logger.warning( + "MCP sampling request for plugin '%s' was auto-approved because no sampling consent callback " + "was configured.", + self.name, + ) + self._sampling_auto_approved_warning_logged = True + elif not await self._is_sampling_approved(params): + return types.ErrorData( + code=types.INTERNAL_ERROR, + message="Sampling denied by policy.", + ) + if not self.kernel or not self.kernel.services: return types.ErrorData( code=types.INTERNAL_ERROR, @@ -431,6 +466,15 @@ async def sampling_callback( model=service.ai_model_id, ) + async def _is_sampling_approved(self, params: types.CreateMessageRequestParams) -> bool: + if self.sampling_consent_callback is None: + return True + try: + return await self.sampling_consent_callback(self.name, params) + except Exception: + logger.exception("MCP sampling consent callback failed for plugin '%s'.", self.name) + return False + async def logging_callback(self, params: types.LoggingMessageNotificationParams) -> None: """Callback function for logging. @@ -464,6 +508,19 @@ async def message_handler( case "notifications/prompts/list_changed": await self.load_prompts() + def _has_mcp_function_name_conflict(self, item_type: str, remote_name: str, local_name: str) -> bool: + if self._mcp_reserved_attribute_names is None: + self._mcp_reserved_attribute_names = set(dir(self)) + if local_name not in self._mcp_reserved_attribute_names: + return False + logger.warning( + "Skipping MCP %s '%s' because normalized name '%s' conflicts with an existing plugin attribute.", + item_type, + remote_name, + local_name, + ) + return True + async def load_prompts(self): """Load prompts from the MCP server.""" try: @@ -472,6 +529,8 @@ async def load_prompts(self): prompt_list = None for prompt in prompt_list.prompts if prompt_list else []: local_name = _normalize_mcp_name(prompt.name) + if self._has_mcp_function_name_conflict("prompt", prompt.name, local_name): + continue func = kernel_function(name=local_name, description=prompt.description)( partial(self.get_prompt, prompt.name) ) @@ -484,9 +543,11 @@ async def load_tools(self): tool_list = await self.session.list_tools() except Exception: tool_list = None - # Create methods with the kernel_function decorator for each tool + # Create methods with the kernel_function decorator for each tool for tool in tool_list.tools if tool_list else []: local_name = _normalize_mcp_name(tool.name) + if self._has_mcp_function_name_conflict("tool", tool.name, local_name): + continue func = kernel_function(name=local_name, description=tool.description)(partial(self.call_tool, tool.name)) func.__kernel_function_parameters__ = _get_parameter_dicts_from_mcp_tool(tool) setattr(self, local_name, func) @@ -558,6 +619,7 @@ def __init__( env: dict[str, str] | None = None, encoding: str | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP stdio plugin. @@ -579,6 +641,10 @@ def __init__( env: The environment variables to set for the command. encoding: The encoding to use for the command output. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the stdio client. """ @@ -590,6 +656,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.command = command self.args = args or [] @@ -628,6 +695,7 @@ def __init__( timeout: float | None = None, sse_read_timeout: float | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP sse plugin. @@ -650,6 +718,10 @@ def __init__( timeout: The timeout for the request. sse_read_timeout: The timeout for reading from the SSE stream. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the sse client. """ @@ -661,6 +733,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.url = url self.headers = headers or {} @@ -702,6 +775,7 @@ def __init__( sse_read_timeout: float | None = None, terminate_on_close: bool | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP streamable http plugin. @@ -725,6 +799,10 @@ def __init__( sse_read_timeout: The timeout for reading from the SSE stream. terminate_on_close: Close the transport when the MCP client is terminated. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the sse client. """ super().__init__( @@ -735,6 +813,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.url = url self.headers = headers or {} @@ -775,6 +854,7 @@ def __init__( session: ClientSession | None = None, description: str | None = None, kernel: Kernel | None = None, + sampling_consent_callback: SamplingConsentCallback | None = None, **kwargs: Any, ) -> None: """Initialize the MCP websocket plugin. @@ -794,6 +874,10 @@ def __init__( session: The session to use for the MCP connection. description: The description of the plugin. kernel: The kernel instance with one or more Chat Completion clients. + sampling_consent_callback: Optional callback for approving MCP sampling requests. + Receives the plugin name and MCP sampling request params. Return + False to deny the request. When omitted, sampling requests are + auto-approved and a warning is logged. kwargs: Any extra arguments to pass to the websocket client. """ @@ -805,6 +889,7 @@ def __init__( load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, + sampling_consent_callback=sampling_consent_callback, ) self.url = url self._client_kwargs = kwargs diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py index 7963c55883e8..570a4352892c 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py @@ -2,7 +2,7 @@ import re from typing import Any, Final -from urllib.parse import ParseResult, ParseResultBytes, urlencode, urljoin, urlparse, urlunparse +from urllib.parse import ParseResult, ParseResultBytes, quote, urlencode, urljoin, urlparse, urlunparse from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( RestApiExpectedResponse, @@ -288,7 +288,7 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str: f"required parameter of the operation - `{self.id}`." ) continue - path_template = path_template.replace(f"{{{parameter.name}}}", str(argument)) + path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe="")) return path_template def build_query_string(self, arguments: dict[str, Any]) -> str: diff --git a/python/semantic_kernel/core_plugins/http_plugin.py b/python/semantic_kernel/core_plugins/http_plugin.py index 40269a5c49c8..8a554410fb44 100644 --- a/python/semantic_kernel/core_plugins/http_plugin.py +++ b/python/semantic_kernel/core_plugins/http_plugin.py @@ -15,23 +15,48 @@ class HttpPlugin(KernelBaseModel): """A plugin that provides HTTP functionality. Usage: - kernel.add_plugin(HttpPlugin(), "http") - - # With allowed domains for security: + # With allowed domains (recommended): kernel.add_plugin(HttpPlugin(allowed_domains=["example.com", "api.example.com"]), "http") + # Explicitly allow all domains (opt-in, less secure): + kernel.add_plugin(HttpPlugin(allow_all_domains=True), "http") + Examples: {{http.getAsync $url}} {{http.postAsync $url}} {{http.putAsync $url}} {{http.deleteAsync $url}} + + Security: + - By default, all requests are blocked unless ``allowed_domains`` is provided + or ``allow_all_domains`` is set to True. + - When ``allowed_domains`` is set and ``allow_all_domains`` is False, HTTP + redirects are disabled to prevent redirect-based domain bypass (SSRF). + - When ``allow_all_domains`` is True, redirects are allowed regardless of + whether ``allowed_domains`` is also set. + - Only ``http`` and ``https`` URL schemes are permitted. """ allowed_domains: set[str] | None = None - """List of allowed domains to send requests to. If None, all domains are allowed.""" + """Set of allowed domains to send requests to.""" + + allow_all_domains: bool = False + """When True, requests to any domain are allowed. Must be explicitly set.""" + + _ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"}) + + @property + def _allow_redirects(self) -> bool: + """Whether HTTP redirects should be followed. + + Redirects are only allowed when ``allow_all_domains`` is True. + When domain restrictions are configured, redirects are disabled + to prevent redirect-based SSRF bypass. + """ + return self.allow_all_domains def _is_uri_allowed(self, url: str) -> bool: - """Check if the URL's host is in the allowed domains list. + """Check if the URL's host and scheme are permitted. Args: url: The URL to check. @@ -39,25 +64,36 @@ def _is_uri_allowed(self, url: str) -> bool: Returns: True if the URL is allowed, False otherwise. """ - if self.allowed_domains is None: - return True - parsed = urlparse(url) + + # Validate scheme + if parsed.scheme.lower() not in self._ALLOWED_SCHEMES: + return False + host = parsed.hostname - if host is None: + if not host: return False - # Case-insensitive comparison - return host.lower() in {domain.lower() for domain in self.allowed_domains} + # If allow_all_domains is set, skip domain check + if self.allow_all_domains: + return True + + # If allowed_domains is set, check against it + if self.allowed_domains is not None: + return host.lower() in {domain.lower() for domain in self.allowed_domains} + + # Default: deny all + return False def _validate_url(self, url: str) -> None: - """Validate the URL, checking if it's not empty and is in the allowed domains. + """Validate the URL, checking scheme, emptiness, and allowed domains. Args: url: The URL to validate. Raises: - FunctionExecutionException: If the URL is empty or not in the allowed domains. + FunctionExecutionException: If the URL is empty, uses a disallowed scheme, + or targets a domain that is not allowed. """ if not url: raise FunctionExecutionException("url cannot be `None` or empty") @@ -77,7 +113,10 @@ async def get(self, url: Annotated[str, "The URL to send the request to."]) -> s """ self._validate_url(url) - async with aiohttp.ClientSession() as session, session.get(url, raise_for_status=True) as response: + async with ( + aiohttp.ClientSession() as session, + session.get(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response, + ): return await response.text() @kernel_function(description="Makes a POST request to a uri", name="postAsync") @@ -100,7 +139,9 @@ async def post( data = json.dumps(body) if body is not None else None async with ( aiohttp.ClientSession() as session, - session.post(url, headers=headers, data=data, raise_for_status=True) as response, + session.post( + url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects + ) as response, ): return await response.text() @@ -125,7 +166,9 @@ async def put( data = json.dumps(body) if body is not None else None async with ( aiohttp.ClientSession() as session, - session.put(url, headers=headers, data=data, raise_for_status=True) as response, + session.put( + url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects + ) as response, ): return await response.text() @@ -141,5 +184,8 @@ async def delete(self, url: Annotated[str, "The URI to send the request to."]) - """ self._validate_url(url) - async with aiohttp.ClientSession() as session, session.delete(url, raise_for_status=True) as response: + async with ( + aiohttp.ClientSession() as session, + session.delete(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response, + ): return await response.text() diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 55ca71313574..dc8ea38330d3 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import logging import re from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch @@ -89,6 +90,120 @@ async def test_mcp_plugin_session_initialized(plugin_class, plugin_args): assert not mock_session.initialize.called +async def test_mcp_sampling_denied_by_consent_callback(): + sampling_consent_callback = AsyncMock(return_value=False) + plugin = MCPSsePlugin( + name="TestMCPPlugin", + url="http://localhost:8080/sse", + sampling_consent_callback=sampling_consent_callback, + ) + params = types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], + systemPrompt="server instructions", + maxTokens=100, + ) + + result = await plugin.sampling_callback(MagicMock(), params) + + sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params) + assert isinstance(result, types.ErrorData) + assert result.message == "Sampling denied by policy." + + +async def test_mcp_sampling_consent_callback_error_denies_request(caplog): + sampling_consent_callback = AsyncMock(side_effect=RuntimeError("policy failure")) + plugin = MCPSsePlugin( + name="TestMCPPlugin", + url="http://localhost:8080/sse", + sampling_consent_callback=sampling_consent_callback, + ) + params = types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], + systemPrompt="server instructions", + maxTokens=100, + ) + + with caplog.at_level(logging.ERROR, logger="semantic_kernel.connectors.mcp"): + result = await plugin.sampling_callback(MagicMock(), params) + + sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params) + assert isinstance(result, types.ErrorData) + assert result.message == "Sampling denied by policy." + assert "MCP sampling consent callback failed" in caplog.text + + +async def test_mcp_sampling_without_consent_callback_logs_auto_approve_warning(caplog): + plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse") + params = types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], + systemPrompt="server instructions", + maxTokens=100, + ) + + with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): + result = await plugin.sampling_callback(MagicMock(), params) + + assert isinstance(result, types.ErrorData) + assert "auto-approved because no sampling consent callback was configured" in caplog.text + + +async def test_mcp_tool_and_prompt_names_do_not_shadow_plugin_attributes(): + kernel = MagicMock() + plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse", kernel=kernel) + session = AsyncMock(spec=ClientSession) + session.list_tools.return_value = ListToolsResult( + tools=[ + Tool(name="kernel", description="reserved", inputSchema={}), + Tool(name="safe_tool", description="safe", inputSchema={}), + ] + ) + session.list_prompts.return_value = types.ListPromptsResult( + prompts=[ + types.Prompt(name="session", description="reserved", arguments=[]), + types.Prompt(name="safe_prompt", description="safe", arguments=[]), + ] + ) + plugin.session = session + + await plugin.load_tools() + + assert plugin.kernel is kernel + assert hasattr(plugin, "safe_tool") + + await plugin.load_prompts() + + assert plugin.session is session + assert hasattr(plugin, "safe_prompt") + + +async def test_mcp_tool_and_prompt_names_can_reload_existing_mcp_functions(): + plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse") + session = AsyncMock(spec=ClientSession) + session.list_tools.side_effect = [ + ListToolsResult(tools=[Tool(name="safe_tool", description="first tool", inputSchema={})]), + ListToolsResult(tools=[Tool(name="safe_tool", description="second tool", inputSchema={})]), + ] + session.list_prompts.side_effect = [ + types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="first prompt", arguments=[])]), + types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="second prompt", arguments=[])]), + ] + plugin.session = session + + await plugin.load_tools() + first_tool = plugin.safe_tool + await plugin.load_tools() + + assert plugin.safe_tool is not first_tool + assert plugin.safe_tool.__kernel_function_description__ == "second tool" + + await plugin.load_prompts() + first_prompt = plugin.safe_prompt + await plugin.load_prompts() + + assert plugin.safe_prompt is not first_prompt + assert plugin.safe_prompt.__kernel_function_description__ == "second prompt" + + async def test_mcp_plugin_failed_get_session(): with ( patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, diff --git a/python/tests/unit/connectors/memory/test_azure_ai_search.py b/python/tests/unit/connectors/memory/test_azure_ai_search.py index 82615ca6c426..d5a8ba2111e0 100644 --- a/python/tests/unit/connectors/memory/test_azure_ai_search.py +++ b/python/tests/unit/connectors/memory/test_azure_ai_search.py @@ -16,6 +16,7 @@ AzureAISearchStore, _definition_to_azure_ai_search_index, _get_search_index_client, + _resolve_credential, ) from semantic_kernel.exceptions import ( ServiceInitializationError, @@ -171,8 +172,6 @@ def test_init_with_search_index_client(azure_ai_search_unit_test_env, definition @mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_INDEX_NAME"]], indirect=True) def test_init_with_search_index_client_fail(azure_ai_search_unit_test_env, definition): search_index_client = MagicMock(spec=SearchIndexClient) - search_index_client._endpoint = "test-endpoint" - search_index_client._credential = "test-credential" with raises(VectorStoreInitializationException): AzureAISearchCollection( record_type=dict, @@ -234,6 +233,7 @@ async def test_ensure_collection_deleted(collection, mock_ensure_collection_dele await collection.ensure_collection_deleted() +@mark.parametrize("distance_function", [("cosine_distance")]) async def test_create_index_from_index(collection, mock_ensure_collection_exists): from azure.search.documents.indexes.models import SearchIndex @@ -241,6 +241,7 @@ async def test_create_index_from_index(collection, mock_ensure_collection_exists await collection.ensure_collection_exists(index=index) +@mark.parametrize("distance_function", [("cosine_distance")]) async def test_create_index_from_definition(collection, mock_ensure_collection_exists): from azure.search.documents.indexes.models import SearchIndex @@ -301,32 +302,74 @@ def test_get_collection(vector_store, definition): assert collection.collection_name == "test" assert collection.search_index_client == vector_store.search_index_client assert collection.search_client is not None - assert collection.search_client._endpoint == vector_store.search_index_client._endpoint + assert collection.search_endpoint == vector_store.search_endpoint + assert collection.search_credential == vector_store.search_credential + + +def test_get_collection_with_provided_search_index_client(azure_ai_search_unit_test_env, definition): + """Test that get_collection works when AzureAISearchStore is created with a pre-built search_index_client. + + When search_index_client is provided directly, search_endpoint and search_credential + are not resolved at store creation time. get_collection() should still succeed + by falling back to environment variables for endpoint/credential resolution. + """ + search_index_client = MagicMock(spec=SearchIndexClient) + store = AzureAISearchStore(search_index_client=search_index_client) + assert store.search_endpoint is None + assert store.search_credential is None + + collection = store.get_collection( + collection_name="test", + record_type=dict, + definition=definition, + ) + assert collection is not None + assert collection.collection_name == "test" + assert collection.search_index_client == search_index_client + assert collection.search_client is not None @mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True) def test_get_search_index_client(azure_ai_search_unit_test_env): - from azure.core.credentials import AzureKeyCredential, TokenCredential + from azure.core.credentials import AzureKeyCredential + from azure.core.credentials_async import AsyncTokenCredential settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env") azure_credential = MagicMock(spec=AzureKeyCredential) client = _get_search_index_client(settings, azure_credential=azure_credential) assert client is not None - assert client._credential == azure_credential - token_credential = MagicMock(spec=TokenCredential) + token_credential = MagicMock(spec=AsyncTokenCredential) client2 = _get_search_index_client( settings, token_credential=token_credential, ) assert client2 is not None - assert client2._credential == token_credential with raises(ServiceInitializationError): _get_search_index_client(settings) +@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True) +def test_resolve_credential(azure_ai_search_unit_test_env): + from azure.core.credentials import AzureKeyCredential + from azure.core.credentials_async import AsyncTokenCredential + + settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env") + + azure_credential = MagicMock(spec=AzureKeyCredential) + resolved = _resolve_credential(settings, azure_credential=azure_credential) + assert resolved == azure_credential + + token_credential = MagicMock(spec=AsyncTokenCredential) + resolved = _resolve_credential(settings, token_credential=token_credential) + assert resolved == token_credential + + with raises(ServiceInitializationError): + _resolve_credential(settings) + + @mark.parametrize("include_vectors", [True, False]) async def test_search_vectorized_search(collection, mock_search, include_vectors): results = await collection.search(vector=[0.1, 0.2, 0.3], include_vectors=include_vectors) diff --git a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py index 1d25486b5a86..2dd2488fc506 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py +++ b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py @@ -412,6 +412,56 @@ def test_build_path_with_optional_and_required_parameters(): assert operation.build_path(operation.path, arguments) == expected_path +def test_build_path_encodes_special_characters(): + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + # Characters like /, ?, #, and spaces must be percent-encoded to prevent traversal + arguments = {"id": "foo/bar?q=1#frag data"} + result = operation.build_path(operation.path, arguments) + encoded_part = result.split("/resource/")[1] + assert "/" not in encoded_part + assert "?" not in encoded_part + assert "#" not in encoded_part + assert " " not in encoded_part + # Python's quote(safe="") encodes all except unreserved chars (letters, digits, _, ., -, ~) + assert result == "/resource/foo%2Fbar%3Fq%3D1%23frag%20data" + + +def test_build_path_prevents_path_traversal(): + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + arguments = {"id": "../../admin"} + result = operation.build_path(operation.path, arguments) + # The slashes must be encoded so ../../admin becomes a single path segment, not a traversal + assert result == "/resource/..%2F..%2Fadmin" + + +def test_build_path_double_encodes_pre_encoded_values(): + """Arguments must be raw/unencoded values. Pre-encoded values are double-encoded by design.""" + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + arguments = {"id": "hello%2Fworld"} + result = operation.build_path(operation.path, arguments) + # %2F in input becomes %252F — the % is encoded, preventing decode-based bypass + assert result == "/resource/hello%252Fworld" + + +def test_build_path_encodes_unicode_characters(): + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] + operation = RestApiOperation( + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters + ) + arguments = {"id": "café résumé"} + result = operation.build_path(operation.path, arguments) + assert result == "/resource/caf%C3%A9%20r%C3%A9sum%C3%A9" + + def test_build_query_string_with_required_parameter(): parameters = [ RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True) diff --git a/python/tests/unit/core_plugins/test_http_plugin.py b/python/tests/unit/core_plugins/test_http_plugin.py index 4472211c76c7..216967ffaee9 100644 --- a/python/tests/unit/core_plugins/test_http_plugin.py +++ b/python/tests/unit/core_plugins/test_http_plugin.py @@ -11,7 +11,7 @@ async def test_it_can_be_instantiated(): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) assert plugin is not None @@ -23,7 +23,7 @@ async def test_it_can_be_instantiated_with_allowed_domains(): async def test_it_can_be_imported(): kernel = Kernel() - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) kernel.add_plugin(plugin, "http") assert kernel.get_plugin(plugin_name="http") is not None assert kernel.get_plugin(plugin_name="http").name == "http" @@ -36,20 +36,20 @@ async def test_get(mock_get): mock_get.return_value.__aenter__.return_value.text.return_value = "Hello" mock_get.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) response = await plugin.get("https://example.org/get") assert response == "Hello" @pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) async def test_fail_no_url(method): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) with pytest.raises(FunctionExecutionException): await getattr(plugin, method)(url="") async def test_get_none_url(): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) with pytest.raises(FunctionExecutionException): await plugin.get(None) @@ -59,7 +59,7 @@ async def test_post(mock_post): mock_post.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_post.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/post", body="{message: 'Hello, world!'}") response = await plugin.post(**arguments) assert response == "Hello World !" @@ -70,7 +70,7 @@ async def test_post_nobody(mock_post): mock_post.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_post.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/post") response = await plugin.post(**arguments) assert response == "Hello World !" @@ -81,7 +81,7 @@ async def test_put(mock_put): mock_put.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_put.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/put", body="{message: 'Hello, world!'}") response = await plugin.put(**arguments) assert response == "Hello World !" @@ -92,7 +92,7 @@ async def test_put_nobody(mock_put): mock_put.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_put.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/put") response = await plugin.put(**arguments) assert response == "Hello World !" @@ -103,7 +103,7 @@ async def test_delete(mock_delete): mock_delete.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_delete.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/delete") response = await plugin.delete(**arguments) assert response == "Hello World !" @@ -178,9 +178,9 @@ async def test_allowed_domains_case_insensitive(): assert plugin._is_uri_allowed("https://Example.Com/path") is True -async def test_allowed_domains_none_allows_all(): - """Test that when allowed_domains is None, all domains are allowed.""" - plugin = HttpPlugin() # allowed_domains defaults to None +async def test_allow_all_domains_allows_all(): + """Test that when allow_all_domains is True, all domains are allowed.""" + plugin = HttpPlugin(allow_all_domains=True) assert plugin._is_uri_allowed("https://any-domain.com/path") is True assert plugin._is_uri_allowed("https://another-domain.org/path") is True @@ -214,3 +214,178 @@ async def test_allowed_domains_exact_subdomain_match(): assert plugin._is_uri_allowed("https://sub.example.com/path") is True assert plugin._is_uri_allowed("https://example.com/path") is False assert plugin._is_uri_allowed("https://other.example.com/path") is False + + +# Security regression tests + + +async def test_default_constructor_denies_all(): + """Test that default HttpPlugin() denies all requests (issue 115285).""" + plugin = HttpPlugin() + assert plugin._is_uri_allowed("https://example.com/path") is False + assert plugin._is_uri_allowed("https://any-domain.com/path") is False + + +@pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) +async def test_default_constructor_blocks_requests(method): + """Test that default HttpPlugin() blocks all HTTP methods (issue 115285).""" + plugin = HttpPlugin() + with pytest.raises(FunctionExecutionException, match="Sending requests to the provided location is not allowed"): + if method in ["post", "put"]: + await getattr(plugin, method)(url="https://example.com/path", body={"key": "value"}) + else: + await getattr(plugin, method)(url="https://example.com/path") + + +@patch("aiohttp.ClientSession.get") +async def test_allow_all_domains_flag(mock_get): + """Test that allow_all_domains=True permits requests to any domain.""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + response = await plugin.get("https://any-domain.com/path") + assert response == "OK" + + +@patch("aiohttp.ClientSession.get") +async def test_redirects_disabled_with_allowed_domains(mock_get): + """Test that redirects are disabled when allowed_domains is set (issue 115048).""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.get("https://example.com/path") + + _, kwargs = mock_get.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.post") +async def test_redirects_disabled_for_post_with_allowed_domains(mock_post): + """Test that redirects are disabled for POST when allowed_domains is set.""" + mock_post.return_value.__aenter__.return_value.text.return_value = "OK" + mock_post.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.post("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_post.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.put") +async def test_redirects_disabled_for_put_with_allowed_domains(mock_put): + """Test that redirects are disabled for PUT when allowed_domains is set.""" + mock_put.return_value.__aenter__.return_value.text.return_value = "OK" + mock_put.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.put("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_put.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.delete") +async def test_redirects_disabled_for_delete_with_allowed_domains(mock_delete): + """Test that redirects are disabled for DELETE when allowed_domains is set.""" + mock_delete.return_value.__aenter__.return_value.text.return_value = "OK" + mock_delete.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.delete("https://example.com/path") + + _, kwargs = mock_delete.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.get") +async def test_redirects_allowed_with_allow_all_domains(mock_get): + """Test that redirects are still allowed when allow_all_domains is True.""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.get("https://example.com/path") + + _, kwargs = mock_get.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.post") +async def test_redirects_allowed_for_post_with_allow_all_domains(mock_post): + """Test that redirects are allowed for POST when allow_all_domains is True.""" + mock_post.return_value.__aenter__.return_value.text.return_value = "OK" + mock_post.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.post("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_post.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.put") +async def test_redirects_allowed_for_put_with_allow_all_domains(mock_put): + """Test that redirects are allowed for PUT when allow_all_domains is True.""" + mock_put.return_value.__aenter__.return_value.text.return_value = "OK" + mock_put.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.put("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_put.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.delete") +async def test_redirects_allowed_for_delete_with_allow_all_domains(mock_delete): + """Test that redirects are allowed for DELETE when allow_all_domains is True.""" + mock_delete.return_value.__aenter__.return_value.text.return_value = "OK" + mock_delete.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.delete("https://example.com/path") + + _, kwargs = mock_delete.call_args + assert kwargs["allow_redirects"] is True + + +@pytest.mark.parametrize("scheme", ["file", "ftp", "gopher", "data"]) +async def test_disallowed_schemes_blocked(scheme): + """Test that non-HTTP schemes are blocked.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed(f"{scheme}://example.com/path") is False + + +@pytest.mark.parametrize("scheme", ["file", "ftp", "gopher"]) +@pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) +async def test_disallowed_schemes_blocked_all_methods(scheme, method): + """Test that non-HTTP schemes are blocked for all HTTP methods.""" + plugin = HttpPlugin(allow_all_domains=True) + with pytest.raises(FunctionExecutionException, match="Sending requests to the provided location is not allowed"): + if method in ["post", "put"]: + await getattr(plugin, method)(url=f"{scheme}://example.com/path", body={"key": "value"}) + else: + await getattr(plugin, method)(url=f"{scheme}://example.com/path") + + +async def test_http_scheme_allowed(): + """Test that both http and https schemes are allowed.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed("http://example.com/path") is True + assert plugin._is_uri_allowed("https://example.com/path") is True + + +async def test_empty_hostname_rejected(): + """Test that URLs with empty hostnames are rejected.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed("http://") is False + assert plugin._is_uri_allowed("https://") is False + + +async def test_allow_all_domains_with_allowed_domains_allows_redirects(): + """Test that redirects are allowed when both allow_all_domains and allowed_domains are set.""" + plugin = HttpPlugin(allowed_domains={"example.com"}, allow_all_domains=True) + assert plugin._is_uri_allowed("https://any-domain.com/path") is True diff --git a/python/uv.lock b/python/uv.lock index 0b65fbe34c83..10b500f8c457 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -319,14 +319,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.9" +version = "1.6.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" }, ] [[package]] @@ -953,7 +953,8 @@ dependencies = [ { name = "kubernetes", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mmh3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "onnxruntime", version = "1.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1019,7 +1020,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "humanfriendly", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -1611,7 +1612,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.114.0" +version = "1.133.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1625,12 +1626,11 @@ dependencies = [ { name = "proto-plus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "shapely", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/0e/8097231fba8e688993b0b6d371ee298ac3955cdca77fc0731799de1253ca/google_cloud_aiplatform-1.114.0.tar.gz", hash = "sha256:44e5e3da9b23c9316a4d9e7cd6a04258ebf84f3aadf95a725d5d1de179e2c2ce", size = 9650673, upload-time = "2025-09-16T19:47:55.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/0a/526e70e5cd8e0e96207e201721457dac020d9b8d1bd2ce7326e550b8150d/google_cloud_aiplatform-1.114.0-py2.py3-none-any.whl", hash = "sha256:87386d9364bd0bed4dd33873845afbbe251d1ed83ee25d676c3c0cea630af682", size = 8032171, upload-time = "2025-09-16T19:47:52.725Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" }, ] [[package]] @@ -2181,7 +2181,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -3458,7 +3458,7 @@ wheels = [ [[package]] name = "nbconvert" -version = "7.17.0" +version = "7.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3476,9 +3476,9 @@ dependencies = [ { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "traitlets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] @@ -3738,13 +3738,18 @@ wheels = [ name = "onnxruntime" version = "1.22.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] dependencies = [ - { name = "coloredlogs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flatbuffers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sympy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "coloredlogs", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "flatbuffers", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "protobuf", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "sympy", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/76/b9/664a1ffee62fa51529fac27b37409d5d28cadee8d97db806fcba68339b7e/onnxruntime-1.22.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:80e7f51da1f5201c1379b8d6ef6170505cd800e40da216290f5e06be01aadf95", size = 34319864, upload-time = "2025-07-10T19:15:15.371Z" }, @@ -3767,13 +3772,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/15/d75fd66aba116ce3732bb1050401394c5ec52074c4f7ee18db8838dd4667/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7e823624b015ea879d976cbef8bfaed2f7e2cc233d7506860a76dd37f8f381", size = 16477261, upload-time = "2025-07-10T19:16:03.226Z" }, ] +[[package]] +name = "onnxruntime" +version = "1.24.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '4' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and python_full_version < '4' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '4' and sys_platform == 'linux'", + "python_full_version >= '3.14' and python_full_version < '4' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '4' and sys_platform == 'win32'", + "python_full_version >= '3.14' and python_full_version < '4' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] +dependencies = [ + { name = "flatbuffers", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "protobuf", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "sympy", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c5/3af6b325f1492d691b23844d88ed26844c1164620860c5efe95c0e22782d/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978", size = 15130330, upload-time = "2026-03-05T16:34:53.831Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/f96b46c1866a293ed23ca2cf5e5a63d413ad3a951da60dd877e3c56cbbca/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d", size = 17213247, upload-time = "2026-03-05T17:17:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/36/13/27cf4d8df2578747584e8758aeb0b673b60274048510257f1f084b15e80e/onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39", size = 12595530, upload-time = "2026-03-05T17:18:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/19/8c/6d9f31e6bae72a8079be12ed8ba36c4126a571fad38ded0a1b96f60f6896/onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723", size = 12261715, upload-time = "2026-03-05T17:18:39.699Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/dfdc4e52600fde4c02d59bfe98c4b057931c1114b701e175aee311a9bc11/onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91", size = 17342578, upload-time = "2026-03-05T17:19:02.403Z" }, + { url = "https://files.pythonhosted.org/packages/1c/dc/1f5489f7b21817d4ad352bf7a92a252bd5b438bcbaa7ad20ea50814edc79/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452", size = 15150105, upload-time = "2026-03-05T16:34:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/fd253da53594ab8efbefdc85b3638620ab1a6aab6eb7028a513c853559ce/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d", size = 17237101, upload-time = "2026-03-05T17:18:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/71/5f/eaabc5699eeed6a9188c5c055ac1948ae50138697a0428d562ac970d7db5/onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a", size = 12597638, upload-time = "2026-03-05T17:18:52.141Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5c/d8066c320b90610dbeb489a483b132c3b3879b2f93f949fb5d30cfa9b119/onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8", size = 12270943, upload-time = "2026-03-05T17:18:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/51/8d/487ece554119e2991242d4de55de7019ac6e47ee8dfafa69fcf41d37f8ed/onnxruntime-1.24.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7", size = 17342706, upload-time = "2026-03-05T16:35:10.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/25/8b444f463c1ac6106b889f6235c84f01eec001eaf689c3eff8c69cf48fae/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7", size = 15149956, upload-time = "2026-03-05T16:34:59.264Z" }, + { url = "https://files.pythonhosted.org/packages/34/fc/c9182a3e1ab46940dd4f30e61071f59eee8804c1f641f37ce6e173633fb6/onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9", size = 17237370, upload-time = "2026-03-05T17:18:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/3b549e1f4538514118bff98a1bcd6481dd9a17067f8c9af77151621c9a5c/onnxruntime-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b", size = 12597939, upload-time = "2026-03-05T17:18:54.772Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/9696a5c4631a0caa75cc8bc4efd30938fd483694aa614898d087c3ee6d29/onnxruntime-1.24.3-cp313-cp313-win_arm64.whl", hash = "sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b", size = 12270705, upload-time = "2026-03-05T17:18:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/b7/65/a26c5e59e3b210852ee04248cf8843c81fe7d40d94cf95343b66efe7eec9/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34", size = 15161796, upload-time = "2026-03-05T16:35:02.871Z" }, + { url = "https://files.pythonhosted.org/packages/f3/25/2035b4aa2ccb5be6acf139397731ec507c5f09e199ab39d3262b22ffa1ac/onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d", size = 17240936, upload-time = "2026-03-05T17:18:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a4/b3240ea84b92a3efb83d49cc16c04a17ade1ab47a6a95c4866d15bf0ac35/onnxruntime-1.24.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a6b4bce87d96f78f0a9bf5cefab3303ae95d558c5bfea53d0bf7f9ea207880a8", size = 17344149, upload-time = "2026-03-05T16:35:13.382Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4a/4b56757e51a56265e8c56764d9c36d7b435045e05e3b8a38bedfc5aedba3/onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d48f36c87b25ab3b2b4c88826c96cf1399a5631e3c2c03cc27d6a1e5d6b18eb4", size = 15151571, upload-time = "2026-03-05T16:35:05.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/c6fb84980cec8f682a523fcac7c2bdd6b311e7f342c61ce48d3a9cb87fc6/onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e104d33a409bf6e3f30f0e8198ec2aaf8d445b8395490a80f6e6ad56da98e400", size = 17238951, upload-time = "2026-03-05T17:18:12.394Z" }, + { url = "https://files.pythonhosted.org/packages/57/14/447e1400165aca8caf35dabd46540eb943c92f3065927bb4d9bcbc91e221/onnxruntime-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:e785d73fbd17421c2513b0bb09eb25d88fa22c8c10c3f5d6060589efa5537c5b", size = 12903820, upload-time = "2026-03-05T17:18:57.123Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/6b2fa5702e4bbba7339ca5787a9d056fc564a16079f8833cc6ba4798da1c/onnxruntime-1.24.3-cp314-cp314-win_arm64.whl", hash = "sha256:951e897a275f897a05ffbcaa615d98777882decaeb80c9216c68cdc62f849f53", size = 12594089, upload-time = "2026-03-05T17:18:47.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/dc/cd06cba3ddad92ceb17b914a8e8d49836c79e38936e26bde6e368b62c1fe/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d4e70ce578aa214c74c7a7a9226bc8e229814db4a5b2d097333b81279ecde36", size = 15162789, upload-time = "2026-03-05T16:35:08.282Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d6/413e98ab666c6fb9e8be7d1c6eb3bd403b0bea1b8d42db066dab98c7df07/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa", size = 17240738, upload-time = "2026-03-05T17:18:15.203Z" }, +] + [[package]] name = "onnxruntime-genai" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "onnxruntime", version = "1.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d5/fd/7e26537155bba5a6498d93b9d72de2f70e6af50df8200f9b7fe346074769/onnxruntime_genai-0.9.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:18ffc8d3921d82578f33c05f7d123d0ddcac90ce92ce94d23226764e483f6609", size = 3249017, upload-time = "2025-08-06T17:32:06.804Z" }, @@ -5371,11 +5432,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] [[package]] @@ -6351,7 +6412,8 @@ ollama = [ { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] onnx = [ - { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "onnxruntime", version = "1.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "onnxruntime-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] oracledb = [ @@ -6418,15 +6480,15 @@ requires-dist = [ { name = "azure-core-tracing-opentelemetry", marker = "extra == 'azure'", specifier = ">=1.0.0b11" }, { name = "azure-cosmos", marker = "extra == 'azure'", specifier = "~=4.7" }, { name = "azure-identity", specifier = ">=1.13" }, - { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4" }, + { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4,<12.0.0" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.36.4,<1.41.0" }, - { name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.1" }, + { name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.4" }, { name = "cloudevents", specifier = "~=1.0" }, { name = "defusedxml", specifier = "~=0.7" }, { name = "faiss-cpu", marker = "extra == 'faiss'", specifier = ">=1.10.0" }, - { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = "~=1.114.0" }, + { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = ">=1.114,<1.134" }, { name = "google-genai", marker = "extra == 'google'", specifier = "~=1.51.0" }, - { name = "ipykernel", marker = "extra == 'notebooks'", specifier = "~=6.29" }, + { name = "ipykernel", marker = "extra == 'notebooks'", specifier = ">=6.29,<8.0" }, { name = "jinja2", specifier = "~=3.1" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8" }, @@ -6439,7 +6501,8 @@ requires-dist = [ { name = "numpy", marker = "python_full_version < '3.12'", specifier = ">=1.25.0" }, { name = "numpy", marker = "python_full_version >= '3.12'", specifier = ">=1.26.0" }, { name = "ollama", marker = "extra == 'ollama'", specifier = "~=0.4" }, - { name = "onnxruntime", marker = "extra == 'onnx'", specifier = "==1.22.1" }, + { name = "onnxruntime", marker = "python_full_version == '3.10.*' and extra == 'onnx'", specifier = "==1.22.1" }, + { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3" }, { name = "onnxruntime-genai", marker = "extra == 'onnx'", specifier = "==0.9.0" }, { name = "openai", specifier = ">=2.0.0" }, { name = "openapi-core", specifier = ">=0.18,<0.20" }, @@ -6452,13 +6515,13 @@ requires-dist = [ { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'postgres'", specifier = "~=3.2" }, { name = "pyarrow", marker = "extra == 'usearch'", specifier = ">=12.0,<22.0" }, { name = "pybars4", specifier = "~=0.9" }, - { name = "pydantic", specifier = ">=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.12" }, + { name = "pydantic", specifier = ">=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.13" }, { name = "pydantic-settings", specifier = "~=2.0" }, { name = "pymilvus", marker = "extra == 'milvus'", specifier = ">=2.3,<2.7" }, - { name = "pymongo", marker = "extra == 'mongo'", specifier = ">=4.8.0,<4.15" }, + { name = "pymongo", marker = "extra == 'mongo'", specifier = ">=4.8.0,<4.16" }, { name = "pyodbc", marker = "extra == 'sql'", specifier = ">=5.2" }, { name = "qdrant-client", marker = "extra == 'qdrant'", specifier = "~=1.9" }, - { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = "~=6.0" }, + { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = ">=6,<8" }, { name = "redisvl", marker = "extra == 'redis'", specifier = "~=0.4" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "sentence-transformers", marker = "extra == 'hugging-face'", specifier = ">=2.2,<6.0" }, @@ -6475,7 +6538,7 @@ provides-extras = ["anthropic", "autogen", "aws", "azure", "chroma", "copilotstu [package.metadata.requires-dev] dev = [ - { name = "ipykernel", specifier = "~=6.29" }, + { name = "ipykernel", specifier = ">=6.29,<8.0" }, { name = "mypy", specifier = ">=1.10" }, { name = "nbconvert", specifier = "~=7.16" }, { name = "pre-commit", specifier = "~=3.7" }, @@ -6519,73 +6582,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, - { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, - { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, - { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, - { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, - { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, - { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, - { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, - { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, - { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, - { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, - { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, -] - [[package]] name = "shellingham" version = "1.5.4"