diff --git a/.devcontainer/dotnet/devcontainer.json b/.devcontainer/dotnet/devcontainer.json index a10557f9819..59b56a44383 100644 --- a/.devcontainer/dotnet/devcontainer.json +++ b/.devcontainer/dotnet/devcontainer.json @@ -1,10 +1,11 @@ { "name": "C# (.NET)", - "image": "mcr.microsoft.com/devcontainers/dotnet:9.0", + "image": "mcr.microsoft.com/devcontainers/dotnet:10.0", "features": { "ghcr.io/devcontainers/features/dotnet:2.4.0": {}, "ghcr.io/devcontainers/features/powershell:1.5.1": {}, - "ghcr.io/devcontainers/features/azure-cli:1.2.8": {} + "ghcr.io/devcontainers/features/azure-cli:1.2.8": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2.12.4": {} }, "workspaceFolder": "/workspaces/agent-framework/dotnet/", "customizations": { diff --git a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md index a121a5f4469..1b28626ea85 100644 --- a/.github/upgrades/prompts/SemanticKernelToAgentFramework.md +++ b/.github/upgrades/prompts/SemanticKernelToAgentFramework.md @@ -1636,4 +1636,4 @@ The property mapping guide from a `AutoFunctionInvocationContext` to a `Function | Result | Use `return` from the delegate | | Terminate | Terminate | | CancellationToken | provided via argument to middleware delegate | -| Arguments | Arguments | \ No newline at end of file +| Arguments | Arguments | diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index ecf093a3e91..109f5c11aab 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -18,6 +18,7 @@ on: env: COVERAGE_THRESHOLD: 80 + COVERAGE_FRAMEWORK: net10.0 # framework target for which we run/report code coverage concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -59,9 +60,9 @@ jobs: fail-fast: false matrix: include: - - { targetFramework: "net9.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" } - - { targetFramework: "net9.0", os: "ubuntu-latest", configuration: Debug } - - { targetFramework: "net9.0", os: "windows-latest", configuration: Release } + - { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" } + - { targetFramework: "net9.0", os: "windows-latest", configuration: Debug } + - { targetFramework: "net8.0", os: "ubuntu-latest", configuration: Release } - { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" } runs-on: ${{ matrix.os }} @@ -69,16 +70,16 @@ jobs: steps: - uses: actions/checkout@v5 with: - persist-credentials: false - sparse-checkout: | - . - .github - dotnet - python - workflow-samples + persist-credentials: false + sparse-checkout: | + . + .github + dotnet + python + workflow-samples - name: Setup dotnet - uses: actions/setup-dotnet@v5.0.0 + uses: actions/setup-dotnet@v5.0.1 with: global-json-file: ${{ github.workspace }}/dotnet/global.json - name: Build dotnet solutions @@ -123,7 +124,17 @@ jobs: popd rm -rf "$TEMP_DIR" - - name: Run Unit Tests Windows + # Start Cosmos DB Emulator for Cosmos-based unit tests (only on Windows) + - name: Start Azure Cosmos DB Emulator + if: runner.os == 'Windows' + shell: pwsh + run: | + Write-Host "Launching Azure Cosmos DB Emulator" + Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator" + Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV + + - name: Run Unit Tests shell: bash run: | export UT_PROJECTS=$(find ./dotnet -type f -name "*.UnitTests.csproj" | tr '\n' ' ') @@ -133,12 +144,20 @@ jobs: # Check if the project supports the target framework if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then - dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + if [[ "${{ matrix.targetFramework }}" == "${{ env.COVERAGE_FRAMEWORK }}" ]]; then + dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + else + dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx + fi else echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)" fi done - + env: + # Cosmos DB Emulator connection settings + COSMOSDB_ENDPOINT: https://localhost:8081 + COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw== + - name: Log event name and matrix integration-tests shell: bash run: echo "github.event_name:${{ github.event_name }} matrix.integration-tests:${{ matrix.integration-tests }} github.event.action:${{ github.event.action }} github.event.pull_request.merged:${{ github.event.pull_request.merged }}" @@ -176,6 +195,9 @@ jobs: fi done env: + # Cosmos DB Emulator connection settings + COSMOSDB_ENDPOINT: https://localhost:8081 + COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw== # OpenAI Models OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }} OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }} @@ -194,19 +216,22 @@ jobs: # Generate test reports and check coverage - name: Generate test reports - uses: danielpalme/ReportGenerator-GitHub-Action@5.4.18 + if: matrix.targetFramework == env.COVERAGE_FRAMEWORK + uses: danielpalme/ReportGenerator-GitHub-Action@5.5.0 with: reports: "./TestResults/Coverage/**/coverage.cobertura.xml" targetdir: "./TestResults/Reports" reporttypes: "HtmlInline;JsonSummary" - name: Upload coverage report artifact + if: matrix.targetFramework == env.COVERAGE_FRAMEWORK uses: actions/upload-artifact@v5 with: name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name path: ./TestResults/Reports # Directory containing files to upload - name: Check coverage + if: matrix.targetFramework == env.COVERAGE_FRAMEWORK shell: pwsh run: .github/workflows/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index a9fe0900135..757d877028f 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: include: - - { dotnet: "9.0", configuration: Release, os: ubuntu-latest } + - { dotnet: "10.0", configuration: Release, os: ubuntu-latest } runs-on: ${{ matrix.os }} env: diff --git a/README.md b/README.md index 30d9ab2bdd6..64b0dbd821d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ dotnet add package Microsoft.Agents.AI - **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel - **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen +Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users. + ### ✨ **Highlights** - **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities diff --git a/TRANSPARENCY_FAQ.md b/TRANSPARENCY_FAQ.md index cd850ff7969..3a09f191eb2 100644 --- a/TRANSPARENCY_FAQ.md +++ b/TRANSPARENCY_FAQ.md @@ -42,9 +42,9 @@ Microsoft Agent Framework relies on existing LLMs. Using the framework retains c **Framework-Specific Limitations**: -- **Platform Requirements**: Python 3.10+ required, specific .NET versions (.NET 8.0, 9.0, netstandard2.0, net472) +- **Platform Requirements**: Python 3.10+ required, specific .NET versions (.NET 8.0, 9.0, 10.0, netstandard2.0, net472) - **API Dependencies**: Requires proper configuration of LLM provider keys and endpoints -- **Orchestration Features**: Advanced orchestration patterns like GroupChat, Sequential, and Concurrent orchestrations are "coming soon" for Python implementation +- **Orchestration Features**: Advanced orchestration patterns including GroupChat, Sequential, and Concurrent workflows are now available in both Python and .NET implementations. See the respective language documentation for examples. - **Privacy and Data Protection**: The framework allows for human participation in conversations between agents. It is important to ensure that user data and conversations are protected and that developers use appropriate measures to safeguard privacy. - **Accountability and Transparency**: The framework involves multiple agents conversing and collaborating, it is important to establish clear accountability and transparency mechanisms. Users should be able to understand and trace the decision-making process of the agents involved in order to ensure accountability and address any potential issues or biases. - **Security & unintended consequences**: The use of multi-agent conversations and automation in complex tasks may have unintended consequences. Especially, allowing agents to make changes in external environments through tool calls or function execution could pose significant risks. Developers should carefully consider the potential risks and ensure that appropriate safeguards are in place to prevent harm or negative outcomes, including keeping a human in the loop for decision making. diff --git a/agent-samples/azure/AzureOpenAIAssistants.yaml b/agent-samples/azure/AzureOpenAIAssistants.yaml index 8c0d889598a..f973d05acc7 100644 --- a/agent-samples/azure/AzureOpenAIAssistants.yaml +++ b/agent-samples/azure/AzureOpenAIAssistants.yaml @@ -1,9 +1,9 @@ kind: Prompt name: Assistant description: Helpful assistant -instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response. +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response. model: - id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME + id: gpt-4o-mini provider: AzureOpenAI apiType: Assistants options: @@ -12,14 +12,14 @@ model: outputSchema: properties: language: - kind: string + type: string required: true description: The language of the answer. answer: - kind: string + type: string required: true description: The answer text. type: - kind: string + type: string required: true description: The type of the response. diff --git a/agent-samples/azure/AzureOpenAIChat.yaml b/agent-samples/azure/AzureOpenAIChat.yaml new file mode 100644 index 00000000000..d02e0c60398 --- /dev/null +++ b/agent-samples/azure/AzureOpenAIChat.yaml @@ -0,0 +1,25 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response. +model: + id: gpt-4o-mini + provider: AzureOpenAI + apiType: Chat + options: + temperature: 0.9 + topP: 0.95 +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/azure/AzureOpenAIResponses.yaml b/agent-samples/azure/AzureOpenAIResponses.yaml index 5db218ade35..006c1476f42 100644 --- a/agent-samples/azure/AzureOpenAIResponses.yaml +++ b/agent-samples/azure/AzureOpenAIResponses.yaml @@ -1,28 +1,25 @@ kind: Prompt name: Assistant description: Helpful assistant -instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response. +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response. model: - id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME + id: gpt-4o-mini provider: AzureOpenAI apiType: Responses options: - text: - verbosity: medium - connection: - kind: remote - endpoint: =Env.AZURE_OPENAI_ENDPOINT + temperature: 0.9 + topP: 0.95 outputSchema: properties: language: - kind: string + type: string required: true description: The language of the answer. answer: - kind: string + type: string required: true description: The answer text. type: - kind: string + type: string required: true description: The type of the response. diff --git a/agent-samples/chatclient/Assistant.yaml b/agent-samples/chatclient/Assistant.yaml index b34add2d232..3332d545405 100644 --- a/agent-samples/chatclient/Assistant.yaml +++ b/agent-samples/chatclient/Assistant.yaml @@ -1,7 +1,7 @@ kind: Prompt name: Assistant description: Helpful assistant -instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. model: options: temperature: 0.9 @@ -9,10 +9,10 @@ model: outputSchema: properties: language: - kind: string + type: string required: true description: The language of the answer. answer: - kind: string + type: string required: true description: The answer text. diff --git a/agent-samples/chatclient/GetWeather.yaml b/agent-samples/chatclient/GetWeather.yaml index 9ed637894dd..f32411be982 100644 --- a/agent-samples/chatclient/GetWeather.yaml +++ b/agent-samples/chatclient/GetWeather.yaml @@ -4,6 +4,8 @@ description: Helpful assistant instructions: You are a helpful assistant. You answer questions using the tools provided. model: options: + temperature: 0.9 + topP: 0.95 allowMultipleToolCalls: true chatToolMode: auto tools: diff --git a/agent-samples/foundry/FoundryAgent.yaml b/agent-samples/foundry/FoundryAgent.yaml new file mode 100644 index 00000000000..2de2ea069ef --- /dev/null +++ b/agent-samples/foundry/FoundryAgent.yaml @@ -0,0 +1,22 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. +model: + id: gpt-4.1-mini + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: Remote + endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. diff --git a/agent-samples/openai/OpenAIAssistants.yaml b/agent-samples/openai/OpenAIAssistants.yaml index 78bd48d7015..c1f20beb381 100644 --- a/agent-samples/openai/OpenAIAssistants.yaml +++ b/agent-samples/openai/OpenAIAssistants.yaml @@ -1,30 +1,28 @@ kind: Prompt name: Assistant description: Helpful assistant -instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response. +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response. model: - id: =Env.OPENAI_MODEL + id: gpt-4.1-mini provider: OpenAI apiType: Assistants options: temperature: 0.9 topP: 0.95 connection: - kind: key + kind: ApiKey key: =Env.OPENAI_APIKEY outputSchema: - name: AssistantResponse - description: The response from the assistant. properties: language: - kind: string + type: string required: true description: The language of the answer. answer: - kind: string + type: string required: true description: The answer text. type: - kind: string + type: string required: true description: The type of the response. diff --git a/agent-samples/openai/OpenAIChat.yaml b/agent-samples/openai/OpenAIChat.yaml new file mode 100644 index 00000000000..832ef4eb151 --- /dev/null +++ b/agent-samples/openai/OpenAIChat.yaml @@ -0,0 +1,28 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response. +model: + id: gpt-4.1-mini + provider: OpenAI + apiType: Chat + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: ApiKey + key: =Env.OPENAI_APIKEY +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/openai/OpenAIResponses.yaml b/agent-samples/openai/OpenAIResponses.yaml index 0fcda30c9cd..efe822233ec 100644 --- a/agent-samples/openai/OpenAIResponses.yaml +++ b/agent-samples/openai/OpenAIResponses.yaml @@ -1,28 +1,28 @@ kind: Prompt name: Assistant description: Helpful assistant -instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response. +instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response. model: - id: =Env.OPENAI_MODEL + id: gpt-4.1-mini provider: OpenAI apiType: Responses options: - text: - verbosity: medium + temperature: 0.9 + topP: 0.95 connection: - kind: key + kind: ApiKey key: =Env.OPENAI_APIKEY outputSchema: properties: language: - kind: string + type: string required: true description: The language of the answer. answer: - kind: string + type: string required: true description: The answer text. type: - kind: string + type: string required: true description: The type of the response. diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 6b61196bbd9..54a125a13b4 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -6,14 +6,12 @@ AllEnabledByDefault latest true - 13 + latest enable - $(NoWarn);NU5128 + $(NoWarn);NU5128;CS8002 true - net9.0;net8.0 - net9.0 - net9.0;net8.0;netstandard2.0;net472 - net9.0;net472 + net10.0;net9.0;net8.0 + $(TargetFrameworksCore);netstandard2.0;net472 true Debug;Release;Publish diff --git a/dotnet/Directory.Build.targets b/dotnet/Directory.Build.targets index 75033d16e31..5e62f1cef75 100644 --- a/dotnet/Directory.Build.targets +++ b/dotnet/Directory.Build.targets @@ -5,7 +5,7 @@ - + diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 00f10230a5b..b9338fc9bb7 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -7,10 +7,12 @@ - 13.0.0 + 13.0.1 + + @@ -18,15 +20,22 @@ - - - + + + + + + + + + + - + @@ -37,6 +46,7 @@ + @@ -48,13 +58,13 @@ - + - - + + - + @@ -80,22 +90,21 @@ - + - - + + - - + @@ -117,16 +126,17 @@ - - - + + + + @@ -153,20 +163,20 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 9c5f1a81f33..816ffdb678f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -40,16 +40,19 @@ + + + @@ -76,11 +79,21 @@ + + + + + + + + + + @@ -311,6 +324,7 @@ + @@ -335,9 +349,12 @@ + + + @@ -357,6 +374,7 @@ + @@ -373,8 +391,11 @@ + + + @@ -389,4 +410,4 @@ - + \ No newline at end of file diff --git a/dotnet/global.json b/dotnet/global.json index 402d97f6652..54533bf771b 100644 --- a/dotnet/global.json +++ b/dotnet/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "9.0.300", - "rollForward": "latestMajor", + "version": "10.0.100", + "rollForward": "minor", "allowPrerelease": false } } \ No newline at end of file diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 2282c9ce133..ca719be243f 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251114.1 - $(VersionPrefix)-preview.251114.1 - 1.0.0-preview.251114.1 + $(VersionPrefix)-$(VersionSuffix).251125.1 + $(VersionPrefix)-preview.251125.1 + 1.0.0-preview.251125.1 Debug;Release;Publish true diff --git a/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj b/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj index 77a05882319..6b88c5c697f 100644 --- a/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj +++ b/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 @@ -12,8 +12,6 @@ - - diff --git a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj index 8d67180f64f..0a3b170a0bd 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 @@ -11,8 +11,11 @@ - - + + + + + diff --git a/dotnet/samples/A2AClientServer/README.md b/dotnet/samples/A2AClientServer/README.md index 8bf5fc5816f..04b9968e760 100644 --- a/dotnet/samples/A2AClientServer/README.md +++ b/dotnet/samples/A2AClientServer/README.md @@ -103,7 +103,7 @@ dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentId " Exe - net9.0 + net10.0 enable enable a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10 @@ -11,8 +11,6 @@ - - diff --git a/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs b/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs index 0cbf15d6e45..3079bf14517 100644 --- a/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs +++ b/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs @@ -201,12 +201,12 @@ private static string PrintArguments(IDictionary? arguments) { return ""; } - var builder = new StringBuilder(); - builder.AppendLine(); + var builder = new StringBuilder().AppendLine(); foreach (var kvp in arguments) { - builder.AppendLine($" Name: {kvp.Key}"); - builder.AppendLine($" Value: {kvp.Value}"); + builder + .AppendLine($" Name: {kvp.Key}") + .AppendLine($" Value: {kvp.Value}"); } return builder.ToString(); } diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj index 0513374a935..cea8efff76e 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21 @@ -11,8 +11,6 @@ - - diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs index 57cc409c589..7e9ccca9b90 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs @@ -42,4 +42,4 @@ await app.RunAsync(); -public partial class Program { } +public partial class Program; diff --git a/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServer.csproj b/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServer.csproj index c1bcd511da2..ccfe22923a8 100644 --- a/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServer.csproj +++ b/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServer.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10 @@ -11,8 +11,6 @@ - - diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index b4141ba166a..3f2a832a690 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable true @@ -31,11 +31,4 @@ - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs index d3deb9162cb..14f0bcee41a 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs @@ -4,9 +4,7 @@ namespace AgentWebChat.AgentHost.Custom; -public class CustomAITool : AITool -{ -} +public class CustomAITool : AITool; public class CustomFunctionTool : AIFunction { diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj index 464ba54db84..de87c119ecf 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj @@ -4,7 +4,7 @@ Exe - net9.0 + net10.0 enable enable true diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj index 09110f11ad2..0c5573beac6 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs index db690950da9..08dafea1299 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs @@ -25,7 +25,7 @@ public A2AAgentClient(ILogger logger, Uri baseUri) this._uri = baseUri; } - public async override IAsyncEnumerable RunStreamingAsync( + public override async IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -122,7 +122,7 @@ public async override IAsyncEnumerable RunStreamingAsync } } - public async override Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) + public override async Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) { this._logger.LogInformation("Retrieving agent card for {Agent}", agentName); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj index 72541f046f1..fd26f561912 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable $(NoWarn);CA1812 @@ -15,11 +15,4 @@ - - - - - - - diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs index ae71a876786..95e3d16fd4c 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs @@ -16,7 +16,7 @@ namespace AgentWebChat.Web; /// internal sealed class OpenAIChatCompletionsAgentClient(HttpClient httpClient) : AgentClientBase { - public async override IAsyncEnumerable RunStreamingAsync( + public override async IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs index bb7f6c151c3..7cc85b97c3b 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs @@ -15,7 +15,7 @@ namespace AgentWebChat.Web; /// internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentClientBase { - public async override IAsyncEnumerable RunStreamingAsync( + public override async IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj b/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj index bce1b96f7b6..99f78cc1abb 100644 --- a/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj index 4ec460450ab..af6fe8bcded 100644 --- a/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj +++ b/dotnet/samples/AzureFunctions/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj index 8698b0a7b87..394bf9cc35b 100644 --- a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs index d4d5750df7f..5a6fbaf2034 100644 --- a/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs +++ b/dotnet/samples/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs @@ -36,8 +36,9 @@ .ConfigureFunctionsWebApplication() .ConfigureDurableAgents(options => { - options.AddAIAgent(physicistAgent); - options.AddAIAgent(chemistAgent); + options + .AddAIAgent(physicistAgent) + .AddAIAgent(chemistAgent); }) .Build(); diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj index 1971fb164a4..8dc1832227d 100644 --- a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs index e63d1a96677..971f862f21c 100644 --- a/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs +++ b/dotnet/samples/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs @@ -39,8 +39,9 @@ .ConfigureFunctionsWebApplication() .ConfigureDurableAgents(options => { - options.AddAIAgent(spamDetectionAgent); - options.AddAIAgent(emailAssistantAgent); + options + .AddAIAgent(spamDetectionAgent) + .AddAIAgent(emailAssistantAgent); }) .Build(); diff --git a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj index b7d211605fd..a240ea03946 100644 --- a/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj +++ b/dotnet/samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj b/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj index f6e6b7bbfc1..8711331aa24 100644 --- a/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj +++ b/dotnet/samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj index 8fa1f5f2e77..12795b2efbd 100644 --- a/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj +++ b/dotnet/samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 v4 Exe enable diff --git a/dotnet/samples/AzureFunctions/README.md b/dotnet/samples/AzureFunctions/README.md index 83d26a53b7f..e60b0f662e3 100644 --- a/dotnet/samples/AzureFunctions/README.md +++ b/dotnet/samples/AzureFunctions/README.md @@ -18,7 +18,7 @@ These samples are designed to be run locally in a cloned repository. The following prerequisites are required to run the samples: -- [.NET 9.0 SDK or later](https://dotnet.microsoft.com/download/dotnet) +- [.NET 10.0 SDK or later](https://dotnet.microsoft.com/download/dotnet) - [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (version 4.x or later) - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated (`az login`) or an API key for the Azure OpenAI service - [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-4o-mini or better is recommended) diff --git a/dotnet/samples/Directory.Build.props b/dotnet/samples/Directory.Build.props index dd86677c3e5..15880d4a8e5 100644 --- a/dotnet/samples/Directory.Build.props +++ b/dotnet/samples/Directory.Build.props @@ -5,7 +5,7 @@ false false - net472;net9.0 + net10.0;net472 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj index 2b89b20fbf1..d91b20e34bc 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -13,8 +13,6 @@ - - diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md index 6cbd56dca4c..c050ad08302 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md @@ -7,7 +7,7 @@ and register these function tools with another AI agent so it can leverage the A Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Access to the A2A agent host service **Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj new file mode 100644 index 00000000000..1f36cef5766 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs new file mode 100644 index 00000000000..7b5934575cd --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. + +using A2A; +using Microsoft.Agents.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent agent = agentCard.GetAIAgent(); + +AgentThread thread = agent.GetNewThread(); + +// Start the initial run with a long-running task. +AgentRunResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", thread); + +// Poll until the response is complete. +while (response.ContinuationToken is { } token) +{ + // Wait before polling again. + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Continue with the token. + response = await agent.RunAsync(thread, options: new AgentRunOptions { ContinuationToken = token }); +} + +// Display the result +Console.WriteLine(response); diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md new file mode 100644 index 00000000000..3e1160b5109 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md @@ -0,0 +1,25 @@ +# Polling for A2A Agent Task Completion + +This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent, following the background responses pattern. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Sends a request to the agent that may take time to complete +- Polls the agent at regular intervals using continuation tokens until a final response is received +- Displays the final result + +This pattern is useful when an AI model cannot complete a complex task in a single response and needs multiple rounds of processing. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/GettingStarted/A2A/README.md b/dotnet/samples/GettingStarted/A2A/README.md index 3ddac959967..b513ffa9293 100644 --- a/dotnet/samples/GettingStarted/A2A/README.md +++ b/dotnet/samples/GettingStarted/A2A/README.md @@ -14,6 +14,7 @@ See the README.md for each sample for the prerequisites for that sample. |Sample|Description| |---|---| |[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| +|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.| ## Running the samples from the console diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj b/dotnet/samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj index f9b7b3da2a6..e194fec9c25 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -22,7 +22,6 @@ - diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md b/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md index 8f675a20d1e..229d37dca6a 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md @@ -22,7 +22,7 @@ graph TD ## Prerequisites -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) - Docker installed (for running Aspire Dashboard) @@ -71,7 +71,7 @@ If you prefer to run the components manually: #### Step 1: Start the Aspire Dashboard via Docker ```powershell -docker run -d --name aspire-dashboard -p 4318:18888 -p 4317:18889 -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true mcr.microsoft.com/dotnet/aspire-dashboard:9.0 +docker run -d --name aspire-dashboard -p 4318:18888 -p 4317:18889 -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true mcr.microsoft.com/dotnet/aspire-dashboard:latest ``` #### Step 2: Access the Dashboard @@ -207,7 +207,7 @@ If you encounter port binding errors, try: - Ensure the Azure OpenAI deployment name matches your actual deployment ### Build Issues -- Ensure you're using .NET 9.0 SDK +- Ensure you're using .NET 10.0 SDK - Run `dotnet restore` if you encounter package restore issues - Check that all project references are correctly resolved diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/start-demo.ps1 b/dotnet/samples/GettingStarted/AgentOpenTelemetry/start-demo.ps1 index 8445d1e7e32..7af1c9d8aee 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/start-demo.ps1 +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/start-demo.ps1 @@ -65,7 +65,7 @@ $dockerResult = docker run -d ` -p 4317:18889 ` -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true ` --restart unless-stopped ` - mcr.microsoft.com/dotnet/aspire-dashboard:9.0 + mcr.microsoft.com/dotnet/aspire-dashboard:latest if ($LASTEXITCODE -ne 0) { Write-Host "Failed to start Aspire Dashboard container" -ForegroundColor Red diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj index e01a9f74587..7236ee50447 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -10,8 +10,6 @@ - - diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/README.md index ce7a9174b06..536514306ed 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Access to the A2A agent host service **Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Agent_With_Anthropic.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Agent_With_Anthropic.csproj new file mode 100644 index 00000000000..eb29d1d310f --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Agent_With_Anthropic.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);IDE0059 + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs new file mode 100644 index 00000000000..df070c335b2 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use an AI agent with Anthropic as the backend. + +using System.Net.Http.Headers; +using Anthropic; +using Anthropic.Foundry; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Sample; + +var deploymentName = Environment.GetEnvironmentVariable("ANTHROPIC_DEPLOYMENT_NAME") ?? "claude-haiku-4-5"; + +// The resource is the subdomain name / first name coming before '.services.ai.azure.com' in the endpoint Uri +// ie: https://(resource name).services.ai.azure.com/anthropic/v1/chat/completions +string? resource = Environment.GetEnvironmentVariable("ANTHROPIC_RESOURCE"); +string? apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +AnthropicClient? client = (resource is null) + ? new AnthropicClient() { APIKey = apiKey ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is required when no ANTHROPIC_RESOURCE is provided") } // If no resource is provided, use Anthropic public API + : (apiKey is not null) + ? new AnthropicFoundryClient(new AnthropicFoundryApiKeyCredentials(apiKey, resource)) // If an apiKey is provided, use Foundry with ApiKey authentication + : new AnthropicFoundryClient(new AnthropicAzureTokenCredential(new AzureCliCredential(), resource)); // Otherwise, use Foundry with Azure Client authentication + +AIAgent agent = client.CreateAIAgent(model: deploymentName, instructions: JokerInstructions, name: JokerName); + +// Invoke the agent and output the text result. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); + +namespace Sample +{ + /// + /// Provides methods for invoking the Azure hosted Anthropic models using types. + /// + public sealed class AnthropicAzureTokenCredential : IAnthropicFoundryCredentials + { + private readonly TokenCredential _tokenCredential; + private readonly Lock _lock = new(); + private AccessToken? _cachedAccessToken; + + /// + public string ResourceName { get; } + + /// + /// Creates a new instance of the . + /// + /// The credential provider. Use any specialization of to get your access token in supported environments. + /// The service resource subdomain name to use in the anthropic azure endpoint + internal AnthropicAzureTokenCredential(TokenCredential tokenCredential, string resourceName) + { + this.ResourceName = resourceName ?? throw new ArgumentNullException(nameof(resourceName)); + this._tokenCredential = tokenCredential ?? throw new ArgumentNullException(nameof(tokenCredential)); + } + + /// + public void Apply(HttpRequestMessage requestMessage) + { + lock (this._lock) + { + // Add a 5-minute buffer to avoid using tokens that are about to expire + if (this._cachedAccessToken is null || this._cachedAccessToken.Value.ExpiresOn <= DateTimeOffset.Now.AddMinutes(5)) + { + this._cachedAccessToken = this._tokenCredential.GetToken(new TokenRequestContext(scopes: ["https://ai.azure.com/.default"]), CancellationToken.None); + } + } + + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", this._cachedAccessToken.Value.Token); + } + } +} diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/README.md new file mode 100644 index 00000000000..afcf3915721 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/README.md @@ -0,0 +1,53 @@ +# Creating an AIAgent with Anthropic + +This sample demonstrates how to create an AIAgent using Anthropic Claude models as the underlying inference service. + +The sample supports three deployment scenarios: + +1. **Anthropic Public API** - Direct connection to Anthropic's public API +2. **Azure Foundry with API Key** - Anthropic models deployed through Azure Foundry using API key authentication +3. **Azure Foundry with Azure CLI** - Anthropic models deployed through Azure Foundry using Azure CLI credentials + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later + +### For Anthropic Public API + +- Anthropic API key + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key +$env:ANTHROPIC_DEPLOYMENT_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 +``` + +### For Azure Foundry with API Key + +- Azure Foundry service endpoint and deployment configured +- Anthropic API key + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Azure Foundry resource name (subdomain before .services.ai.azure.com) +$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key +$env:ANTHROPIC_DEPLOYMENT_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 +``` + +### For Azure Foundry with Azure CLI + +- Azure Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_RESOURCE="your-foundry-resource-name" # Replace with your Azure Foundry resource name (subdomain before .services.ai.azure.com) +$env:ANTHROPIC_DEPLOYMENT_NAME="claude-haiku-4-5" # Optional, defaults to claude-haiku-4-5 +``` + +**Note**: When using Azure Foundry with Azure CLI, make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj index 11c7beb3bf7..d40e93232b9 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md index df0854ba2f5..d6b54976016 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md @@ -1,8 +1,18 @@ +# Classic Foundry Agents + +This sample demonstrates how to create an agent using the classic Foundry Agents experience. + +# Classic vs New Foundry Agents + +Below is a comparison between the classic and new Foundry Agents approaches: + +[Migration Guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry) + # Prerequisites Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj index 057a0fc507d..a8deaa57b59 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Agent_With_AzureAIProject.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs index dd4a011e4d3..2c2b9d19699 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -10,35 +10,34 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerInstructions = "You are good at telling jokes."; const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); // Define the agent you want to create. (Prompt Agent in this case) -var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); +var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); // Azure.AI.Agents SDK creates and manages agent by name and versions. // You can create a server side agent version with the Azure.AI.Agents SDK client below. -var agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions); +var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions); // Note: // agentVersion.Id = ":", // agentVersion.Version = , // agentVersion.Name = -// You can retrieve an AIAgent for a already created server side agent version. -AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion); +// You can retrieve an AIAgent for an already created server side agent version. +AIAgent existingJokerAgent = aiProjectClient.GetAIAgent(createdAgentVersion); -// You can also create another AIAgent version (V2) by providing the same name with a different definition. -AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions + "V2"); +// You can also create another AIAgent version by providing the same name with a different definition. +AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes."); // You can also get the AIAgent latest version just providing its name. AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName); -var latestVersion = jokerAgentLatest.GetService()!; +var latestAgentVersion = jokerAgentLatest.GetService()!; // The AIAgent version can be accessed via the GetService method. -Console.WriteLine($"Latest agent version id: {latestVersion.Id}"); +Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); // Once you have the AIAgent, you can invoke it like any other AIAgent. AgentThread thread = jokerAgentLatest.GetNewThread(); @@ -47,5 +46,5 @@ // This will use the same thread to continue the conversation. Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", thread)); -// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2). -aiProjectClient.Agents.DeleteAgent(jokerAgentV1.Name); +// Cleanup by agent name removes both agent versions created. +aiProjectClient.Agents.DeleteAgent(existingJokerAgent.Name); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md index df0854ba2f5..7e4a28f6a10 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/README.md @@ -1,8 +1,18 @@ +# New Foundry Agents + +This sample demonstrates how to create an agent using the new Foundry Agents experience. + +# Classic vs New Foundry Agents + +Below is a comparison between the classic and new Foundry Agents approaches: + +[Migration Guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry) + # Prerequisites Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Agent_With_AzureFoundryModel.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Agent_With_AzureFoundryModel.csproj index cd545ddb486..0c4701fafd8 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Agent_With_AzureFoundryModel.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Agent_With_AzureFoundryModel.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/README.md index 9147bda1da6..cff8767770b 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/README.md @@ -10,7 +10,7 @@ You could use models from Microsoft, OpenAI, DeepSeek, Hugging Face, Meta, xAI o Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure AI Foundry resource - A model deployment in your Azure AI Foundry resource. This example defaults to using the `Phi-4-mini-instruct` model, so if you want to use a different model, ensure that you set your `AZURE_FOUNDRY_MODEL_DEPLOYMENT` environment diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj index 0eacdab258a..41aafe34372 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md index 1278eb59e5b..4cacf30131d 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj index 0eacdab258a..41aafe34372 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/README.md index 1278eb59e5b..4cacf30131d 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj index aa1c382aef6..945912bfd49 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs index fd00618f5fe..8f1039251d0 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs @@ -39,11 +39,16 @@ public override async Task RunAsync(IEnumerable m // Create a thread if the user didn't supply one. thread ??= this.GetNewThread(); + if (thread is not CustomAgentThread typedThread) + { + throw new ArgumentException($"The provided thread is not of type {nameof(CustomAgentThread)}.", nameof(thread)); + } + // Clone the input messages and turn them into response messages with upper case text. List responseMessages = CloneAndToUpperCase(messages, this.DisplayName).ToList(); // Notify the thread of the input and output messages. - await NotifyThreadOfNewMessagesAsync(thread, messages.Concat(responseMessages), cancellationToken); + await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken); return new AgentRunResponse { @@ -58,11 +63,16 @@ public override async IAsyncEnumerable RunStreamingAsync // Create a thread if the user didn't supply one. thread ??= this.GetNewThread(); + if (thread is not CustomAgentThread typedThread) + { + throw new ArgumentException($"The provided thread is not of type {nameof(CustomAgentThread)}.", nameof(thread)); + } + // Clone the input messages and turn them into response messages with upper case text. List responseMessages = CloneAndToUpperCase(messages, this.DisplayName).ToList(); // Notify the thread of the input and output messages. - await NotifyThreadOfNewMessagesAsync(thread, messages.Concat(responseMessages), cancellationToken); + await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken); foreach (var message in responseMessages) { diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj new file mode 100644 index 00000000000..d01f015a4b9 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj @@ -0,0 +1,25 @@ + + + + Exe + net8.0;net9.0;net10.0 + + enable + enable + $(NoWarn);IDE0059;NU1510 + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GeminiChatClient.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GeminiChatClient.cs new file mode 100644 index 00000000000..2a1d47a456a --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GeminiChatClient.cs @@ -0,0 +1,558 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Google.Apis.Util; +using Google.GenAI; +using Google.GenAI.Types; + +namespace Microsoft.Extensions.AI; + +/// Provides an implementation based on . +internal sealed class GoogleGenAIChatClient : IChatClient +{ + /// The wrapped instance (optional). + private readonly Client? _client; + + /// The wrapped instance. + private readonly Models _models; + + /// The default model that should be used when no override is specified. + private readonly string? _defaultModelId; + + /// Lazily-initialized metadata describing the implementation. + private ChatClientMetadata? _metadata; + + /// Initializes a new instance. + public GoogleGenAIChatClient(Client client, string? defaultModelId) + { + this._client = client; + this._models = client.Models; + this._defaultModelId = defaultModelId; + } + + /// Initializes a new instance. + public GoogleGenAIChatClient(Models client, string? defaultModelId) + { + this._models = client; + this._defaultModelId = defaultModelId; + } + + /// + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Utilities.ThrowIfNull(messages, nameof(messages)); + + // Create the request. + (string? modelId, List contents, GenerateContentConfig config) = this.CreateRequest(messages, options); + + // Send it. + GenerateContentResponse generateResult = await this._models.GenerateContentAsync(modelId!, contents, config).ConfigureAwait(false); + + // Create the response. + ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, new List())) + { + CreatedAt = generateResult.CreateTime is { } dt ? new DateTimeOffset(dt) : null, + ModelId = !string.IsNullOrWhiteSpace(generateResult.ModelVersion) ? generateResult.ModelVersion : modelId, + RawRepresentation = generateResult, + ResponseId = generateResult.ResponseId, + }; + + // Populate the response messages. + chatResponse.FinishReason = PopulateResponseContents(generateResult, chatResponse.Messages[0].Contents); + + // Populate usage information if there is any. + if (generateResult.UsageMetadata is { } usageMetadata) + { + chatResponse.Usage = ExtractUsageDetails(usageMetadata); + } + + // Return the response. + return chatResponse; + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Utilities.ThrowIfNull(messages, nameof(messages)); + + // Create the request. + (string? modelId, List contents, GenerateContentConfig config) = this.CreateRequest(messages, options); + + // Send it, and process the results. + await foreach (GenerateContentResponse generateResult in this._models.GenerateContentStreamAsync(modelId!, contents, config).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + // Create a response update for each result in the stream. + ChatResponseUpdate responseUpdate = new(ChatRole.Assistant, new List()) + { + CreatedAt = generateResult.CreateTime is { } dt ? new DateTimeOffset(dt) : null, + ModelId = !string.IsNullOrWhiteSpace(generateResult.ModelVersion) ? generateResult.ModelVersion : modelId, + RawRepresentation = generateResult, + ResponseId = generateResult.ResponseId, + }; + + // Populate the response update contents. + responseUpdate.FinishReason = PopulateResponseContents(generateResult, responseUpdate.Contents); + + // Populate usage information if there is any. + if (generateResult.UsageMetadata is { } usageMetadata) + { + responseUpdate.Contents.Add(new UsageContent(ExtractUsageDetails(usageMetadata))); + } + + // Yield the update. + yield return responseUpdate; + } + } + + /// + public object? GetService(System.Type serviceType, object? serviceKey = null) + { + Utilities.ThrowIfNull(serviceType, nameof(serviceType)); + + if (serviceKey is null) + { + // If there's a request for metadata, lazily-initialize it and return it. We don't need to worry about race conditions, + // as there's no requirement that the same instance be returned each time, and creation is idempotent. + if (serviceType == typeof(ChatClientMetadata)) + { + return this._metadata ??= new("gcp.gen_ai", new("https://generativelanguage.googleapis.com/"), defaultModelId: this._defaultModelId); + } + + // Allow a consumer to "break glass" and access the underlying client if they need it. + if (serviceType.IsInstanceOfType(this._models)) + { + return this._models; + } + + if (this._client is not null && serviceType.IsInstanceOfType(this._client)) + { + return this._client; + } + + if (serviceType.IsInstanceOfType(this)) + { + return this; + } + } + + return null; + } + + /// + void IDisposable.Dispose() { /* nop */ } + + /// Creates the message parameters for from and . + private (string? ModelId, List Contents, GenerateContentConfig Config) CreateRequest(IEnumerable messages, ChatOptions? options) + { + // Create the GenerateContentConfig object. If the options contains a RawRepresentationFactory, try to use it to + // create the request instance, allowing the caller to populate it with GenAI-specific options. Otherwise, create + // a new instance directly. + string? model = this._defaultModelId; + List contents = new(); + GenerateContentConfig config = options?.RawRepresentationFactory?.Invoke(this) as GenerateContentConfig ?? new(); + + if (options is not null) + { + if (options.FrequencyPenalty is { } frequencyPenalty) + { + config.FrequencyPenalty ??= frequencyPenalty; + } + + if (options.Instructions is { } instructions) + { + ((config.SystemInstruction ??= new()).Parts ??= new()).Add(new() { Text = instructions }); + } + + if (options.MaxOutputTokens is { } maxOutputTokens) + { + config.MaxOutputTokens ??= maxOutputTokens; + } + + if (!string.IsNullOrWhiteSpace(options.ModelId)) + { + model = options.ModelId; + } + + if (options.PresencePenalty is { } presencePenalty) + { + config.PresencePenalty ??= presencePenalty; + } + + if (options.Seed is { } seed) + { + config.Seed ??= (int)seed; + } + + if (options.StopSequences is { } stopSequences) + { + (config.StopSequences ??= new()).AddRange(stopSequences); + } + + if (options.Temperature is { } temperature) + { + config.Temperature ??= temperature; + } + + if (options.TopP is { } topP) + { + config.TopP ??= topP; + } + + if (options.TopK is { } topK) + { + config.TopK ??= topK; + } + + // Populate tools. Each kind of tool is added on its own, except for function declarations, + // which are grouped into a single FunctionDeclaration. + List? functionDeclarations = null; + if (options.Tools is { } tools) + { + foreach (var tool in tools) + { + switch (tool) + { + case AIFunctionDeclaration af: + functionDeclarations ??= new(); + functionDeclarations.Add(new() + { + Name = af.Name, + Description = af.Description ?? "", + ParametersJsonSchema = af.JsonSchema, + }); + break; + + case HostedCodeInterpreterTool: + (config.Tools ??= new()).Add(new() { CodeExecution = new() }); + break; + + case HostedFileSearchTool: + (config.Tools ??= new()).Add(new() { Retrieval = new() }); + break; + + case HostedWebSearchTool: + (config.Tools ??= new()).Add(new() { GoogleSearch = new() }); + break; + } + } + } + + if (functionDeclarations is { Count: > 0 }) + { + Tool functionTools = new(); + (functionTools.FunctionDeclarations ??= new()).AddRange(functionDeclarations); + (config.Tools ??= new()).Add(functionTools); + } + + // Transfer over the tool mode if there are any tools. + if (options.ToolMode is { } toolMode && config.Tools?.Count > 0) + { + switch (toolMode) + { + case NoneChatToolMode: + config.ToolConfig = new() { FunctionCallingConfig = new() { Mode = FunctionCallingConfigMode.NONE } }; + break; + + case AutoChatToolMode: + config.ToolConfig = new() { FunctionCallingConfig = new() { Mode = FunctionCallingConfigMode.AUTO } }; + break; + + case RequiredChatToolMode required: + config.ToolConfig = new() { FunctionCallingConfig = new() { Mode = FunctionCallingConfigMode.ANY } }; + if (required.RequiredFunctionName is not null) + { + ((config.ToolConfig.FunctionCallingConfig ??= new()).AllowedFunctionNames ??= new()).Add(required.RequiredFunctionName); + } + break; + } + } + + // Set the response format if specified. + if (options.ResponseFormat is ChatResponseFormatJson responseFormat) + { + config.ResponseMimeType = "application/json"; + if (responseFormat.Schema is { } schema) + { + config.ResponseJsonSchema = schema; + } + } + } + + // Transfer messages to request, handling system messages specially + Dictionary? callIdToFunctionNames = null; + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + string instruction = message.Text; + if (!string.IsNullOrWhiteSpace(instruction)) + { + ((config.SystemInstruction ??= new()).Parts ??= new()).Add(new() { Text = instruction }); + } + + continue; + } + + Content content = new() { Role = message.Role == ChatRole.Assistant ? "model" : "user" }; + content.Parts ??= new(); + AddPartsForAIContents(ref callIdToFunctionNames, message.Contents, content.Parts); + + contents.Add(content); + } + + // Make sure the request contains at least one content part (the request would always fail if empty). + if (!contents.SelectMany(c => c.Parts ?? Enumerable.Empty()).Any()) + { + contents.Add(new() { Role = "user", Parts = new() { { new() { Text = "" } } } }); + } + + return (model, contents, config); + } + + /// Creates s for and adds them to . + private static void AddPartsForAIContents(ref Dictionary? callIdToFunctionNames, IList contents, List parts) + { + for (int i = 0; i < contents.Count; i++) + { + var content = contents[i]; + + byte[]? thoughtSignature = null; + if (content is not TextReasoningContent { ProtectedData: not null } && + i + 1 < contents.Count && + contents[i + 1] is TextReasoningContent nextReasoning && + string.IsNullOrWhiteSpace(nextReasoning.Text) && + nextReasoning.ProtectedData is { } protectedData) + { + i++; + thoughtSignature = Convert.FromBase64String(protectedData); + } + + Part? part = null; + switch (content) + { + case TextContent textContent: + part = new() { Text = textContent.Text }; + break; + + case TextReasoningContent reasoningContent: + part = new() + { + Thought = true, + Text = !string.IsNullOrWhiteSpace(reasoningContent.Text) ? reasoningContent.Text : null, + ThoughtSignature = reasoningContent.ProtectedData is not null ? Convert.FromBase64String(reasoningContent.ProtectedData) : null, + }; + break; + + case DataContent dataContent: + part = new() + { + InlineData = new() + { + MimeType = dataContent.MediaType, + Data = dataContent.Data.ToArray(), + DisplayName = dataContent.Name, + } + }; + break; + + case UriContent uriContent: + part = new() + { + FileData = new() + { + FileUri = uriContent.Uri.AbsoluteUri, + MimeType = uriContent.MediaType, + } + }; + break; + + case FunctionCallContent functionCallContent: + (callIdToFunctionNames ??= new())[functionCallContent.CallId] = functionCallContent.Name; + callIdToFunctionNames[""] = functionCallContent.Name; // track last function name in case calls don't have IDs + + part = new() + { + FunctionCall = new() + { + Id = functionCallContent.CallId, + Name = functionCallContent.Name, + Args = functionCallContent.Arguments is null ? null : functionCallContent.Arguments as Dictionary ?? new(functionCallContent.Arguments!), + } + }; + break; + + case FunctionResultContent functionResultContent: + part = new() + { + FunctionResponse = new() + { + Id = functionResultContent.CallId, + Name = callIdToFunctionNames?.TryGetValue(functionResultContent.CallId, out string? functionName) is true || callIdToFunctionNames?.TryGetValue("", out functionName) is true ? + functionName : + null, + Response = functionResultContent.Result is null ? null : new() { ["result"] = functionResultContent.Result }, + } + }; + break; + } + + if (part is not null) + { + part.ThoughtSignature ??= thoughtSignature; + parts.Add(part); + } + } + } + + /// Creates s for and adds them to . + private static void AddAIContentsForParts(List parts, IList contents) + { + foreach (var part in parts) + { + AIContent? content = null; + + if (!string.IsNullOrEmpty(part.Text)) + { + content = part.Thought is true ? + new TextReasoningContent(part.Text) : + new TextContent(part.Text); + } + else if (part.InlineData is { } inlineData) + { + content = new DataContent(inlineData.Data, inlineData.MimeType ?? "application/octet-stream") + { + Name = inlineData.DisplayName, + }; + } + else if (part.FileData is { FileUri: not null } fileData) + { + content = new UriContent(new Uri(fileData.FileUri), fileData.MimeType ?? "application/octet-stream"); + } + else if (part.FunctionCall is { Name: not null } functionCall) + { + content = new FunctionCallContent(functionCall.Id ?? "", functionCall.Name, functionCall.Args!); + } + else if (part.FunctionResponse is { } functionResponse) + { + content = new FunctionResultContent( + functionResponse.Id ?? "", + functionResponse.Response?.TryGetValue("output", out var output) is true ? output : + functionResponse.Response?.TryGetValue("error", out var error) is true ? error : + null); + } + + if (content is not null) + { + content.RawRepresentation = part; + contents.Add(content); + + if (part.ThoughtSignature is { } thoughtSignature) + { + contents.Add(new TextReasoningContent(null) + { + ProtectedData = Convert.ToBase64String(thoughtSignature), + }); + } + } + } + } + + private static ChatFinishReason? PopulateResponseContents(GenerateContentResponse generateResult, IList responseContents) + { + ChatFinishReason? finishReason = null; + + // Populate the response messages. There should only be at most one candidate, but if there are more, ignore all but the first. + if (generateResult.Candidates is { Count: > 0 } && + generateResult.Candidates[0] is { Content: { } candidateContent } candidate) + { + // Grab the finish reason if one exists. + finishReason = ConvertFinishReason(candidate.FinishReason); + + // Add all of the response content parts as AIContents. + if (candidateContent.Parts is { } parts) + { + AddAIContentsForParts(parts, responseContents); + } + + // Add any citation metadata. + if (candidate.CitationMetadata is { Citations: { Count: > 0 } citations } && + responseContents.OfType().FirstOrDefault() is TextContent textContent) + { + foreach (var citation in citations) + { + textContent.Annotations = new List() + { + new CitationAnnotation() + { + Title = citation.Title, + Url = Uri.TryCreate(citation.Uri, UriKind.Absolute, out Uri? uri) ? uri : null, + AnnotatedRegions = new List() + { + new TextSpanAnnotatedRegion() + { + StartIndex = citation.StartIndex, + EndIndex = citation.EndIndex, + } + }, + } + }; + } + } + } + + // Populate error information if there is any. + if (generateResult.PromptFeedback is { } promptFeedback) + { + responseContents.Add(new ErrorContent(promptFeedback.BlockReasonMessage)); + } + + return finishReason; + } + + /// Creates an M.E.AI from a Google . + private static ChatFinishReason? ConvertFinishReason(FinishReason? finishReason) + { + return finishReason switch + { + null => null, + + FinishReason.MAX_TOKENS => + ChatFinishReason.Length, + + FinishReason.MALFORMED_FUNCTION_CALL or + FinishReason.UNEXPECTED_TOOL_CALL => + ChatFinishReason.ToolCalls, + + FinishReason.FINISH_REASON_UNSPECIFIED or + FinishReason.STOP => + ChatFinishReason.Stop, + + _ => ChatFinishReason.ContentFilter, + }; + } + + /// Creates a populated from the supplied . + private static UsageDetails ExtractUsageDetails(GenerateContentResponseUsageMetadata usageMetadata) + { + UsageDetails details = new() + { + InputTokenCount = usageMetadata.PromptTokenCount, + OutputTokenCount = usageMetadata.CandidatesTokenCount, + TotalTokenCount = usageMetadata.TotalTokenCount, + }; + + AddIfPresent(nameof(usageMetadata.CachedContentTokenCount), usageMetadata.CachedContentTokenCount); + AddIfPresent(nameof(usageMetadata.ThoughtsTokenCount), usageMetadata.ThoughtsTokenCount); + AddIfPresent(nameof(usageMetadata.ToolUsePromptTokenCount), usageMetadata.ToolUsePromptTokenCount); + + return details; + + void AddIfPresent(string key, int? value) + { + if (value is int i) + { + (details.AdditionalCounts ??= new())[key] = i; + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GoogleGenAIExtensions.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GoogleGenAIExtensions.cs new file mode 100644 index 00000000000..b1044fa3731 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GoogleGenAIExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Google.Apis.Util; +using Google.GenAI; + +namespace Microsoft.Extensions.AI; + +/// Provides implementations of Microsoft.Extensions.AI abstractions based on . +public static class GoogleGenAIExtensions +{ + /// + /// Creates an wrapper around the specified . + /// + /// The to wrap. + /// The default model ID to use for chat requests if not specified in . + /// An that wraps the specified client. + /// is . + public static IChatClient AsIChatClient(this Client client, string? defaultModelId = null) + { + Utilities.ThrowIfNull(client, nameof(client)); + return new GoogleGenAIChatClient(client, defaultModelId); + } + + /// + /// Creates an wrapper around the specified . + /// + /// The client to wrap. + /// The default model ID to use for chat requests if not specified in . + /// An that wraps the specified client. + /// is . + public static IChatClient AsIChatClient(this Models models, string? defaultModelId = null) + { + Utilities.ThrowIfNull(models, nameof(models)); + return new GoogleGenAIChatClient(models, defaultModelId); + } +} diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Program.cs new file mode 100644 index 00000000000..db633dc47d3 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use an AI agent with Google Gemini + +using Google.GenAI; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Mscc.GenerativeAI.Microsoft; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +string apiKey = Environment.GetEnvironmentVariable("GOOGLE_GENAI_API_KEY") ?? throw new InvalidOperationException("Please set the GOOGLE_GENAI_API_KEY environment variable."); +string model = Environment.GetEnvironmentVariable("GOOGLE_GENAI_MODEL") ?? "gemini-2.5-fast"; + +// Using a Google GenAI IChatClient implementation +// Until the PR https://github.com/googleapis/dotnet-genai/pull/81 is not merged this option +// requires usage of also both GeminiChatClient.cs and GoogleGenAIExtensions.cs polyfills to work. + +ChatClientAgent agentGenAI = new( + new Client(vertexAI: false, apiKey: apiKey).AsIChatClient(model), + name: JokerName, + instructions: JokerInstructions); + +AgentRunResponse response = await agentGenAI.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine($"Google GenAI client based agent response:\n{response}"); + +// Using a community driven Mscc.GenerativeAI.Microsoft package + +ChatClientAgent agentCommunity = new( + new GeminiChatClient(apiKey, model), + name: JokerName, + instructions: JokerInstructions); + +response = await agentCommunity.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine($"Community client based agent response:\n{response}"); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/README.md new file mode 100644 index 00000000000..bc3a3592e6c --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/README.md @@ -0,0 +1,37 @@ +# Creating an AIAgent with Google Gemini + +This sample demonstrates how to create an AIAgent using Google Gemini models as the underlying inference service. + +The sample showcases two different `IChatClient` implementations: + +1. **Google GenAI** - Using the official [Google.GenAI](https://www.nuget.org/packages/Google.GenAI) package +2. **Mscc.GenerativeAI.Microsoft** - Using the community-driven [Mscc.GenerativeAI.Microsoft](https://www.nuget.org/packages/Mscc.GenerativeAI.Microsoft) package + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- Google AI Studio API key (get one at [Google AI Studio](https://aistudio.google.com/apikey)) + +Set the following environment variables: + +```powershell +$env:GOOGLE_GENAI_API_KEY="your-google-api-key" # Replace with your Google AI Studio API key +$env:GOOGLE_GENAI_MODEL="gemini-2.5-fast" # Optional, defaults to gemini-2.5-fast +``` + +## Package Options + +### Google GenAI (Official) + +The official Google GenAI package provides direct access to Google's Generative AI models. This sample uses an extension method to convert the Google client to an `IChatClient`. + +> [!NOTE] +> Until PR [googleapis/dotnet-genai#81](https://github.com/googleapis/dotnet-genai/pull/81) is merged, this option requires the additional `GeminiChatClient.cs` and `GoogleGenAIExtensions.cs` files included in this sample. +> +> We appreciate any community push by liking and commenting in the above PR to get it merged and release as part of official Google GenAI package. + +### Mscc.GenerativeAI.Microsoft (Community) + +The community-driven Mscc.GenerativeAI.Microsoft package provides a ready-to-use `IChatClient` implementation for Google Gemini models through the `GeminiChatClient` class. diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Agent_With_ONNX.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Agent_With_ONNX.csproj index c4a9467179c..61acc80e9cb 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Agent_With_ONNX.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Agent_With_ONNX.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/README.md index cb86e0d7c49..d97b0075ac6 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/README.md @@ -4,7 +4,7 @@ WARNING: ONNX doesn't support function calling, so any function tools passed to Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - An ONNX model downloaded to your machine You can download an ONNX model from hugging face, using git clone: diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Agent_With_Ollama.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Agent_With_Ollama.csproj index 1ad175831bf..c538cbedd1b 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Agent_With_Ollama.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Agent_With_Ollama.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/README.md index be76a75de01..d448f31d659 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Docker installed and running on your machine - An Ollama model downloaded into Ollama diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj index 0629a84bd01..eeda3eef6f4 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Agent_With_OpenAIAssistants.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/README.md index 22a4bae18c6..ad2b8e14d96 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/README.md @@ -5,7 +5,7 @@ For more information see the OpenAI documentation: https://platform.openai.com/d Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - OpenAI API key Set the following environment variables: diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj index 0629a84bd01..4ea7a45b8a6 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs index 9b03c989e1d..331109fba9e 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs @@ -3,6 +3,7 @@ // This sample shows how to create and use a simple AI agent with OpenAI Chat Completion as the backend. using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; using OpenAI; var apiKey = Environment.GetEnvironmentVariable("OPENAI_APIKEY") ?? throw new InvalidOperationException("OPENAI_APIKEY is not set."); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/README.md index 80b63e7cd0d..4df942f676d 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - OpenAI api key Set the following environment variables: diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj index 0629a84bd01..eeda3eef6f4 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/README.md index 80b63e7cd0d..4df942f676d 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - OpenAI api key Set the following environment variables: diff --git a/dotnet/samples/GettingStarted/AgentProviders/README.md b/dotnet/samples/GettingStarted/AgentProviders/README.md index 5d32f2542b7..964e560c9ac 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/README.md @@ -15,6 +15,7 @@ See the README.md for each sample for the prerequisites for that sample. |Sample|Description| |---|---| |[Creating an AIAgent with A2A](./Agent_With_A2A/)|This sample demonstrates how to create AIAgent for an existing A2A agent.| +|[Creating an AIAgent with Anthropic](./Agent_With_Anthropic/)|This sample demonstrates how to create an AIAgent using Anthropic Claude models as the underlying inference service| |[Creating an AIAgent with Foundry Agents using Azure.AI.Agents.Persistent](./Agent_With_AzureAIAgentsPersistent/)|This sample demonstrates how to create a Foundry Persistent agent and expose it as an AIAgent using the Azure.AI.Agents.Persistent SDK| |[Creating an AIAgent with Foundry Agents using Azure.AI.Project](./Agent_With_AzureAIProject/)|This sample demonstrates how to create an Foundry Project agent and expose it as an AIAgent using the Azure.AI.Project SDK| |[Creating an AIAgent with AzureFoundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Azure Foundry to create an AIAgent| diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Agent_Anthropic_Step01_Running.csproj b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Agent_Anthropic_Step01_Running.csproj new file mode 100644 index 00000000000..09359c5e781 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Agent_Anthropic_Step01_Running.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs new file mode 100644 index 00000000000..cf7e29c2fe9 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent with Anthropic as the backend. + +using Anthropic; +using Anthropic.Core; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is not set."); +var model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-haiku-4-5"; + +AIAgent agent = new AnthropicClient(new ClientOptions { APIKey = apiKey }) + .CreateAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker"); + +// Invoke the agent and output the text result. +var response = await agent.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine(response); + +// Invoke the agent with streaming support. +await foreach (var update in agent.RunStreamingAsync("Tell me a joke about a pirate.")) +{ + Console.WriteLine(update); +} diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/README.md b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/README.md new file mode 100644 index 00000000000..4800650bd93 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/README.md @@ -0,0 +1,43 @@ +# Running a simple agent with Anthropic + +This sample demonstrates how to create and run a basic agent with Anthropic Claude models. + +## What this sample demonstrates + +- Creating an AI agent with Anthropic Claude +- Running a simple agent with instructions +- Managing agent lifecycle + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Anthropic API key configured + +**Note**: This sample uses Anthropic Claude models. For more information, see [Anthropic documentation](https://docs.anthropic.com/). + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key +$env:ANTHROPIC_MODEL="your-anthropic-model" # Replace with your Anthropic model +``` + +## Run the sample + +Navigate to the AgentWithAnthropic sample directory and run: + +```powershell +cd dotnet\samples\GettingStarted\AgentWithAnthropic +dotnet run --project .\Agent_Anthropic_Step01_Running +``` + +## Expected behavior + +The sample will: + +1. Create an agent with Anthropic Claude +2. Run the agent with a simple prompt +3. Display the agent's response + diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Agent_Anthropic_Step02_Reasoning.csproj b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Agent_Anthropic_Step02_Reasoning.csproj new file mode 100644 index 00000000000..fc0914f1fc1 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Agent_Anthropic_Step02_Reasoning.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Program.cs b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Program.cs new file mode 100644 index 00000000000..d362a9dd0d6 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Program.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use an AI agent with reasoning capabilities. + +using Anthropic; +using Anthropic.Core; +using Anthropic.Models.Messages; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is not set."); +var model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-haiku-4-5"; +var maxTokens = 4096; +var thinkingTokens = 2048; + +var agent = new AnthropicClient(new ClientOptions { APIKey = apiKey }) + .CreateAIAgent( + model: model, + clientFactory: (chatClient) => chatClient + .AsBuilder() + .ConfigureOptions( + options => options.RawRepresentationFactory = (_) => new MessageCreateParams() + { + Model = options.ModelId ?? model, + MaxTokens = options.MaxOutputTokens ?? maxTokens, + Messages = [], + Thinking = new ThinkingConfigParam(new ThinkingConfigEnabled(budgetTokens: thinkingTokens)) + }) + .Build()); + +Console.WriteLine("1. Non-streaming:"); +var response = await agent.RunAsync("Solve this problem step by step: If a train travels 60 miles per hour and needs to cover 180 miles, how long will the journey take? Show your reasoning."); + +Console.WriteLine("#### Start Thinking ####"); +Console.WriteLine($"\e[92m{string.Join("\n", response.Messages.SelectMany(m => m.Contents.OfType().Select(c => c.Text)))}\e[0m"); +Console.WriteLine("#### End Thinking ####"); + +Console.WriteLine("\n#### Final Answer ####"); +Console.WriteLine(response.Text); + +Console.WriteLine("Token usage:"); +Console.WriteLine($"Input: {response.Usage?.InputTokenCount}, Output: {response.Usage?.OutputTokenCount}, {string.Join(", ", response.Usage?.AdditionalCounts ?? [])}"); +Console.WriteLine(); + +Console.WriteLine("2. Streaming"); +await foreach (var update in agent.RunStreamingAsync("Explain the theory of relativity in simple terms.")) +{ + foreach (var item in update.Contents) + { + if (item is TextReasoningContent reasoningContent) + { + Console.WriteLine($"\e[92m{reasoningContent.Text}\e[0m"); + } + else if (item is TextContent textContent) + { + Console.WriteLine(textContent.Text); + } + } +} diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/README.md b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/README.md new file mode 100644 index 00000000000..ae088b23867 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/README.md @@ -0,0 +1,46 @@ +# Using reasoning with Anthropic agents + +This sample demonstrates how to use extended thinking/reasoning capabilities with Anthropic Claude agents. + +## What this sample demonstrates + +- Creating an AI agent with Anthropic Claude extended thinking +- Using reasoning capabilities for complex problem solving +- Extracting thinking and response content from agent output +- Managing agent lifecycle + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Anthropic API key configured +- Access to Anthropic Claude models with extended thinking support + +**Note**: This sample uses Anthropic Claude models with extended thinking. For more information, see [Anthropic documentation](https://docs.anthropic.com/). + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key +$env:ANTHROPIC_MODEL="your-anthropic-model" # Replace with your Anthropic model +``` + +## Run the sample + +Navigate to the AgentWithAnthropic sample directory and run: + +```powershell +cd dotnet\samples\GettingStarted\AgentWithAnthropic +dotnet run --project .\Agent_Anthropic_Step02_Reasoning +``` + +## Expected behavior + +The sample will: + +1. Create an agent with Anthropic Claude extended thinking enabled +2. Run the agent with a complex reasoning prompt +3. Display the agent's thinking process +4. Display the agent's final response + diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Agent_Anthropic_Step03_UsingFunctionTools.csproj b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Agent_Anthropic_Step03_UsingFunctionTools.csproj new file mode 100644 index 00000000000..fdb9a2f50f9 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Agent_Anthropic_Step03_UsingFunctionTools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Program.cs new file mode 100644 index 00000000000..a56db8d4a22 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Program.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use an agent with function tools. +// It shows both non-streaming and streaming agent interactions using weather-related tools. + +using System.ComponentModel; +using Anthropic; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is not set."); +var model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-haiku-4-5"; + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +const string AssistantInstructions = "You are a helpful assistant that can get weather information."; +const string AssistantName = "WeatherAssistant"; + +// Define the agent with function tools. +AITool tool = AIFunctionFactory.Create(GetWeather); + +// Get anthropic client to create agents. +AIAgent agent = new AnthropicClient { APIKey = apiKey } + .CreateAIAgent(model: model, instructions: AssistantInstructions, name: AssistantName, tools: [tool]); + +// Non-streaming agent interaction with function tools. +AgentThread thread = agent.GetNewThread(); +Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", thread)); + +// Streaming agent interaction with function tools. +thread = agent.GetNewThread(); +await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync("What is the weather like in Amsterdam?", thread)) +{ + Console.WriteLine(update); +} diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/README.md b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/README.md new file mode 100644 index 00000000000..6c905864ef7 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/README.md @@ -0,0 +1,47 @@ +# Using Function Tools with Anthropic agents + +This sample demonstrates how to use function tools with Anthropic Claude agents, allowing agents to call custom functions to retrieve information. + +## What this sample demonstrates + +- Creating function tools using AIFunctionFactory +- Passing function tools to an Anthropic Claude agent +- Running agents with function tools (text output) +- Running agents with function tools (streaming output) +- Managing agent lifecycle + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Anthropic API key configured + +**Note**: This sample uses Anthropic Claude models. For more information, see [Anthropic documentation](https://docs.anthropic.com/). + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key +$env:ANTHROPIC_MODEL="your-anthropic-model" # Replace with your Anthropic model +``` + +## Run the sample + +Navigate to the AgentWithAnthropic sample directory and run: + +```powershell +cd dotnet\samples\GettingStarted\AgentWithAnthropic +dotnet run --project .\Agent_Anthropic_Step03_UsingFunctionTools +``` + +## Expected behavior + +The sample will: + +1. Create an agent named "WeatherAssistant" with a GetWeather function tool +2. Run the agent with a text prompt asking about weather +3. The agent will invoke the GetWeather function tool to retrieve weather information +4. Run the agent again with streaming to display the response as it's generated +5. Clean up resources by deleting the agent + diff --git a/dotnet/samples/GettingStarted/AgentWithAnthropic/README.md b/dotnet/samples/GettingStarted/AgentWithAnthropic/README.md new file mode 100644 index 00000000000..44c15b384b3 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithAnthropic/README.md @@ -0,0 +1,72 @@ +# Getting started with agents using Anthropic + +The getting started with agents using Anthropic samples demonstrate the fundamental concepts and functionalities +of single agents using Anthropic as the AI provider. + +These samples use Anthropic Claude models as the AI provider and use ChatCompletion as the type of service. + +For other samples that demonstrate how to create and configure each type of agent that come with the agent framework, +see the [How to create an agent for each provider](../AgentProviders/README.md) samples. + +## Getting started with agents using Anthropic prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Anthropic API key configured +- User has access to Anthropic Claude models + +**Note**: These samples use Anthropic Claude models. For more information, see [Anthropic documentation](https://docs.anthropic.com/). + +## Using Anthropic with Azure Foundry + +To use Anthropic with Azure Foundry, you can check the sample [AgentProviders/Agent_With_Anthropic](../AgentProviders/Agent_With_Anthropic/README.md) for more details. + +## Samples + +|Sample|Description| +|---|---| +|[Running a simple agent](./Agent_Anthropic_Step01_Running/)|This sample demonstrates how to create and run a basic agent with Anthropic Claude| +|[Using reasoning with an agent](./Agent_Anthropic_Step02_Reasoning/)|This sample demonstrates how to use extended thinking/reasoning capabilities with Anthropic Claude agents| +|[Using function tools with an agent](./Agent_Anthropic_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with an Anthropic Claude agent| + +## Running the samples from the console + +To run the samples, navigate to the desired sample directory, e.g. + +```powershell +cd Agent_Anthropic_Step01_Running +``` + +Set the following environment variables: + +```powershell +$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key +``` + +If the variables are not set, you will be prompted for the values when running the samples. + +Execute the following command to build the sample: + +```powershell +dotnet build +``` + +Execute the following command to run the sample: + +```powershell +dotnet run --no-build +``` + +Or just build and run in one step: + +```powershell +dotnet run +``` + +## Running the samples from Visual Studio + +Open the solution in Visual Studio and set the desired sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. + +You will be prompted for any required environment variables if they are not already set. + diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj index 1caf270c49a..860089b6218 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs index 9e4c27cebb8..19e1fead1e6 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs @@ -32,7 +32,7 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions { - Instructions = "You are good at telling jokes.", + ChatOptions = new() { Instructions = "You are good at telling jokes." }, Name = "Joker", AIContextProviderFactory = (ctx) => new ChatHistoryMemoryProvider( vectorStore, diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj index 9d7aa41a99c..1e0863d66fe 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs index 539ebbaecb9..87f5842e2c9 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs @@ -30,8 +30,8 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions() { - Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.", - AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined + ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." }, + AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined // If each thread should have its own Mem0 scope, you can create a new id per thread here: // ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() }) // In this case we are storing memories scoped by application and user instead so that memories are retained across threads. diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs index ad59deb97fd..4b9b1866a9b 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs @@ -33,7 +33,7 @@ // and its storage to that user id. AIAgent agent = chatClient.CreateAIAgent(new ChatClientAgentOptions() { - Instructions = "You are a friendly assistant. Always address the user by their name.", + ChatOptions = new() { Instructions = "You are a friendly assistant. Always address the user by their name." }, AIContextProviderFactory = ctx => new UserInfoMemory(chatClient.AsIChatClient(), ctx.SerializedState, ctx.JsonSerializerOptions) }); diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj index 0629a84bd01..eeda3eef6f4 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj index 4253d9cf9e8..78f09816769 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj index 0c8a9f2dfc7..860089b6218 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs index ec665325a7e..a30e0371a06 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs @@ -62,7 +62,7 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions { - Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." }, AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs index 502c17dba16..82559ecf83c 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStore.cs @@ -98,8 +98,8 @@ public TextSearchStore( // Create a definition so that we can use the dimensions provided at runtime. VectorStoreCollectionDefinition ragDocumentDefinition = new() { - Properties = new List() - { + Properties = + [ new VectorStoreKeyProperty("Key", this._options.KeyType ?? typeof(string)), new VectorStoreDataProperty("Namespaces", typeof(List)) { IsIndexed = true }, new VectorStoreDataProperty("SourceId", typeof(string)) { IsIndexed = true }, @@ -107,7 +107,7 @@ public TextSearchStore( new VectorStoreDataProperty("SourceName", typeof(string)), new VectorStoreDataProperty("SourceLink", typeof(string)), new VectorStoreVectorProperty("TextEmbedding", typeof(string), vectorDimensions), - } + ] }; this._vectorStoreRecordCollection = this._vectorStore.GetDynamicCollection(collectionName, ragDocumentDefinition); @@ -267,7 +267,7 @@ public async Task> SearchAsync(string query, int cancellationToken: cancellationToken); // Retrieve the documents from the search results. - List> searchResponseDocs = new(); + List> searchResponseDocs = []; await foreach (var searchResponseDoc in searchResult.WithCancellation(cancellationToken).ConfigureAwait(false)) { searchResponseDocs.Add(searchResponseDoc.Record); @@ -291,12 +291,8 @@ public async Task> SearchAsync(string query, int } // Retrieve the source text for the documents that need it. - var retrievalResponses = await this._options.SourceRetrievalCallback(sourceIdsToRetrieve).ConfigureAwait(false); - - if (retrievalResponses is null) - { + var retrievalResponses = await this._options.SourceRetrievalCallback(sourceIdsToRetrieve).ConfigureAwait(false) ?? throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} must return a non-null value."); - } // Update the retrieved documents with the retrieved text. return searchResponseDocs.GroupJoin( diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs index 53da092c825..d9b8761be63 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/TextSearchStore/TextSearchStoreOptions.cs @@ -107,15 +107,8 @@ public sealed class SourceRetrievalResponse /// The source text that was retrieved. public SourceRetrievalResponse(SourceRetrievalRequest request, string text) { - if (request == null) - { - throw new ArgumentNullException(nameof(request)); - } - - if (text == null) - { - throw new ArgumentNullException(nameof(text)); - } + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(text); this.SourceId = request.SourceId; this.SourceLink = request.SourceLink; diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj index 56e2ad232b0..33029395dd9 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs index 89ced52b69e..89312f85973 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs @@ -71,7 +71,7 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions { - Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.", + ChatOptions = new() { Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief." }, AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md index 1817f0d8ca9..131adde82bb 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md @@ -6,7 +6,7 @@ This sample uses Qdrant for the vector store, but this can easily be swapped out ## Prerequisites -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint - Both a chat completion and embedding deployment configured in the Azure OpenAI resource - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs index 38bc2e09f39..5e7b2c41329 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs @@ -29,7 +29,7 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions { - Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." }, AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions) }); @@ -48,7 +48,7 @@ { // The mock search inspects the user's question and returns pre-defined snippets // that resemble documents stored in an external knowledge source. - List results = new(); + List results = []; if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase)) { diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj index aefb46524f8..d90e1c394b6 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Agent_Step01_Running.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Agent_Step01_Running.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Agent_Step01_Running.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Agent_Step01_Running.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Agent_Step02_MultiturnConversation.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Agent_Step02_MultiturnConversation.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Agent_Step02_MultiturnConversation.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Agent_Step02_MultiturnConversation.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs index b18d8e2d847..ef1849fe02c 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs @@ -22,7 +22,7 @@ .GetChatClient(deploymentName); // Create the ChatClientAgent with the specified name and instructions. -ChatClientAgent agent = chatClient.CreateAIAgent(new ChatClientAgentOptions(name: "HelpfulAssistant", instructions: "You are a helpful assistant.")); +ChatClientAgent agent = chatClient.CreateAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); // Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input. AgentRunResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); @@ -34,12 +34,10 @@ Console.WriteLine($"Occupation: {response.Result.Occupation}"); // Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce. -ChatClientAgent agentWithPersonInfo = chatClient.CreateAIAgent(new ChatClientAgentOptions(name: "HelpfulAssistant", instructions: "You are a helpful assistant.") +ChatClientAgent agentWithPersonInfo = chatClient.CreateAIAgent(new ChatClientAgentOptions() { - ChatOptions = new() - { - ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() - } + Name = "HelpfulAssistant", + ChatOptions = new() { Instructions = "You are a helpful assistant.", ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() } }); // Invoke the agent with some unstructured input while streaming, to extract the structured information from. diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs index 1ffe3c99935..559fc03d8c6 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs @@ -32,7 +32,7 @@ await File.WriteAllTextAsync(tempFilePath, JsonSerializer.Serialize(serializedThread)); // Load the serialized thread from the temporary file (for demonstration purposes). -JsonElement reloadedSerializedThread = JsonSerializer.Deserialize(await File.ReadAllTextAsync(tempFilePath)); +JsonElement reloadedSerializedThread = JsonElement.Parse(await File.ReadAllTextAsync(tempFilePath)); // Deserialize the thread state after loading from storage. AgentThread resumedThread = agent.DeserializeThread(reloadedSerializedThread); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Agent_Step07_3rdPartyThreadStorage.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Agent_Step07_3rdPartyThreadStorage.csproj index 1caf270c49a..860089b6218 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Agent_Step07_3rdPartyThreadStorage.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Agent_Step07_3rdPartyThreadStorage.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index 89867349721..d1316b6c803 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -28,7 +28,7 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions { - Instructions = "You are good at telling jokes.", + ChatOptions = new() { Instructions = "You are good at telling jokes." }, Name = "Joker", ChatMessageStoreFactory = ctx => { diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj index 980e2826410..1a618d660af 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Agent_Step09_DependencyInjection.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Agent_Step09_DependencyInjection.csproj index b0890e1817f..0aaa4712602 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Agent_Step09_DependencyInjection.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Agent_Step09_DependencyInjection.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs index 894c034eb0c..d1b75d2fe5d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs @@ -18,8 +18,7 @@ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); // Add agent options to the service collection. -builder.Services.AddSingleton( - new ChatClientAgentOptions(instructions: "You are good at telling jokes.", name: "Joker")); +builder.Services.AddSingleton(new ChatClientAgentOptions() { Name = "Joker", ChatOptions = new() { Instructions = "You are good at telling jokes." } }); // Add a chat client to the service collection. builder.Services.AddKeyedChatClient("AzureOpenAI", (sp) => new AzureOpenAIClient( diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj index 1fb367c0443..db776afd1ed 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -14,7 +14,6 @@ - diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj index 7e9e70c763b..73a41005f14 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj index 21c8d9e49ea..26600904041 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj index 4735f4a7a09..29fab5f992f 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs index f2a3bdf5c0a..83a97d76c55 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs @@ -44,7 +44,7 @@ await Task.Delay(TimeSpan.FromSeconds(10)); - RestoreAgentState(agent, out thread, out object? continuationToken); + RestoreAgentState(agent, out thread, out ResponseContinuationToken? continuationToken); options.ContinuationToken = continuationToken; response = await agent.RunAsync(thread, options); @@ -52,19 +52,19 @@ Console.WriteLine(response.Text); -void PersistAgentState(AgentThread thread, object? continuationToken) +void PersistAgentState(AgentThread thread, ResponseContinuationToken? continuationToken) { stateStore["thread"] = thread.Serialize(); stateStore["continuationToken"] = JsonSerializer.SerializeToElement(continuationToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); } -void RestoreAgentState(AIAgent agent, out AgentThread thread, out object? continuationToken) +void RestoreAgentState(AIAgent agent, out AgentThread thread, out ResponseContinuationToken? continuationToken) { JsonElement serializedThread = stateStore["thread"] ?? throw new InvalidOperationException("No serialized thread found in state store."); JsonElement? serializedToken = stateStore["continuationToken"]; thread = agent.DeserializeThread(serializedThread); - continuationToken = serializedToken?.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + continuationToken = (ResponseContinuationToken?)serializedToken?.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); } [Description("Researches relevant space facts and scientific information for writing a science fiction novel")] diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/README.md index 146f4185125..ca52e8afa31 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/README.md +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/README.md @@ -14,7 +14,7 @@ For more information, see the [official documentation](https://learn.microsoft.c Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj index 09beb78195c..6582c30cd52 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs index 28a50cc7d7d..a0ca3382970 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs @@ -154,10 +154,11 @@ static IList FilterMessages(IEnumerable messages) static string FilterPii(string content) { // Regex patterns for PII detection (simplified for demonstration) - Regex[] piiPatterns = [ + Regex[] piiPatterns = + [ new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) - new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address - new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address + new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) ]; foreach (var pattern in piiPatterns) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj index c1cf0bf930e..ae2f9ac1945 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj index 8298cfe6e8e..0f9de7c3599 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs index 590b5308d5f..04704b5da0d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs @@ -21,7 +21,7 @@ .GetChatClient(deploymentName) .CreateAIAgent(new ChatClientAgentOptions { - Instructions = "You are good at telling jokes.", + ChatOptions = new() { Instructions = "You are good at telling jokes." }, Name = "Joker", ChatMessageStoreFactory = ctx => new InMemoryChatMessageStore(new MessageCountingChatReducer(2), ctx.SerializedState, ctx.JsonSerializerOptions) }); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj index c5b2ae56a60..1c95b4af256 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/README.md index 5b7df74ca99..e898733bc3e 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/README.md +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/README.md @@ -13,7 +13,7 @@ For more information, see the [official documentation](https://learn.microsoft.c Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj index 11c7beb3bf7..d40e93232b9 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj new file mode 100644 index 00000000000..550e1f22cb8 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs new file mode 100644 index 00000000000..1fc985b3bb4 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create an agent from a YAML based declarative representation. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +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"; + +// Create the chat client +IChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .AsIChatClient(); + +// Define the agent using a YAML definition. +var text = + """ + kind: Prompt + name: Assistant + description: Helpful assistant + instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. + model: + options: + temperature: 0.9 + topP: 0.95 + outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + """; + +// Create the agent from the YAML definition. +var agentFactory = new ChatClientPromptAgentFactory(chatClient); +var agent = await agentFactory.CreateFromYamlAsync(text); + +// Invoke the agent and output the text result. +Console.WriteLine(await agent!.RunAsync("Tell me a joke about a pirate in English.")); + +// Invoke the agent with streaming support. +await foreach (var update in agent!.RunStreamingAsync("Tell me a joke about a pirate in French.")) +{ + Console.WriteLine(update); +} diff --git a/dotnet/samples/GettingStarted/Agents/README.md b/dotnet/samples/GettingStarted/Agents/README.md index f510b03fafe..d023d6455c0 100644 --- a/dotnet/samples/GettingStarted/Agents/README.md +++ b/dotnet/samples/GettingStarted/Agents/README.md @@ -13,7 +13,7 @@ see the [How to create an agent for each provider](../AgentProviders/README.md) Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. @@ -45,6 +45,7 @@ Before you begin, ensure you have the following prerequisites: |[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally| |[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support| |[Deep research with an agent](./Agent_Step18_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics| +|[Declarative agent](./Agent_Step19_Declarative/)|This sample demonstrates how to declaratively define an agent.| ## Running the samples from the console diff --git a/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj new file mode 100644 index 00000000000..0fc316acaca --- /dev/null +++ b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs new file mode 100644 index 00000000000..bed16f496ac --- /dev/null +++ b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to load an AI agent from a YAML file and process a prompt using Azure OpenAI as the backend. + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +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"; + +// Create the chat client +IChatClient chatClient = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .AsIChatClient(); + +// Read command-line arguments +if (args.Length < 2) +{ + Console.WriteLine("Usage: DeclarativeAgents "); + Console.WriteLine(" : The path to the YAML file containing the agent definition"); + Console.WriteLine(" : The prompt to send to the agent"); + return; +} + +var yamlFilePath = args[0]; +var prompt = args[1]; + +// Verify the YAML file exists +if (!File.Exists(yamlFilePath)) +{ + Console.WriteLine($"Error: File not found: {yamlFilePath}"); + return; +} + +// Read the YAML content from the file +var text = await File.ReadAllTextAsync(yamlFilePath); + +// Example function tool that can be used by the agent. +[Description("Get the weather for a given location.")] +static string GetWeather( + [Description("The city and state, e.g. San Francisco, CA")] string location, + [Description("The unit of temperature. Possible values are 'celsius' and 'fahrenheit'.")] string unit) + => $"The weather in {location} is cloudy with a high of {(unit.Equals("celsius", StringComparison.Ordinal) ? "15°C" : "59°F")}."; + +// Create the agent from the YAML definition. +var agentFactory = new ChatClientPromptAgentFactory(chatClient, [AIFunctionFactory.Create(GetWeather, "GetWeather")]); +var agent = await agentFactory.CreateFromYamlAsync(text); + +// Invoke the agent and output the text result. +Console.WriteLine(await agent!.RunAsync(prompt)); diff --git a/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Properties/launchSettings.json b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Properties/launchSettings.json new file mode 100644 index 00000000000..5ec486626ce --- /dev/null +++ b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "GetWeather": { + "commandName": "Project", + "commandLineArgs": "..\\..\\..\\..\\..\\..\\..\\..\\agent-samples\\chatclient\\GetWeather.yaml \"What is the weather in Cambridge, MA in °C?\"" + }, + "Assistant": { + "commandName": "Project", + "commandLineArgs": "..\\..\\..\\..\\..\\..\\..\\..\\agent-samples\\chatclient\\Assistant.yaml \"Tell me a joke about a pirate in Italian.\"" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj index 8ae36b52e0a..09037b5f1d7 100644 --- a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj +++ b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable DevUI_Step01_BasicUsage @@ -19,7 +19,6 @@ - diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj index a2ccc2a339a..89b9d8ddc0a 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs index 3c374d799f5..9a7ee0736a9 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs @@ -11,19 +11,17 @@ string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerInstructionsV1 = "You are good at telling jokes."; -const string JokerInstructionsV2 = "You are extremely hilarious at telling jokes."; const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); // Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructionsV1 }); +AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); // Azure.AI.Agents SDK creates and manages agent by name and versions. // You can create a server side agent version with the Azure.AI.Agents SDK client below. -AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); +AgentVersion createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); // Note: // agentVersion.Id = ":", @@ -31,20 +29,20 @@ // agentVersion.Name = // You can retrieve an AIAgent for an already created server side agent version. -AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion); +AIAgent existingJokerAgent = aiProjectClient.GetAIAgent(createdAgentVersion); -// You can also create another AIAgent version (V2) by providing the same name with a different definition/instruction. -AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructionsV2); +// You can also create another AIAgent version by providing the same name with a different definition/instruction. +AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes."); // You can also get the AIAgent latest version by just providing its name. AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName); -AgentVersion latestVersion = jokerAgentLatest.GetService()!; +AgentVersion latestAgentVersion = jokerAgentLatest.GetService()!; // The AIAgent version can be accessed via the GetService method. -Console.WriteLine($"Latest agent version id: {latestVersion.Id}"); +Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); // Once you have the AIAgent, you can invoke it like any other AIAgent. Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.")); -// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2). -await aiProjectClient.Agents.DeleteAgentAsync(jokerAgentV1.Name); +// Cleanup by agent name removes both agent versions created. +await aiProjectClient.Agents.DeleteAgentAsync(existingJokerAgent.Name); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md index 6a22b1df811..ce56e057557 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md @@ -10,7 +10,7 @@ This sample demonstrates how to create and manage AI agents with Azure Foundry A Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj index 3ed207aadfd..daf7e244940 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md index 26725e016e7..53254e19754 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/README.md @@ -13,7 +13,7 @@ This sample demonstrates how to create and run a simple AI agent with Azure Foun Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj index 3ed207aadfd..daf7e244940 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md index 2c38002f500..dab9f596db0 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to implement multi-turn conversations with AI agent Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj index 3ed207aadfd..daf7e244940 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md index 934373aa809..35bef8a9992 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to use function tools with AI agents, allowing agen Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj index 3ed207aadfd..daf7e244940 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md index 55aac6c8df8..5a797acd0f3 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to use function tools that require human approval b Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj index 3ed207aadfd..daf7e244940 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs index 0edbed70e83..ac055658366 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs @@ -24,10 +24,12 @@ // Create ChatClientAgent directly ChatClientAgent agent = await aiProjectClient.CreateAIAgentAsync( model: deploymentName, - new ChatClientAgentOptions(name: AssistantName, instructions: AssistantInstructions) + new ChatClientAgentOptions() { + Name = AssistantName, ChatOptions = new() { + Instructions = AssistantInstructions, ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() } }); @@ -44,10 +46,12 @@ // Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce. ChatClientAgent agentWithPersonInfo = aiProjectClient.CreateAIAgent( model: deploymentName, - new ChatClientAgentOptions(name: AssistantName, instructions: AssistantInstructions) + new ChatClientAgentOptions() { + Name = AssistantName, ChatOptions = new() { + Instructions = AssistantInstructions, ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() } }); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md index 57887fc0d3d..956a2542e97 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to configure AI agents to produce structured output Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj index 3ed207aadfd..daf7e244940 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs index 305422aa4d2..d404a814c0c 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs @@ -32,7 +32,7 @@ await File.WriteAllTextAsync(tempFilePath, JsonSerializer.Serialize(serializedThread)); // Load the serialized thread from the temporary file (for demonstration purposes). -JsonElement reloadedSerializedThread = JsonSerializer.Deserialize(await File.ReadAllTextAsync(tempFilePath))!; +JsonElement reloadedSerializedThread = JsonElement.Parse(await File.ReadAllTextAsync(tempFilePath))!; // Deserialize the thread state after loading from storage. AgentThread resumedThread = agent.DeserializeThread(reloadedSerializedThread); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md index f0ae5905456..29c22337486 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to serialize and persist agent conversation threads Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj index 49b903d0416..5ceeabb2042 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md index 57d4e5df139..30f7014dff4 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/README.md @@ -15,7 +15,7 @@ This sample demonstrates how to add observability to AI agents using OpenTelemet Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) - (Optional) Application Insights connection string for Azure Monitor integration diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj index ea8fc63de01..f1812befeb2 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md index ab2b01e5d14..580821bb0a9 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md @@ -15,7 +15,7 @@ This sample demonstrates how to use dependency injection to register and manage Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj index 0ee9c80764e..a6d96cb3db2 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md index 9b3322b3fbd..b2d923fc2fa 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to use Model Context Protocol (MCP) client tools wi Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) - Node.js and npm installed (for running the GitHub MCP server) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj index 1d423b22bca..53661ff1999 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj index f9336d45566..54f37f1aa6c 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md index 3702134ab39..4b64b7e7125 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to expose an AI agent as a function tool, enabling Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj index 4de5d131d9a..9f29a8d7e60 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md index 1f7321051f6..04192a2cc65 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md @@ -21,7 +21,7 @@ Attempting to use function middleware on agents that do not wrap a ChatClientAge Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj index 1c8496b2391..4a345609467 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md index d086e28aa0a..0aeccf57894 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to use plugins with AI agents, where plugins are se Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj index 1c8496b2391..4a345609467 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md index 007f283a760..a3dd4d50b9f 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md @@ -15,7 +15,7 @@ This sample demonstrates how to use the code interpreter tool with AI agents. Th Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj index 383b55a939e..041c72c43e5 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md index 94ddf6b2693..4686ec5984d 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md @@ -15,7 +15,7 @@ This sample demonstrates how to use the computer use tool with AI agents. The co Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/README.md b/dotnet/samples/GettingStarted/FoundryAgents/README.md index c5d027d8d76..daeb2db8df8 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/README.md @@ -6,11 +6,20 @@ of Azure Foundry Agents and can be used with Azure Foundry as the AI provider. These samples showcase how to work with agents managed through Azure Foundry, including agent creation, versioning, multi-turn conversations, and advanced features like code interpretation and computer use. +## Classic vs New Foundry Agents + +> [!NOTE] +> Recently, Azure Foundry introduced a new and improved experience for creating and managing AI agents, which is the target of these samples. + +For more information about the previous classic agents and for what's new in Foundry Agents, see the [Foundry Agents migration documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry). + +For a sample demonstrating how to use classic Foundry Agents, see the following: [Agent with Azure AI Persistent](../AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md). + ## Getting started with Foundry Agents prerequisites Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and project configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj index c5e06bc3822..aa73860c141 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj index 389b504c508..46c13061496 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -15,7 +15,6 @@ - diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/README.md b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/README.md index ae88df95eef..a6505d65244 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/README.md +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/README.md @@ -17,7 +17,7 @@ The sample shows: ## Installing Prerequisites - A self-signed certificate to enable HTTPS use in development, see [dotnet dev-certs](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-dev-certs) -- .NET 9.0 or later +- .NET 10.0 or later - A running TestOAuthServer (for OAuth authentication), see [Start the Test OAuth Server](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/ProtectedMcpClient#step-1-start-the-test-oauth-server) - A running ProtectedMCPServer (for MCP services), see [Start the Protected MCP Server](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/ProtectedMcpClient#step-2-start-the-protected-mcp-server) @@ -38,7 +38,7 @@ First, you need to start the TestOAuthServer which provides OAuth authentication ```bash cd \tests\ModelContextProtocol.TestOAuthServer -dotnet run --framework net9.0 +dotnet run --framework net10.0 ``` The OAuth server will start at `https://localhost:7029` diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj index 11c7beb3bf7..d40e93232b9 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index f824f099910..123d666f093 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -34,9 +34,9 @@ options: new() { Name = "MicrosoftLearnAgent", - Instructions = "You answer questions by searching the Microsoft Learn content only.", ChatOptions = new() { + Instructions = "You answer questions by searching the Microsoft Learn content only.", Tools = [mcpTool] }, }); @@ -67,9 +67,9 @@ options: new() { Name = "MicrosoftLearnAgentWithApproval", - Instructions = "You answer questions by searching the Microsoft Learn content only.", ChatOptions = new() { + Instructions = "You answer questions by searching the Microsoft Learn content only.", Tools = [mcpToolWithApproval] }, }); diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md index e320a6c3d73..f3be7da5765 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure Foundry service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/README.md b/dotnet/samples/GettingStarted/ModelContextProtocol/README.md index 874afa28b84..be1aa835138 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/README.md +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/README.md @@ -6,7 +6,7 @@ The getting started with Model Content Protocol samples demonstrate how to use M Before you begin, ensure you have the following prerequisites: -- .NET 9.0 SDK or later +- .NET 10.0 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md index f84bd8f1b4f..c311edae403 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md @@ -2,7 +2,7 @@ Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) - User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource. diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj index 0eacdab258a..41aafe34372 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/README.md b/dotnet/samples/GettingStarted/README.md index 4e349c77427..7a46d81a62c 100644 --- a/dotnet/samples/GettingStarted/README.md +++ b/dotnet/samples/GettingStarted/README.md @@ -15,5 +15,6 @@ of the agent framework. |[A2A](./A2A/README.md)|Getting started with A2A (Agent-to-Agent) specific features| |[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents| |[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents| +|[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude| |[Workflow](./Workflows/README.md)|Getting started with Workflow| |[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol| diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj index 51b18bdeb22..881f20e1af3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs index 5d5369883cb..91f58f460e6 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs @@ -118,10 +118,11 @@ internal sealed class SloganWriterExecutor : Executor /// The chat client to use for the AI agent. public SloganWriterExecutor(string id, IChatClient chatClient) : base(id) { - ChatClientAgentOptions agentOptions = new(instructions: "You are a professional slogan writer. You will be given a task to create a slogan.") + ChatClientAgentOptions agentOptions = new() { ChatOptions = new() { + Instructions = "You are a professional slogan writer. You will be given a task to create a slogan.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }; @@ -193,10 +194,11 @@ internal sealed class FeedbackExecutor : Executor /// The chat client to use for the AI agent. public FeedbackExecutor(string id, IChatClient chatClient) : base(id) { - ChatClientAgentOptions agentOptions = new(instructions: "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.") + ChatClientAgentOptions agentOptions = new() { ChatOptions = new() { + Instructions = "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }; diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj index 888274205a7..f75c7fd28b8 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj index 51b18bdeb22..881f20e1af3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj index 0a0945caffb..2f410707596 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj index 0a0945caffb..2f410707596 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj index 0a0945caffb..2f410707596 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj index 3f3fe6d56ca..28a01e4540f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Concurrent.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/MapReduce.csproj b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/MapReduce.csproj index 7282e3fde4f..fd311b7be3a 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/MapReduce.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/MapReduce.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj index 17b1cb882ac..495f645f832 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs index b6e3d4d5136..0f762ea40d0 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -85,10 +85,11 @@ private static async Task Main() /// /// A ChatClientAgent configured for spam detection private static ChatClientAgent GetSpamDetectionAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are a spam detection assistant that identifies spam emails.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are a spam detection assistant that identifies spam emails.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); @@ -98,10 +99,11 @@ private static ChatClientAgent GetSpamDetectionAgent(IChatClient chatClient) => /// /// A ChatClientAgent configured for email assistance private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are an email assistant that helps users draft responses to emails with professionalism.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are an email assistant that helps users draft responses to emails with professionalism.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj index 17b1cb882ac..495f645f832 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs index 13f0a75bc2f..ccda3fa19e7 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs @@ -100,10 +100,11 @@ private static async Task Main() /// /// A ChatClientAgent configured for spam detection private static ChatClientAgent GetSpamDetectionAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are a spam detection assistant that identifies spam emails. Be less confident in your assessments.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are a spam detection assistant that identifies spam emails. Be less confident in your assessments.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); @@ -113,10 +114,11 @@ private static ChatClientAgent GetSpamDetectionAgent(IChatClient chatClient) => /// /// A ChatClientAgent configured for email assistance private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are an email assistant that helps users draft responses to emails with professionalism.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are an email assistant that helps users draft responses to emails with professionalism.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj index 17b1cb882ac..495f645f832 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs index 9d340cbae3b..49faff39da7 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs @@ -140,10 +140,11 @@ private static async Task Main() /// /// A ChatClientAgent configured for email analysis private static ChatClientAgent GetEmailAnalysisAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are a spam detection assistant that identifies spam emails.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are a spam detection assistant that identifies spam emails.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); @@ -153,10 +154,11 @@ private static ChatClientAgent GetEmailAnalysisAgent(IChatClient chatClient) => /// /// A ChatClientAgent configured for email assistance private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are an email assistant that helps users draft responses to emails with professionalism.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are an email assistant that helps users draft responses to emails with professionalism.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); @@ -166,10 +168,11 @@ private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) => /// /// A ChatClientAgent configured for email summarization private static ChatClientAgent GetEmailSummaryAgent(IChatClient chatClient) => - new(chatClient, new ChatClientAgentOptions(instructions: "You are an assistant that helps users summarize emails.") + new(chatClient, new ChatClientAgentOptions() { ChatOptions = new() { + Instructions = "You are an assistant that helps users summarize emails.", ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj index 3254317876b..da32d18b99b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj index 5ddd5705710..583dbc6e8fb 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/CustomerSupport.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj index 619c727b1ba..413fa562108 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj index ca7c10cde35..9725826c7ac 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj index 1fb6abe55dd..074a31121da 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable $(NoWarn);CA1812 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj index 888a48f5df7..f8a51cb0f20 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj index b10f7c5e95a..117e27abd85 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj index 1f57e7e7bc4..3cbd0ada956 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable $(NoWarn);CA1812 diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj index 51582438eb1..5ef0b7e99eb 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj index 12599a1b791..ceba7b740ba 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj index 7c210d6f96d..862e39bd992 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj index 6fa1cf12d94..1ebaa26645f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj @@ -2,9 +2,7 @@ Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj index 0a0945caffb..2f410707596 100644 --- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj b/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj index fcc2aaf5c8e..0de620de0cb 100644 --- a/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj index f7a5a4424f7..4c91a01fad3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -11,6 +11,9 @@ + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj b/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj index db5479dd0f0..57b34f3d69a 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -12,6 +12,9 @@ + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj index 2193722d261..3e27c6b3034 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable @@ -15,6 +15,9 @@ + + + diff --git a/dotnet/samples/GettingStarted/Workflows/SharedStates/SharedStates.csproj b/dotnet/samples/GettingStarted/Workflows/SharedStates/SharedStates.csproj index 2af5bbc1d75..35f87e7ebe8 100644 --- a/dotnet/samples/GettingStarted/Workflows/SharedStates/SharedStates.csproj +++ b/dotnet/samples/GettingStarted/Workflows/SharedStates/SharedStates.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/Visualization/Visualization.csproj b/dotnet/samples/GettingStarted/Workflows/Visualization/Visualization.csproj index c9b83f7c38b..57b1fef0e13 100644 --- a/dotnet/samples/GettingStarted/Workflows/Visualization/Visualization.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Visualization/Visualization.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj index 0a0945caffb..2f410707596 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj index 0a0945caffb..2f410707596 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj index 51b18bdeb22..881f20e1af3 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj index 51b18bdeb22..881f20e1af3 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/04_AgentWorkflowPatterns.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs index 8cc66ed18a8..1fa3aabb5cd 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -64,7 +64,7 @@ await RunWorkflowAsync( while (true) { Console.Write("Q: "); - messages.Add(new(ChatRole.User, Console.ReadLine()!)); + messages.Add(new(ChatRole.User, Console.ReadLine())); messages.AddRange(await RunWorkflowAsync(workflow, messages)); } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj index ea370c4eaa2..65d85d21af0 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj @@ -2,14 +2,14 @@ Exe - net9.0 + net10.0 enable enable - + diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs index c90131a27cd..8b4ac7645d8 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs @@ -12,19 +12,16 @@ IChatClient aws = new AmazonBedrockRuntimeClient( Environment.GetEnvironmentVariable("BEDROCK_ACCESSKEY"!), Environment.GetEnvironmentVariable("BEDROCK_SECRETACCESSKEY")!, - Amazon.RegionEndpoint.USEast1).AsIChatClient("amazon.nova-pro-v1:0"); + Amazon.RegionEndpoint.USEast1) + .AsIChatClient("amazon.nova-pro-v1:0"); -IChatClient anthropic = new Anthropic.SDK.AnthropicClient( - Environment.GetEnvironmentVariable("ANTHROPIC_APIKEY")!).Messages.AsBuilder() - .ConfigureOptions(o => - { - o.ModelId ??= "claude-sonnet-4-20250514"; - o.MaxOutputTokens ??= 10 * 1024; - }) - .Build(); +IChatClient anthropic = new Anthropic.AnthropicClient( + new() { APIKey = Environment.GetEnvironmentVariable("ANTHROPIC_APIKEY") }) + .AsIChatClient("claude-sonnet-4-20250514"); IChatClient openai = new OpenAI.OpenAIClient( - Environment.GetEnvironmentVariable("OPENAI_APIKEY")!).GetChatClient("gpt-4o-mini").AsIChatClient(); + Environment.GetEnvironmentVariable("OPENAI_APIKEY")!).GetChatClient("gpt-4o-mini") + .AsIChatClient(); // Define our agents. AIAgent researcher = new ChatClientAgent(aws, diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj index 89b1e4bbe00..e3913683e1a 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj index 51b18bdeb22..881f20e1af3 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj index 24901257c8c..e7a65f11a77 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 WriterCriticWorkflow enable enable diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index d9cc30f3950..265a87b5f60 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -285,19 +285,19 @@ public CriticExecutor(IChatClient chatClient) : base("Critic") this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions { Name = "Critic", - Instructions = """ - You are a constructive critic. Review the content and provide specific feedback. - Always try to provide actionable suggestions for improvement and strive to identify improvement points. - Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points. - - Provide your decision as structured output with: - - approved: true if content is good, false if revisions needed - - feedback: specific improvements needed (empty if approved) - - Be concise but specific in your feedback. - """, ChatOptions = new() { + Instructions = """ + You are a constructive critic. Review the content and provide specific feedback. + Always try to provide actionable suggestions for improvement and strive to identify improvement points. + Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points. + + Provide your decision as structured output with: + - approved: true if content is good, false if revisions needed + - feedback: specific improvements needed (empty if approved) + + Be concise but specific in your feedback. + """, ResponseFormat = ChatResponseFormat.ForJsonSchema() } }); diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj b/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj index 2343fe016f0..60808f40517 100644 --- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj @@ -1,8 +1,8 @@ - + Exe - net9.0 + net10.0 enable enable @@ -37,15 +37,15 @@ - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -53,15 +53,15 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile index 776f81041e5..a2590fc112b 100644 --- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Dockerfile @@ -1,5 +1,5 @@ # Build the application -FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src # Copy files from the current directory on the host to the working directory in the container @@ -7,10 +7,10 @@ COPY . . RUN dotnet restore RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app +RUN dotnet publish -c Release --no-build -o /app -f net10.0 # Run the application -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final WORKDIR /app # Copy everything needed to run the app from the "build" stage. diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj index 4aafb7582a3..1cc019a1966 100644 --- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj +++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj @@ -1,8 +1,8 @@ - + Exe - net9.0 + net10.0 enable enable @@ -36,15 +36,15 @@ - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -52,15 +52,15 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Dockerfile b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Dockerfile index b494ad22549..3d944c98834 100644 --- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Dockerfile +++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Dockerfile @@ -1,5 +1,5 @@ # Build the application -FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src # Copy files from the current directory on the host to the working directory in the container @@ -7,10 +7,10 @@ COPY . . RUN dotnet restore RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app +RUN dotnet publish -c Release --no-build -o /app -f net10.0 # Run the application -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final WORKDIR /app # Copy everything needed to run the app from the "build" stage. diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs index 94552a80141..d8be12c5b5a 100644 --- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs +++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs @@ -38,7 +38,7 @@ { // The mock search inspects the user's question and returns pre-defined snippets // that resemble documents stored in an external knowledge source. - List results = new(); + List results = []; if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase)) { diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj index 7e70caabdaf..1891ebab9db 100644 --- a/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj +++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj @@ -1,8 +1,8 @@ - + Exe - net9.0 + net10.0 enable enable @@ -36,15 +36,15 @@ - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -52,15 +52,15 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/Dockerfile b/dotnet/samples/HostedAgents/AgentsInWorkflows/Dockerfile index 0d3e5757cde..86b6c156f3a 100644 --- a/dotnet/samples/HostedAgents/AgentsInWorkflows/Dockerfile +++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/Dockerfile @@ -1,5 +1,5 @@ # Build the application -FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src # Copy files from the current directory on the host to the working directory in the container @@ -7,10 +7,10 @@ COPY . . RUN dotnet restore RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app +RUN dotnet publish -c Release --no-build -o /app -f net10.0 # Run the application -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final WORKDIR /app # Copy everything needed to run the app from the "build" stage. diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/README.md b/dotnet/samples/HostedAgents/AgentsInWorkflows/README.md index a92012157e0..5f6babc7556 100644 --- a/dotnet/samples/HostedAgents/AgentsInWorkflows/README.md +++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/README.md @@ -13,7 +13,7 @@ The agents are connected sequentially, creating a translation chain that demonst Before you begin, ensure you have the following prerequisites: -- .NET 8.0 SDK or later +- .NET 10 SDK or later - Azure OpenAI service endpoint and deployment configured - Azure CLI installed and authenticated (for Azure credential authentication) diff --git a/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs b/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs index e133170b36f..740b959a7a3 100644 --- a/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs +++ b/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs @@ -33,9 +33,9 @@ public WeatherForecastAgent(IChatClient chatClient) new ChatClientAgentOptions() { Name = AgentName, - Instructions = AgentInstructions, ChatOptions = new ChatOptions() { + Instructions = AgentInstructions, Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))], // We want the agent to return structured output in a known format // so that we can easily create adaptive cards from the response. diff --git a/dotnet/samples/M365Agent/M365Agent.csproj b/dotnet/samples/M365Agent/M365Agent.csproj index 9beff68dc7b..f40d4042046 100644 --- a/dotnet/samples/M365Agent/M365Agent.csproj +++ b/dotnet/samples/M365Agent/M365Agent.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable b842df34-390f-490d-9dc0-73909363ad16 @@ -20,7 +20,6 @@ - diff --git a/dotnet/samples/M365Agent/README.md b/dotnet/samples/M365Agent/README.md index e669474ef99..e61fa438f58 100644 --- a/dotnet/samples/M365Agent/README.md +++ b/dotnet/samples/M365Agent/README.md @@ -8,7 +8,7 @@ This Agent Sample is intended to introduce you the basics of integrating Agent F ## Prerequisites -- [.NET 8.0 SDK or later](https://dotnet.microsoft.com/download) +- [.NET 10.0 SDK or later](https://dotnet.microsoft.com/download) - [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) - [Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit) diff --git a/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj index 8dc509efed1..0a79857d64d 100644 --- a/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj +++ b/dotnet/samples/Purview/AgentWithPurview/AgentWithPurview.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index cafbf90b879..887e780104e 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -74,26 +75,28 @@ public override async Task RunAsync(IEnumerable m { _ = Throw.IfNull(messages); - var a2aMessage = messages.ToA2AMessage(); + A2AAgentThread typedThread = this.GetA2AThread(thread, options); - thread ??= this.GetNewThread(); - if (thread is not A2AAgentThread typedThread) - { - throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); - } + this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name); - // Linking the message to the existing conversation, if any. - a2aMessage.ContextId = typedThread.ContextId; + A2AResponse? a2aResponse = null; - this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name); + if (GetContinuationToken(messages, options) is { } token) + { + a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); + } + else + { + var a2aMessage = CreateA2AMessage(typedThread, messages); - var a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + } this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name); if (a2aResponse is AgentMessage message) { - UpdateThreadConversationId(typedThread, message.ContextId); + UpdateThread(typedThread, message.ContextId); return new AgentRunResponse { @@ -101,21 +104,30 @@ public override async Task RunAsync(IEnumerable m ResponseId = message.MessageId, RawRepresentation = message, Messages = [message.ToChatMessage()], - AdditionalProperties = message.Metadata.ToAdditionalProperties(), + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), }; } + if (a2aResponse is AgentTask agentTask) { - UpdateThreadConversationId(typedThread, agentTask.ContextId); + UpdateThread(typedThread, agentTask.ContextId, agentTask.Id); - return new AgentRunResponse + var response = new AgentRunResponse { AgentId = this.Id, ResponseId = agentTask.Id, RawRepresentation = agentTask, - Messages = agentTask.ToChatMessages(), - AdditionalProperties = agentTask.Metadata.ToAdditionalProperties(), + Messages = agentTask.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), + AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(), }; + + if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages) + { + response.Messages = taskMessages; + } + + return response; } throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}"); @@ -126,43 +138,63 @@ public override async IAsyncEnumerable RunStreamingAsync { _ = Throw.IfNull(messages); - var a2aMessage = messages.ToA2AMessage(); + A2AAgentThread typedThread = this.GetA2AThread(thread, options); - thread ??= this.GetNewThread(); - if (thread is not A2AAgentThread typedThread) + this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); + + ConfiguredCancelableAsyncEnumerable> a2aSseEvents; + + if (options?.ContinuationToken is not null) { - throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); + // Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations. + // The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream + // from the beginning, but it does not define stream resumption from a specific point in the stream. + // Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification, + // and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to + // the existing ones or reconnect the stream and obtain all updates again. + // For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764 + throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet."); + // a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); } - // Linking the message to the existing conversation, if any. - a2aMessage.ContextId = typedThread.ContextId; - - this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); + var a2aMessage = CreateA2AMessage(typedThread, messages); - var a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name); + string? contextId = null; + string? taskId = null; + await foreach (var sseEvent in a2aSseEvents) { - if (sseEvent.Data is not AgentMessage message) + if (sseEvent.Data is AgentMessage message) { - throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}"); + contextId = message.ContextId; + + yield return this.ConvertToAgentResponseUpdate(message); } + else if (sseEvent.Data is AgentTask task) + { + contextId = task.ContextId; + taskId = task.Id; - UpdateThreadConversationId(typedThread, message.ContextId); + yield return this.ConvertToAgentResponseUpdate(task); + } + else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent) + { + contextId = taskUpdateEvent.ContextId; + taskId = taskUpdateEvent.TaskId; - yield return new AgentRunResponseUpdate + yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent); + } + else { - AgentId = this.Id, - ResponseId = message.MessageId, - RawRepresentation = message, - Role = ChatRole.Assistant, - MessageId = message.MessageId, - Contents = [.. message.Parts.Select(part => part.ToAIContent()).OfType()], - AdditionalProperties = message.Metadata.ToAdditionalProperties(), - }; + throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}"); + } } + + UpdateThread(typedThread, contextId, taskId); } /// @@ -177,7 +209,27 @@ public override async IAsyncEnumerable RunStreamingAsync /// public override string? Description => this._description ?? base.Description; - private static void UpdateThreadConversationId(A2AAgentThread? thread, string? contextId) + private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options) + { + // Aligning with other agent implementations that support background responses, where + // a thread is required for background responses to prevent inconsistent experience + // for callers if they forget to provide the thread for initial or follow-up runs. + if (options?.AllowBackgroundResponses is true && thread is null) + { + throw new InvalidOperationException("A thread must be provided when AllowBackgroundResponses is enabled."); + } + + thread ??= this.GetNewThread(); + + if (thread is not A2AAgentThread typedThread) + { + throw new InvalidOperationException($"The provided thread type {thread.GetType()} is not compatible with the agent. Only A2A agent created threads are supported."); + } + + return typedThread; + } + + private static void UpdateThread(A2AAgentThread? thread, string? contextId, string? taskId = null) { if (thread is null) { @@ -194,5 +246,93 @@ private static void UpdateThreadConversationId(A2AAgentThread? thread, string? c // Assign a server-generated context Id to the thread if it's not already set. thread.ContextId ??= contextId; + thread.TaskId = taskId; + } + + private static AgentMessage CreateA2AMessage(A2AAgentThread typedThread, IEnumerable messages) + { + var a2aMessage = messages.ToA2AMessage(); + + // Linking the message to the existing conversation, if any. + // See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#group-related-interactions + a2aMessage.ContextId = typedThread.ContextId; + + // Link the message as a follow-up to an existing task, if any. + // See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements + a2aMessage.ReferenceTaskIds = typedThread.TaskId is null ? null : [typedThread.TaskId]; + + return a2aMessage; + } + + private static A2AContinuationToken? GetContinuationToken(IEnumerable messages, AgentRunOptions? options = null) + { + if (options?.ContinuationToken is ResponseContinuationToken token) + { + if (messages.Any()) + { + throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); + } + + return A2AContinuationToken.FromToken(token); + } + + return null; + } + + private static A2AContinuationToken? CreateContinuationToken(string taskId, TaskState state) + { + if (state == TaskState.Submitted || state == TaskState.Working) + { + return new A2AContinuationToken(taskId); + } + + return null; + } + + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message) + { + return new AgentRunResponseUpdate + { + AgentId = this.Id, + ResponseId = message.MessageId, + RawRepresentation = message, + Role = ChatRole.Assistant, + MessageId = message.MessageId, + Contents = message.Parts.ConvertAll(part => part.ToAIContent()), + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) + { + return new AgentRunResponseUpdate + { + AgentId = this.Id, + ResponseId = task.Id, + RawRepresentation = task, + Role = ChatRole.Assistant, + Contents = task.ToAIContents(), + AdditionalProperties = task.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent) + { + AgentRunResponseUpdate responseUpdate = new() + { + AgentId = this.Id, + ResponseId = taskUpdateEvent.TaskId, + RawRepresentation = taskUpdateEvent, + Role = ChatRole.Assistant, + AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + }; + + if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) + { + responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); + responseUpdate.RawRepresentation = artifactUpdateEvent; + } + + return responseUpdate; } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs index 010df78a02d..55942c8dd1c 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Text.Json; namespace Microsoft.Agents.AI.A2A; @@ -7,22 +8,59 @@ namespace Microsoft.Agents.AI.A2A; /// /// Thread for A2A based agents. /// -public sealed class A2AAgentThread : ServiceIdAgentThread +public sealed class A2AAgentThread : AgentThread { internal A2AAgentThread() { } - internal A2AAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) + internal A2AAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) { + if (serializedThreadState.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState)); + } + + var state = serializedThreadState.Deserialize( + A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentThreadState))) as A2AAgentThreadState; + + if (state?.ContextId is string contextId) + { + this.ContextId = contextId; + } + + if (state?.TaskId is string taskId) + { + this.TaskId = taskId; + } } /// /// Gets the ID for the current conversation with the A2A agent. /// - public string? ContextId + public string? ContextId { get; internal set; } + + /// + /// Gets the ID for the task the agent is currently working on. + /// + public string? TaskId { get; internal set; } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + var state = new A2AAgentThreadState + { + ContextId = this.ContextId, + TaskId = this.TaskId + }; + + return JsonSerializer.SerializeToElement(state, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentThreadState))); + } + + internal sealed class A2AAgentThreadState { - get { return this.ServiceThreadId; } - internal set { this.ServiceThreadId = value; } + public string? ContextId { get; set; } + + public string? TaskId { get; set; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs new file mode 100644 index 00000000000..5233adb88f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.A2A; +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal class A2AContinuationToken : ResponseContinuationToken +{ + internal A2AContinuationToken(string taskId) + { + _ = Throw.IfNullOrEmpty(taskId); + + this.TaskId = taskId; + } + + internal string TaskId { get; } + + internal static A2AContinuationToken FromToken(ResponseContinuationToken token) + { + if (token is A2AContinuationToken longRunContinuationToken) + { + return longRunContinuationToken; + } + + ReadOnlyMemory data = token.ToBytes(); + + if (data.Length == 0) + { + Throw.ArgumentException(nameof(token), "Failed to create A2AContinuationToken from provided token because it does not contain any data."); + } + + Utf8JsonReader reader = new(data.Span); + + string taskId = null!; + + reader.Read(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string propertyName = reader.GetString() ?? throw new JsonException("Failed to read property name from continuation token."); + + switch (propertyName) + { + case "taskId": + reader.Read(); + taskId = reader.GetString()!; + break; + default: + throw new JsonException($"Unrecognized property '{propertyName}'."); + } + } + + return new(taskId); + } + + public override ReadOnlyMemory ToBytes() + { + using MemoryStream stream = new(); + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + writer.WriteString("taskId", this.TaskId); + + writer.WriteEndObject(); + + writer.Flush(); + stream.Position = 0; + + return stream.ToArray(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs new file mode 100644 index 00000000000..2fbb2e86176 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.A2A; + +namespace Microsoft.Agents.AI; + +/// +/// Provides utility methods and configurations for JSON serialization operations for A2A agent types. +/// +public static partial class A2AJsonUtilities +{ + /// + /// Gets the default instance used for JSON serialization operations of A2A agent types. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for A2A agent types. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// + /// Enables when escaping JSON strings. + /// Consuming applications must ensure that JSON outputs are adequately escaped before embedding in other document formats, such as HTML and XML. + /// + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates and configures the default JSON serialization options for agent abstraction types. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as AIJsonUtilities + }; + + // Chain in the resolvers from both AIJsonUtilities and our source generated context. + // We want AIJsonUtilities first to ensure any M.E.AI types are handled via its resolver. + options.TypeInfoResolverChain.Clear(); + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + + // If reflection-based serialization is enabled by default, this includes + // the default type info resolver that utilizes reflection, but we need to manually + // apply the same converter AIJsonUtilities adds for string-based enum serialization, + // as that's not propagated as part of the resolver. + if (JsonSerializer.IsReflectionEnabledByDefault) + { + options.Converters.Add(new JsonStringEnumConverter()); + } + + options.MakeReadOnly(); + return options; + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] + + // A2A agent types + [JsonSerializable(typeof(A2AAgentThread.A2AAgentThreadState))] + [ExcludeFromCodeCoverage] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs index 236ecfb174f..a577ad93648 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs @@ -11,20 +11,37 @@ namespace A2A; /// internal static class A2AAgentTaskExtensions { - internal static IList ToChatMessages(this AgentTask agentTask) + internal static IList? ToChatMessages(this AgentTask agentTask) { _ = Throw.IfNull(agentTask); - List messages = []; + List? messages = null; - if (agentTask.Artifacts is not null) + if (agentTask?.Artifacts is { Count: > 0 }) { foreach (var artifact in agentTask.Artifacts) { - messages.Add(artifact.ToChatMessage()); + (messages ??= []).Add(artifact.ToChatMessage()); } } return messages; } + + internal static IList? ToAIContents(this AgentTask agentTask) + { + _ = Throw.IfNull(agentTask); + + List? aiContents = null; + + if (agentTask.Artifacts is not null) + { + foreach (var artifact in agentTask.Artifacts) + { + (aiContents ??= []).AddRange(artifact.ToAIContents()); + } + } + + return aiContents; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs index 36683d549b3..cecd9a85047 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs @@ -12,21 +12,15 @@ internal static class A2AArtifactExtensions { internal static ChatMessage ToChatMessage(this Artifact artifact) { - List? aiContents = null; - - foreach (var part in artifact.Parts) - { - var content = part.ToAIContent(); - if (content is not null) - { - (aiContents ??= []).Add(content); - } - } - - return new ChatMessage(ChatRole.Assistant, aiContents) + return new ChatMessage(ChatRole.Assistant, artifact.ToAIContents()) { AdditionalProperties = artifact.Metadata.ToAdditionalProperties(), RawRepresentation = artifact, }; } + + internal static List ToAIContents(this Artifact artifact) + { + return artifact.Parts.ConvertAll(part => part.ToAIContent()); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj index 46e3c97d8fe..b1b9ba76710 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj @@ -1,21 +1,19 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview + $(NoWarn);MEAI001 true + true - - diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs index a168e2eab64..3571d97085c 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs @@ -82,7 +82,7 @@ public override Task GetResponseAsync(IEnumerable mes .ToChatResponseAsync(cancellationToken); /// - public async override IAsyncEnumerable GetStreamingResponseAsync( + public override async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj index 35f89f889fb..57cb375e145 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview @@ -24,8 +22,7 @@ - - + diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs index 0b571c4ff19..b13a8036252 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs @@ -59,6 +59,4 @@ namespace Microsoft.Agents.AI.AGUI; [JsonSerializable(typeof(float))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(decimal))] -internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext -{ -} +internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index 46184a6588f..f5fb103bd47 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -421,7 +421,7 @@ chatResponse.Contents[0] is TextContent && // State snapshot event yield return new StateSnapshotEvent { -#if NET472 || NETSTANDARD2_0 +#if !NET Snapshot = (JsonElement?)JsonSerializer.Deserialize( dataContent.Data.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) @@ -438,7 +438,7 @@ chatResponse.Contents[0] is TextContent && // but its not up to us to validate that here. yield return new StateDeltaEvent { -#if NET472 || NETSTANDARD2_0 +#if !NET Delta = (JsonElement?)JsonSerializer.Deserialize( dataContent.Data.ToArray(), jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) @@ -455,7 +455,7 @@ chatResponse.Contents[0] is TextContent && yield return new TextMessageContentEvent { MessageId = chatResponse.MessageId!, -#if NET472 || NETSTANDARD2_0 +#if !NET Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray()) #else Delta = Encoding.UTF8.GetString(dataContent.Data.Span) diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs index a9396ff7228..f64177146fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs @@ -32,7 +32,7 @@ internal sealed class RunAgentInput [JsonPropertyName("context")] public AGUIContextItem[] Context { get; set; } = []; - [JsonPropertyName("forwardedProperties")] + [JsonPropertyName("forwardedProps")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public JsonElement ForwardedProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 35aa866552b..eba6f84687b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -328,28 +328,4 @@ public abstract IAsyncEnumerable RunStreamingAsync( AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default); - - /// - /// Notifies the specified thread about new messages that have been added to the conversation. - /// - /// The conversation thread to notify about the new messages. - /// The collection of new messages to report to the thread. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous notification operation. - /// or is . - /// - /// - /// This method ensures that conversation threads are kept informed about message additions, which - /// is important for threads that manage their own state, memory components, or derived context. - /// While all agent implementations should notify their threads, the specific actions taken by - /// each thread type may vary. - /// - /// - protected static async Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IEnumerable messages, CancellationToken cancellationToken) - { - _ = Throw.IfNull(thread); - _ = Throw.IfNull(messages); - - await thread.MessagesReceivedAsync(messages, cancellationToken).ConfigureAwait(false); - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index a4b3f5d9564..fd3ff10fc22 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -124,7 +124,7 @@ public virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOption /// that will be used. Context providers can use this information to determine what additional context /// should be provided for the invocation. /// - public class InvokingContext + public sealed class InvokingContext { /// /// Initializes a new instance of the class with the specified request messages. @@ -153,7 +153,7 @@ public InvokingContext(IEnumerable requestMessages) /// request messages that were used and the response messages that were generated. It also indicates /// whether the invocation succeeded or failed. /// - public class InvokedContext + public sealed class InvokedContext { /// /// Initializes a new instance of the class with the specified request messages. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index 72629792079..9cd6d51680f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -49,7 +49,7 @@ public AgentRunOptions(AgentRunOptions options) /// can be polled for completion by obtaining the token from the property /// and passing it via this property on subsequent calls to . /// - public object? ContinuationToken { get; set; } + public ResponseContinuationToken? ContinuationToken { get; set; } /// /// Gets or sets a value indicating whether the background responses are allowed. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs index 2beb2879183..001cfd94694 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs @@ -175,7 +175,7 @@ public IList Messages /// to poll for completion. /// /// - public object? ContinuationToken { get; set; } + public ResponseContinuationToken? ContinuationToken { get; set; } /// /// Gets or sets the timestamp indicating when this response was created. @@ -336,7 +336,7 @@ public bool TryDeserialize(JsonSerializerOptions serializerOptions, [NotNullW private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) { -#if NET9_0_OR_GREATER +#if NET // We need to deserialize only the first top-level object as a workaround for a common LLM backend // issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call. // See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348 diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponseUpdate.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponseUpdate.cs index 954893dbcbb..ccf3deae54b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponseUpdate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponseUpdate.cs @@ -38,9 +38,6 @@ public class AgentRunResponseUpdate /// The response update content items. private IList? _contents; - /// The name of the author of the update. - private string? _authorName; - /// Initializes a new instance of the class. [JsonConstructor] public AgentRunResponseUpdate() @@ -84,8 +81,8 @@ public AgentRunResponseUpdate(ChatResponseUpdate chatResponseUpdate) /// Gets or sets the name of the author of the response update. public string? AuthorName { - get => this._authorName; - set => this._authorName = string.IsNullOrWhiteSpace(value) ? null : value; + get => field; + set => field = string.IsNullOrWhiteSpace(value) ? null : value; } /// Gets or sets the role of the author of the response update. @@ -162,7 +159,7 @@ public IList Contents /// to resume streaming from the point of interruption. /// /// - public object? ContinuationToken { get; set; } + public ResponseContinuationToken? ContinuationToken { get; set; } /// public override string ToString() => this.Text; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs index fb5863a5c95..4794457f415 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs @@ -1,11 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -65,19 +61,6 @@ protected AgentThread() public virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) => default; - /// - /// This method is called when new messages have been contributed to the chat by any participant. - /// - /// - /// Inheritors can use this method to update their context based on the new message. - /// - /// The new messages. - /// The to monitor for cancellation requests. The default is . - /// A task that completes when the context has been updated. - /// The thread has been deleted. - protected internal virtual Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) - => Task.CompletedTask; - /// Asks the for an object of the specified type . /// The type of object being requested. /// An optional key that can be used to help identify the target service. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryAgentThread.cs index af6080a7150..13fcc134f01 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryAgentThread.cs @@ -4,8 +4,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; @@ -116,10 +114,6 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio public override object? GetService(Type serviceType, object? serviceKey = null) => base.GetService(serviceType, serviceKey) ?? this.MessageStore?.GetService(serviceType, serviceKey); - /// - protected internal override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) - => this.MessageStore.AddMessagesAsync(newMessages, cancellationToken); - [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => $"Count = {this.MessageStore.Count}"; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index 4add7f427c8..6b6f9d44f2c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) Microsoft.Agents.AI $(NoWarn);MEAI001 preview diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs new file mode 100644 index 00000000000..6b4f872a63e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Anthropic.Services; + +/// +/// Provides extension methods for the class. +/// +public static class AnthropicBetaServiceExtensions +{ + /// + /// Specifies the default maximum number of tokens allowed for processing operations. + /// + public static int DefaultMaxTokens { get; set; } = 4096; + + /// + /// Creates a new AI agent using the specified model and options. + /// + /// The Anthropic beta service. + /// The model to use for chat completions. + /// The instructions for the AI agent. + /// The name of the AI agent. + /// The description of the AI agent. + /// The tools available to the AI agent. + /// The default maximum tokens for chat completions. Defaults to if not provided. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// The created AI agent. + public static ChatClientAgent CreateAIAgent( + this IBetaService betaService, + string model, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + int? defaultMaxTokens = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + var options = new ChatClientAgentOptions + { + Name = name, + Description = description, + }; + + if (!string.IsNullOrWhiteSpace(instructions)) + { + options.ChatOptions ??= new(); + options.ChatOptions.Instructions = instructions; + } + + if (tools is { Count: > 0 }) + { + options.ChatOptions ??= new(); + options.ChatOptions.Tools = tools; + } + + var chatClient = betaService.AsIChatClient(model, defaultMaxTokens ?? DefaultMaxTokens); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, options, loggerFactory, services); + } + + /// + /// Creates an AI agent from an using the Anthropic Chat Completion API. + /// + /// The Anthropic to use for the agent. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// An instance backed by the Anthropic Chat Completion service. + /// Thrown when or is . + public static ChatClientAgent CreateAIAgent( + this IBetaService betaService, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(betaService); + Throw.IfNull(options); + + var chatClient = betaService.AsIChatClient(); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, options, loggerFactory, services); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs new file mode 100644 index 00000000000..b4b8e2bc1e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Anthropic; + +/// +/// Provides extension methods for the class. +/// +public static class AnthropicClientExtensions +{ + /// + /// Specifies the default maximum number of tokens allowed for processing operations. + /// + public static int DefaultMaxTokens { get; set; } = 4096; + + /// + /// Creates a new AI agent using the specified model and options. + /// + /// An Anthropic to use with the agent.. + /// The model to use for chat completions. + /// The instructions for the AI agent. + /// The name of the AI agent. + /// The description of the AI agent. + /// The tools available to the AI agent. + /// The default maximum tokens for chat completions. Defaults to if not provided. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// The created AI agent. + public static ChatClientAgent CreateAIAgent( + this IAnthropicClient client, + string model, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + int? defaultMaxTokens = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + var options = new ChatClientAgentOptions + { + Name = name, + Description = description, + }; + + if (!string.IsNullOrWhiteSpace(instructions)) + { + options.ChatOptions ??= new(); + options.ChatOptions.Instructions = instructions; + } + + if (tools is { Count: > 0 }) + { + options.ChatOptions ??= new(); + options.ChatOptions.Tools = tools; + } + + var chatClient = client.AsIChatClient(model, defaultMaxTokens ?? DefaultMaxTokens); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, options, loggerFactory, services); + } + + /// + /// Creates an AI agent from an using the Anthropic Chat Completion API. + /// + /// An Anthropic to use with the agent.. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// An instance backed by the Anthropic Chat Completion service. + /// Thrown when or is . + public static ChatClientAgent CreateAIAgent( + this IAnthropicClient client, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(client); + Throw.IfNull(options); + + var chatClient = client.AsIChatClient(); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, options, loggerFactory, services); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientJsonContext.cs new file mode 100644 index 00000000000..080745f1483 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientJsonContext.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable CA1812 + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Anthropic; + +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(Dictionary))] +internal sealed partial class AnthropicClientJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj b/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj new file mode 100644 index 00000000000..60b90a02122 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj @@ -0,0 +1,26 @@ + + + + preview + enable + true + + + + + + + + + + + + + + + + Microsoft Agent Framework Anthropic Agents + Provides Microsoft Agent Framework support for Anthropic Agents. + + + diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj index bdf668391b7..31785a8fa98 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview enable diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs index 1d5f228fcc2..5ca14365871 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs @@ -17,15 +17,21 @@ public static class PersistentAgentsClientExtensions /// The response containing the persistent agent to be converted. Cannot be . /// The default to use when interacting with the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. - public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, Response persistentAgentResponse, ChatOptions? chatOptions = null, Func? clientFactory = null) + public static ChatClientAgent GetAIAgent( + this PersistentAgentsClient persistentAgentsClient, + Response persistentAgentResponse, + ChatOptions? chatOptions = null, + Func? clientFactory = null, + IServiceProvider? services = null) { if (persistentAgentResponse is null) { throw new ArgumentNullException(nameof(persistentAgentResponse)); } - return GetAIAgent(persistentAgentsClient, persistentAgentResponse.Value, chatOptions, clientFactory); + return GetAIAgent(persistentAgentsClient, persistentAgentResponse.Value, chatOptions, clientFactory, services); } /// @@ -35,8 +41,14 @@ public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentA /// The persistent agent metadata to be converted. Cannot be . /// The default to use when interacting with the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. - public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, PersistentAgent persistentAgentMetadata, ChatOptions? chatOptions = null, Func? clientFactory = null) + public static ChatClientAgent GetAIAgent( + this PersistentAgentsClient persistentAgentsClient, + PersistentAgent persistentAgentMetadata, + ChatOptions? chatOptions = null, + Func? clientFactory = null, + IServiceProvider? services = null) { if (persistentAgentMetadata is null) { @@ -55,14 +67,19 @@ public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentA chatClient = clientFactory(chatClient); } + if (!string.IsNullOrWhiteSpace(persistentAgentMetadata.Instructions) && chatOptions?.Instructions is null) + { + chatOptions ??= new ChatOptions(); + chatOptions.Instructions = persistentAgentMetadata.Instructions; + } + return new ChatClientAgent(chatClient, options: new() { Id = persistentAgentMetadata.Id, Name = persistentAgentMetadata.Name, Description = persistentAgentMetadata.Description, - Instructions = persistentAgentMetadata.Instructions, ChatOptions = chatOptions - }); + }, services: services); } /// @@ -73,6 +90,7 @@ public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentA /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. public static ChatClientAgent GetAIAgent( @@ -80,6 +98,7 @@ public static ChatClientAgent GetAIAgent( string agentId, ChatOptions? chatOptions = null, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -93,7 +112,7 @@ public static ChatClientAgent GetAIAgent( } var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken); - return persistentAgentsClient.GetAIAgent(persistentAgentResponse, chatOptions, clientFactory); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, chatOptions, clientFactory, services); } /// @@ -104,6 +123,7 @@ public static ChatClientAgent GetAIAgent( /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. public static async Task GetAIAgentAsync( @@ -111,6 +131,7 @@ public static async Task GetAIAgentAsync( string agentId, ChatOptions? chatOptions = null, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -124,7 +145,7 @@ public static async Task GetAIAgentAsync( } var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); - return persistentAgentsClient.GetAIAgent(persistentAgentResponse, chatOptions, clientFactory); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, chatOptions, clientFactory, services); } /// @@ -134,16 +155,22 @@ public static async Task GetAIAgentAsync( /// The response containing the persistent agent to be converted. Cannot be . /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . - public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, Response persistentAgentResponse, ChatClientAgentOptions options, Func? clientFactory = null) + public static ChatClientAgent GetAIAgent( + this PersistentAgentsClient persistentAgentsClient, + Response persistentAgentResponse, + ChatClientAgentOptions options, + Func? clientFactory = null, + IServiceProvider? services = null) { if (persistentAgentResponse is null) { throw new ArgumentNullException(nameof(persistentAgentResponse)); } - return GetAIAgent(persistentAgentsClient, persistentAgentResponse.Value, options, clientFactory); + return GetAIAgent(persistentAgentsClient, persistentAgentResponse.Value, options, clientFactory, services); } /// @@ -153,9 +180,15 @@ public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentA /// The persistent agent metadata to be converted. Cannot be . /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . - public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, PersistentAgent persistentAgentMetadata, ChatClientAgentOptions options, Func? clientFactory = null) + public static ChatClientAgent GetAIAgent( + this PersistentAgentsClient persistentAgentsClient, + PersistentAgent persistentAgentMetadata, + ChatClientAgentOptions options, + Func? clientFactory = null, + IServiceProvider? services = null) { if (persistentAgentMetadata is null) { @@ -179,19 +212,24 @@ public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentA chatClient = clientFactory(chatClient); } + if (!string.IsNullOrWhiteSpace(persistentAgentMetadata.Instructions) && options.ChatOptions?.Instructions is null) + { + options.ChatOptions ??= new ChatOptions(); + options.ChatOptions.Instructions = persistentAgentMetadata.Instructions; + } + var agentOptions = new ChatClientAgentOptions() { Id = persistentAgentMetadata.Id, Name = options.Name ?? persistentAgentMetadata.Name, Description = options.Description ?? persistentAgentMetadata.Description, - Instructions = options.Instructions ?? persistentAgentMetadata.Instructions, ChatOptions = options.ChatOptions, AIContextProviderFactory = options.AIContextProviderFactory, ChatMessageStoreFactory = options.ChatMessageStoreFactory, UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs }; - return new ChatClientAgent(chatClient, agentOptions); + return new ChatClientAgent(chatClient, agentOptions, services: services); } /// @@ -201,6 +239,7 @@ public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentA /// The ID of the server side agent to create a for. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . @@ -210,6 +249,7 @@ public static ChatClientAgent GetAIAgent( string agentId, ChatClientAgentOptions options, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -228,7 +268,7 @@ public static ChatClientAgent GetAIAgent( } var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken); - return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory, services); } /// @@ -238,6 +278,7 @@ public static ChatClientAgent GetAIAgent( /// The ID of the server side agent to create a for. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . @@ -247,6 +288,7 @@ public static async Task GetAIAgentAsync( string agentId, ChatClientAgentOptions options, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -265,7 +307,7 @@ public static async Task GetAIAgentAsync( } var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); - return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory, services); } /// @@ -283,6 +325,7 @@ public static async Task GetAIAgentAsync( /// The response format for the agent. /// The metadata for the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. public static async Task CreateAIAgentAsync( @@ -298,6 +341,7 @@ public static async Task CreateAIAgentAsync( BinaryData? responseFormat = null, IReadOnlyDictionary? metadata = null, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -319,7 +363,7 @@ public static async Task CreateAIAgentAsync( cancellationToken: cancellationToken).ConfigureAwait(false); // Get a local proxy for the agent to work with. - return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken).ConfigureAwait(false); + return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -337,6 +381,7 @@ public static async Task CreateAIAgentAsync( /// The response format for the agent. /// The metadata for the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. public static ChatClientAgent CreateAIAgent( @@ -352,6 +397,7 @@ public static ChatClientAgent CreateAIAgent( BinaryData? responseFormat = null, IReadOnlyDictionary? metadata = null, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -373,7 +419,7 @@ public static ChatClientAgent CreateAIAgent( cancellationToken: cancellationToken); // Get a local proxy for the agent to work with. - return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken); + return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken); } /// @@ -383,6 +429,7 @@ public static ChatClientAgent CreateAIAgent( /// The model to be used by the agent. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. /// Thrown when or or is . @@ -392,6 +439,7 @@ public static ChatClientAgent CreateAIAgent( string model, ChatClientAgentOptions options, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -415,7 +463,7 @@ public static ChatClientAgent CreateAIAgent( model: model, name: options.Name, description: options.Description, - instructions: options.Instructions, + instructions: options.ChatOptions?.Instructions, tools: toolDefinitionsAndResources.ToolDefinitions, toolResources: toolDefinitionsAndResources.ToolResources, temperature: null, @@ -431,7 +479,7 @@ public static ChatClientAgent CreateAIAgent( } // Get a local proxy for the agent to work with. - return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, cancellationToken: cancellationToken); + return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken); } /// @@ -441,6 +489,7 @@ public static ChatClientAgent CreateAIAgent( /// The model to be used by the agent. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. /// Thrown when or or is . @@ -450,6 +499,7 @@ public static async Task CreateAIAgentAsync( string model, ChatClientAgentOptions options, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -473,7 +523,7 @@ public static async Task CreateAIAgentAsync( model: model, name: options.Name, description: options.Description, - instructions: options.Instructions, + instructions: options.ChatOptions?.Instructions, tools: toolDefinitionsAndResources.ToolDefinitions, toolResources: toolDefinitionsAndResources.ToolResources, temperature: null, @@ -489,7 +539,7 @@ public static async Task CreateAIAgentAsync( } // Get a local proxy for the agent to work with. - return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, cancellationToken: cancellationToken).ConfigureAwait(false); + return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken).ConfigureAwait(false); } private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) @@ -506,7 +556,7 @@ private static (List? ToolDefinitions, ToolResources? ToolResour { case HostedCodeInterpreterTool codeTool: - toolDefinitions ??= new(); + toolDefinitions ??= []; toolDefinitions.Add(new CodeInterpreterToolDefinition()); if (codeTool.Inputs is { Count: > 0 }) @@ -527,7 +577,7 @@ private static (List? ToolDefinitions, ToolResources? ToolResour break; case HostedFileSearchTool fileSearchTool: - toolDefinitions ??= new(); + toolDefinitions ??= []; toolDefinitions.Add(new FileSearchToolDefinition { FileSearch = new() { MaxNumResults = fileSearchTool.MaximumResultCount } @@ -550,12 +600,12 @@ private static (List? ToolDefinitions, ToolResources? ToolResour break; case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true: - toolDefinitions ??= new(); + toolDefinitions ??= []; toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())]))); break; default: - functionToolsAndOtherTools ??= new(); + functionToolsAndOtherTools ??= []; functionToolsAndOtherTools.Add(tool); break; } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs index 2a5ace7a0ad..8acafc8fc35 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs @@ -101,7 +101,7 @@ public override async Task GetResponseAsync(IEnumerable - public async override IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var agentOptions = this.GetAgentEnabledChatOptions(options); diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs index 0ec5f593fdd..dfbdad8e98c 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs @@ -393,7 +393,7 @@ public static ChatClientAgent CreateAIAgent( PromptAgentDefinition agentDefinition = new(model) { - Instructions = options.Instructions, + Instructions = options.ChatOptions?.Instructions, Temperature = options.ChatOptions?.Temperature, TopP = options.ChatOptions?.TopP, TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } @@ -459,7 +459,7 @@ public static async Task CreateAIAgentAsync( PromptAgentDefinition agentDefinition = new(model) { - Instructions = options.Instructions, + Instructions = options.ChatOptions?.Instructions, Temperature = options.ChatOptions?.Temperature, TopP = options.ChatOptions?.TopP, TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } @@ -822,10 +822,9 @@ private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion if (agentDefinition is PromptAgentDefinition promptAgentDefinition) { agentOptions.ChatOptions ??= chatOptions?.Clone() ?? new(); - agentOptions.Instructions = promptAgentDefinition.Instructions; + agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; agentOptions.ChatOptions.Temperature = promptAgentDefinition.Temperature; agentOptions.ChatOptions.TopP = promptAgentDefinition.TopP; - agentOptions.ChatOptions.Instructions = promptAgentDefinition.Instructions; } if (agentTools is { Count: > 0 }) diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj index ff9a1c38fa7..233718b3e47 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview enable true diff --git a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj index d5aad73169c..daa27573853 100644 --- a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj +++ b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs new file mode 100644 index 00000000000..fff7f56fa5c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs @@ -0,0 +1,688 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides a Cosmos DB implementation of the abstract class. +/// +[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")] +[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")] +public sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable +{ + private readonly CosmosClient _cosmosClient; + private readonly Container _container; + private readonly bool _ownsClient; + private bool _disposed; + + // Hierarchical partition key support + private readonly string? _tenantId; + private readonly string? _userId; + private readonly PartitionKey _partitionKey; + private readonly bool _useHierarchicalPartitioning; + + /// + /// Cached JSON serializer options for .NET 9.0 compatibility. + /// + private static readonly JsonSerializerOptions s_defaultJsonOptions = CreateDefaultJsonOptions(); + + private static JsonSerializerOptions CreateDefaultJsonOptions() + { + var options = new JsonSerializerOptions(); +#if NET9_0_OR_GREATER + // Configure TypeInfoResolver for .NET 9.0 to enable JSON serialization + options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver(); +#endif + return options; + } + + /// + /// Gets or sets the maximum number of messages to return in a single query batch. + /// Default is 100 for optimal performance. + /// + public int MaxItemCount { get; set; } = 100; + + /// + /// Gets or sets the maximum number of items per transactional batch operation. + /// Default is 100, maximum allowed by Cosmos DB is 100. + /// + public int MaxBatchSize { get; set; } = 100; + + /// + /// Gets or sets the maximum number of messages to retrieve from the store. + /// This helps prevent exceeding LLM context windows in long conversations. + /// Default is null (no limit). When set, only the most recent messages are returned. + /// + public int? MaxMessagesToRetrieve { get; set; } + + /// + /// Gets or sets the Time-To-Live (TTL) in seconds for messages. + /// Default is 86400 seconds (24 hours). Set to null to disable TTL. + /// + public int? MessageTtlSeconds { get; set; } = 86400; + + /// + /// Gets the conversation ID associated with this message store. + /// + public string ConversationId { get; init; } + + /// + /// Gets the database ID associated with this message store. + /// + public string DatabaseId { get; init; } + + /// + /// Gets the container ID associated with this message store. + /// + public string ContainerId { get; init; } + + /// + /// Internal primary constructor used by all public constructors. + /// + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The unique identifier for this conversation thread. + /// Whether this instance owns the CosmosClient and should dispose it. + /// Optional tenant identifier for hierarchical partitioning. + /// Optional user identifier for hierarchical partitioning. + internal CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId, bool ownsClient, string? tenantId = null, string? userId = null) + { + this._cosmosClient = Throw.IfNull(cosmosClient); + this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); + this.ConversationId = Throw.IfNullOrWhitespace(conversationId); + this.DatabaseId = databaseId; + this.ContainerId = containerId; + this._ownsClient = ownsClient; + + // Initialize partitioning mode + this._tenantId = tenantId; + this._userId = userId; + this._useHierarchicalPartitioning = tenantId != null && userId != null; + + this._partitionKey = this._useHierarchicalPartitioning + ? new PartitionKeyBuilder() + .Add(tenantId!) + .Add(userId!) + .Add(conversationId) + .Build() + : new PartitionKey(conversationId); + } + + /// + /// Initializes a new instance of the class using a connection string. + /// + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(string connectionString, string databaseId, string containerId) + : this(connectionString, databaseId, containerId, Guid.NewGuid().ToString("N")) + { + } + + /// + /// Initializes a new instance of the class using a connection string. + /// + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The unique identifier for this conversation thread. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(string connectionString, string databaseId, string containerId, string conversationId) + : this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, conversationId, ownsClient: true) + { + } + + /// + /// Initializes a new instance of the class using TokenCredential for authentication. + /// + /// The Cosmos DB account endpoint URI. + /// The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential). + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId) + : this(accountEndpoint, tokenCredential, databaseId, containerId, Guid.NewGuid().ToString("N")) + { + } + + /// + /// Initializes a new instance of the class using a TokenCredential for authentication. + /// + /// The Cosmos DB account endpoint URI. + /// The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential). + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The unique identifier for this conversation thread. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string conversationId) + : this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, conversationId, ownsClient: true) + { + } + + /// + /// Initializes a new instance of the class using an existing . + /// + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Thrown when is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId) + : this(cosmosClient, databaseId, containerId, Guid.NewGuid().ToString("N")) + { + } + + /// + /// Initializes a new instance of the class using an existing . + /// + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The unique identifier for this conversation thread. + /// Thrown when is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId) + : this(cosmosClient, databaseId, containerId, conversationId, ownsClient: false) + { + } + + /// + /// Initializes a new instance of the class using a connection string with hierarchical partition keys. + /// + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The tenant identifier for hierarchical partitioning. + /// The user identifier for hierarchical partitioning. + /// The session identifier for hierarchical partitioning. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(string connectionString, string databaseId, string containerId, string tenantId, string userId, string sessionId) + : this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId)) + { + } + + /// + /// Initializes a new instance of the class using a TokenCredential for authentication with hierarchical partition keys. + /// + /// The Cosmos DB account endpoint URI. + /// The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential). + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The tenant identifier for hierarchical partitioning. + /// The user identifier for hierarchical partitioning. + /// The session identifier for hierarchical partitioning. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string tenantId, string userId, string sessionId) + : this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId)) + { + } + + /// + /// Initializes a new instance of the class using an existing with hierarchical partition keys. + /// + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The tenant identifier for hierarchical partitioning. + /// The user identifier for hierarchical partitioning. + /// The session identifier for hierarchical partitioning. + /// Thrown when is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string tenantId, string userId, string sessionId) + : this(cosmosClient, databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: false, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId)) + { + } + + /// + /// Creates a new instance of the class from previously serialized state. + /// + /// The instance to use for Cosmos DB operations. + /// A representing the serialized state of the message store. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Optional settings for customizing the JSON deserialization process. + /// A new instance of initialized from the serialized state. + /// Thrown when is null. + /// Thrown when the serialized state cannot be deserialized. + public static CosmosChatMessageStore CreateFromSerializedState(CosmosClient cosmosClient, JsonElement serializedStoreState, string databaseId, string containerId, JsonSerializerOptions? jsonSerializerOptions = null) + { + Throw.IfNull(cosmosClient); + Throw.IfNullOrWhitespace(databaseId); + Throw.IfNullOrWhitespace(containerId); + + if (serializedStoreState.ValueKind is not JsonValueKind.Object) + { + throw new ArgumentException("Invalid serialized state", nameof(serializedStoreState)); + } + + var state = JsonSerializer.Deserialize(serializedStoreState, jsonSerializerOptions); + if (state?.ConversationIdentifier is not { } conversationId) + { + throw new ArgumentException("Invalid serialized state", nameof(serializedStoreState)); + } + + // Use the internal constructor with all parameters to ensure partition key logic is centralized + return state.UseHierarchicalPartitioning && state.TenantId != null && state.UserId != null + ? new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId, ownsClient: false, state.TenantId, state.UserId) + : new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId, ownsClient: false); + } + + /// + public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default) + { +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + // Fetch most recent messages in descending order when limit is set, then reverse to ascending + var orderDirection = this.MaxMessagesToRetrieve.HasValue ? "DESC" : "ASC"; + var query = new QueryDefinition($"SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type ORDER BY c.timestamp {orderDirection}") + .WithParameter("@conversationId", this.ConversationId) + .WithParameter("@type", "ChatMessage"); + + var iterator = this._container.GetItemQueryIterator(query, requestOptions: new QueryRequestOptions + { + PartitionKey = this._partitionKey, + MaxItemCount = this.MaxItemCount // Configurable query performance + }); + + var messages = new List(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + + foreach (var document in response) + { + if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value) + { + break; + } + + if (!string.IsNullOrEmpty(document.Message)) + { + var message = JsonSerializer.Deserialize(document.Message, s_defaultJsonOptions); + if (message != null) + { + messages.Add(message); + } + } + } + + if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value) + { + break; + } + } + + // If we fetched in descending order (most recent first), reverse to ascending order + if (this.MaxMessagesToRetrieve.HasValue) + { + messages.Reverse(); + } + + return messages; + } + + /// + public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + if (messages is null) + { + throw new ArgumentNullException(nameof(messages)); + } + +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + var messageList = messages as IReadOnlyCollection ?? messages.ToList(); + if (messageList.Count == 0) + { + return; + } + + // Use transactional batch for atomic operations + if (messageList.Count > 1) + { + await this.AddMessagesInBatchAsync(messageList, cancellationToken).ConfigureAwait(false); + } + else + { + await this.AddSingleMessageAsync(messageList.First(), cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Adds multiple messages using transactional batch operations for atomicity. + /// + private async Task AddMessagesInBatchAsync(IReadOnlyCollection messages, CancellationToken cancellationToken) + { + var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // Process messages in optimal batch sizes + for (int i = 0; i < messages.Count; i += this.MaxBatchSize) + { + var batchMessages = messages.Skip(i).Take(this.MaxBatchSize).ToList(); + await this.ExecuteBatchOperationAsync(batchMessages, currentTimestamp, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Executes a single batch operation with enhanced error handling. + /// Cosmos SDK handles throttling (429) retries automatically. + /// + private async Task ExecuteBatchOperationAsync(List messages, long timestamp, CancellationToken cancellationToken) + { + // Create all documents upfront for validation and batch operation + var documents = new List(messages.Count); + foreach (var message in messages) + { + documents.Add(this.CreateMessageDocument(message, timestamp)); + } + + // Defensive check: Verify all messages share the same partition key values + // In hierarchical partitioning, this means same tenantId, userId, and sessionId + // In simple partitioning, this means same conversationId + if (documents.Count > 0) + { + if (this._useHierarchicalPartitioning) + { + // Verify all documents have matching hierarchical partition key components + var firstDoc = documents[0]; + if (!documents.All(d => d.TenantId == firstDoc.TenantId && d.UserId == firstDoc.UserId && d.SessionId == firstDoc.SessionId)) + { + throw new InvalidOperationException("All messages in a batch must share the same partition key values (tenantId, userId, sessionId)."); + } + } + else + { + // Verify all documents have matching conversationId + var firstConversationId = documents[0].ConversationId; + if (!documents.All(d => d.ConversationId == firstConversationId)) + { + throw new InvalidOperationException("All messages in a batch must share the same partition key value (conversationId)."); + } + } + } + + // All messages in this store share the same partition key by design + // Transactional batches require all items to share the same partition key + var batch = this._container.CreateTransactionalBatch(this._partitionKey); + + foreach (var document in documents) + { + batch.CreateItem(document); + } + + try + { + var response = await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException($"Batch operation failed with status: {response.StatusCode}. Details: {response.ErrorMessage}"); + } + } + catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge) + { + // If batch is too large, split into smaller batches + if (messages.Count == 1) + { + // Can't split further, use single operation + await this.AddSingleMessageAsync(messages[0], cancellationToken).ConfigureAwait(false); + return; + } + + // Split the batch in half and retry + var midpoint = messages.Count / 2; + var firstHalf = messages.Take(midpoint).ToList(); + var secondHalf = messages.Skip(midpoint).ToList(); + + await this.ExecuteBatchOperationAsync(firstHalf, timestamp, cancellationToken).ConfigureAwait(false); + await this.ExecuteBatchOperationAsync(secondHalf, timestamp, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Adds a single message to the store. + /// + private async Task AddSingleMessageAsync(ChatMessage message, CancellationToken cancellationToken) + { + var document = this.CreateMessageDocument(message, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + try + { + await this._container.CreateItemAsync(document, this._partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge) + { + throw new InvalidOperationException( + "Message exceeds Cosmos DB's maximum item size limit of 2MB. " + + "Message ID: " + message.MessageId + ", Serialized size is too large. " + + "Consider reducing message content or splitting into smaller messages.", + ex); + } + } + + /// + /// Creates a message document with enhanced metadata. + /// + private CosmosMessageDocument CreateMessageDocument(ChatMessage message, long timestamp) + { + return new CosmosMessageDocument + { + Id = Guid.NewGuid().ToString(), + ConversationId = this.ConversationId, + Timestamp = timestamp, + MessageId = message.MessageId, + Role = message.Role.Value, + Message = JsonSerializer.Serialize(message, s_defaultJsonOptions), + Type = "ChatMessage", // Type discriminator + Ttl = this.MessageTtlSeconds, // Configurable TTL + // Include hierarchical metadata when using hierarchical partitioning + TenantId = this._useHierarchicalPartitioning ? this._tenantId : null, + UserId = this._useHierarchicalPartitioning ? this._userId : null, + SessionId = this._useHierarchicalPartitioning ? this.ConversationId : null + }; + } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + var state = new StoreState + { + ConversationIdentifier = this.ConversationId, + TenantId = this._tenantId, + UserId = this._userId, + UseHierarchicalPartitioning = this._useHierarchicalPartitioning + }; + + var options = jsonSerializerOptions ?? s_defaultJsonOptions; + return JsonSerializer.SerializeToElement(state, options); + } + + /// + /// Gets the count of messages in this conversation. + /// This is an additional utility method beyond the base contract. + /// + /// The cancellation token. + /// The number of messages in the conversation. + public async Task GetMessageCountAsync(CancellationToken cancellationToken = default) + { +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + // Efficient count query + var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.Type = @type") + .WithParameter("@conversationId", this.ConversationId) + .WithParameter("@type", "ChatMessage"); + + var iterator = this._container.GetItemQueryIterator(query, requestOptions: new QueryRequestOptions + { + PartitionKey = this._partitionKey + }); + + // COUNT queries always return a result + var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + return response.FirstOrDefault(); + } + + /// + /// Deletes all messages in this conversation. + /// This is an additional utility method beyond the base contract. + /// + /// The cancellation token. + /// The number of messages deleted. + public async Task ClearMessagesAsync(CancellationToken cancellationToken = default) + { +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + // Batch delete for efficiency + var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.Type = @type") + .WithParameter("@conversationId", this.ConversationId) + .WithParameter("@type", "ChatMessage"); + + var iterator = this._container.GetItemQueryIterator(query, requestOptions: new QueryRequestOptions + { + PartitionKey = this._partitionKey, + MaxItemCount = this.MaxItemCount + }); + + var deletedCount = 0; + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + var batch = this._container.CreateTransactionalBatch(this._partitionKey); + var batchItemCount = 0; + + foreach (var itemId in response) + { + if (!string.IsNullOrEmpty(itemId)) + { + batch.DeleteItem(itemId); + batchItemCount++; + deletedCount++; + } + } + + if (batchItemCount > 0) + { + await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + } + + return deletedCount; + } + + /// + public void Dispose() + { + if (!this._disposed) + { + if (this._ownsClient) + { + this._cosmosClient?.Dispose(); + } + this._disposed = true; + } + } + + private sealed class StoreState + { + public string ConversationIdentifier { get; set; } = string.Empty; + public string? TenantId { get; set; } + public string? UserId { get; set; } + public bool UseHierarchicalPartitioning { get; set; } + } + + /// + /// Represents a document stored in Cosmos DB for chat messages. + /// + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB operations")] + private sealed class CosmosMessageDocument + { + [Newtonsoft.Json.JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [Newtonsoft.Json.JsonProperty("conversationId")] + public string ConversationId { get; set; } = string.Empty; + + [Newtonsoft.Json.JsonProperty("timestamp")] + public long Timestamp { get; set; } + + [Newtonsoft.Json.JsonProperty("messageId")] + public string? MessageId { get; set; } + + [Newtonsoft.Json.JsonProperty("role")] + public string? Role { get; set; } + + [Newtonsoft.Json.JsonProperty("message")] + public string Message { get; set; } = string.Empty; + + [Newtonsoft.Json.JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + [Newtonsoft.Json.JsonProperty("ttl")] + public int? Ttl { get; set; } + + /// + /// Tenant ID for hierarchical partitioning scenarios (optional). + /// + [Newtonsoft.Json.JsonProperty("tenantId")] + public string? TenantId { get; set; } + + /// + /// User ID for hierarchical partitioning scenarios (optional). + /// + [Newtonsoft.Json.JsonProperty("userId")] + public string? UserId { get; set; } + + /// + /// Session ID for hierarchical partitioning scenarios (same as ConversationId for compatibility). + /// + [Newtonsoft.Json.JsonProperty("sessionId")] + public string? SessionId { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs new file mode 100644 index 00000000000..62987b1dfce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Azure.Cosmos; +using Microsoft.Shared.Diagnostics; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Agents.AI.Workflows.Checkpointing; + +/// +/// Provides a Cosmos DB implementation of the abstract class. +/// +/// The type of objects to store as checkpoint values. +[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] +[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] +public class CosmosCheckpointStore : JsonCheckpointStore, IDisposable +{ + private readonly CosmosClient _cosmosClient; + private readonly Container _container; + private readonly bool _ownsClient; + private bool _disposed; + + /// + /// Initializes a new instance of the class using a connection string. + /// + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosCheckpointStore(string connectionString, string databaseId, string containerId) + { + var cosmosClientOptions = new CosmosClientOptions(); + + this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(connectionString), cosmosClientOptions); + this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); + this._ownsClient = true; + } + + /// + /// Initializes a new instance of the class using a TokenCredential for authentication. + /// + /// The Cosmos DB account endpoint URI. + /// The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential). + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId) + { + var cosmosClientOptions = new CosmosClientOptions + { + SerializerOptions = new CosmosSerializationOptions + { + PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase + } + }; + + this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), cosmosClientOptions); + this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); + this._ownsClient = true; + } + + /// + /// Initializes a new instance of the class using an existing . + /// + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// Thrown when is null. + /// Thrown when any string parameter is null or whitespace. + public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId) + { + this._cosmosClient = Throw.IfNull(cosmosClient); + + this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); + this._ownsClient = false; + } + + /// + /// Gets the identifier of the Cosmos DB database. + /// + public string DatabaseId => this._container.Database.Id; + + /// + /// Gets the identifier of the Cosmos DB container. + /// + public string ContainerId => this._container.Id; + + /// + public override async ValueTask CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null) + { + if (string.IsNullOrWhiteSpace(runId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(runId)); + } + +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + var checkpointId = Guid.NewGuid().ToString("N"); + var checkpointInfo = new CheckpointInfo(runId, checkpointId); + + var document = new CosmosCheckpointDocument + { + Id = $"{runId}_{checkpointId}", + RunId = runId, + CheckpointId = checkpointId, + Value = JToken.Parse(value.GetRawText()), + ParentCheckpointId = parent?.CheckpointId, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + await this._container.CreateItemAsync(document, new PartitionKey(runId)).ConfigureAwait(false); + return checkpointInfo; + } + + /// + public override async ValueTask RetrieveCheckpointAsync(string runId, CheckpointInfo key) + { + if (string.IsNullOrWhiteSpace(runId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(runId)); + } + + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + var id = $"{runId}_{key.CheckpointId}"; + + try + { + var response = await this._container.ReadItemAsync(id, new PartitionKey(runId)).ConfigureAwait(false); + using var document = JsonDocument.Parse(response.Resource.Value.ToString()); + return document.RootElement.Clone(); + } + catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new InvalidOperationException($"Checkpoint with ID '{key.CheckpointId}' for run '{runId}' not found."); + } + } + + /// + public override async ValueTask> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null) + { + if (string.IsNullOrWhiteSpace(runId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(runId)); + } + +#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks + if (this._disposed) + { + throw new ObjectDisposedException(this.GetType().FullName); + } +#pragma warning restore CA1513 + + QueryDefinition query = withParent == null + ? new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId ORDER BY c.timestamp ASC") + .WithParameter("@runId", runId) + : new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId AND c.parentCheckpointId = @parentCheckpointId ORDER BY c.timestamp ASC") + .WithParameter("@runId", runId) + .WithParameter("@parentCheckpointId", withParent.CheckpointId); + + var iterator = this._container.GetItemQueryIterator(query); + var checkpoints = new List(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync().ConfigureAwait(false); + checkpoints.AddRange(response.Select(r => new CheckpointInfo(r.RunId, r.CheckpointId))); + } + + return checkpoints; + } + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!this._disposed) + { + if (disposing && this._ownsClient) + { + this._cosmosClient?.Dispose(); + } + this._disposed = true; + } + } + + /// + /// Represents a checkpoint document stored in Cosmos DB. + /// + internal sealed class CosmosCheckpointDocument + { + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("runId")] + public string RunId { get; set; } = string.Empty; + + [JsonProperty("checkpointId")] + public string CheckpointId { get; set; } = string.Empty; + + [JsonProperty("value")] + public JToken Value { get; set; } = JValue.CreateNull(); + + [JsonProperty("parentCheckpointId")] + public string? ParentCheckpointId { get; set; } + + [JsonProperty("timestamp")] + public long Timestamp { get; set; } + } + + /// + /// Represents the result of a checkpoint query. + /// + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB query deserialization")] + private sealed class CheckpointQueryResult + { + public string RunId { get; set; } = string.Empty; + public string CheckpointId { get; set; } = string.Empty; + } +} + +/// +/// Provides a non-generic Cosmos DB implementation of the abstract class. +/// +[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] +[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] +public sealed class CosmosCheckpointStore : CosmosCheckpointStore +{ + /// + public CosmosCheckpointStore(string connectionString, string databaseId, string containerId) + : base(connectionString, databaseId, containerId) + { + } + + /// + public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId) + : base(accountEndpoint, tokenCredential, databaseId, containerId) + { + } + + /// + public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId) + : base(cosmosClient, databaseId, containerId) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBChatExtensions.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBChatExtensions.cs new file mode 100644 index 00000000000..4e3b66fd543 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBChatExtensions.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Azure.Identity; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods for integrating Cosmos DB chat message storage with the Agent Framework. +/// +public static class CosmosDBChatExtensions +{ + /// + /// Configures the agent to use Cosmos DB for message storage with connection string authentication. + /// + /// The chat client agent options to configure. + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The configured . + /// Thrown when is null. + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")] + public static ChatClientAgentOptions WithCosmosDBMessageStore( + this ChatClientAgentOptions options, + string connectionString, + string databaseId, + string containerId) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(connectionString, databaseId, containerId); + return options; + } + + /// + /// Configures the agent to use Cosmos DB for message storage with managed identity authentication. + /// + /// The chat client agent options to configure. + /// The Cosmos DB account endpoint URI. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The configured . + /// Thrown when is null. + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")] + public static ChatClientAgentOptions WithCosmosDBMessageStoreUsingManagedIdentity( + this ChatClientAgentOptions options, + string accountEndpoint, + string databaseId, + string containerId) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId); + return options; + } + + /// + /// Configures the agent to use Cosmos DB for message storage with an existing . + /// + /// The chat client agent options to configure. + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// The configured . + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")] + public static ChatClientAgentOptions WithCosmosDBMessageStore( + this ChatClientAgentOptions options, + CosmosClient cosmosClient, + string databaseId, + string containerId) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(cosmosClient, databaseId, containerId); + return options; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBWorkflowExtensions.cs new file mode 100644 index 00000000000..9d8bc52e68c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBWorkflowExtensions.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides extension methods for integrating Cosmos DB checkpoint storage with the Agent Framework. +/// +public static class CosmosDBWorkflowExtensions +{ + /// + /// Creates a Cosmos DB checkpoint store using connection string authentication. + /// + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// A new instance of . + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] + public static CosmosCheckpointStore CreateCheckpointStore( + string connectionString, + string databaseId, + string containerId) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString)); + } + + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(containerId)); + } + + return new CosmosCheckpointStore(connectionString, databaseId, containerId); + } + + /// + /// Creates a Cosmos DB checkpoint store using managed identity authentication. + /// + /// The Cosmos DB account endpoint URI. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// A new instance of . + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] + public static CosmosCheckpointStore CreateCheckpointStoreUsingManagedIdentity( + string accountEndpoint, + string databaseId, + string containerId) + { + if (string.IsNullOrWhiteSpace(accountEndpoint)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint)); + } + + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(containerId)); + } + + return new CosmosCheckpointStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId); + } + + /// + /// Creates a Cosmos DB checkpoint store using an existing . + /// + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// A new instance of . + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] + public static CosmosCheckpointStore CreateCheckpointStore( + CosmosClient cosmosClient, + string databaseId, + string containerId) + { + if (cosmosClient is null) + { + throw new ArgumentNullException(nameof(cosmosClient)); + } + + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(containerId)); + } + + return new CosmosCheckpointStore(cosmosClient, databaseId, containerId); + } + + /// + /// Creates a generic Cosmos DB checkpoint store using connection string authentication. + /// + /// The type of objects to store as checkpoint values. + /// The Cosmos DB connection string. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// A new instance of . + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] + public static CosmosCheckpointStore CreateCheckpointStore( + string connectionString, + string databaseId, + string containerId) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString)); + } + + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(containerId)); + } + + return new CosmosCheckpointStore(connectionString, databaseId, containerId); + } + + /// + /// Creates a generic Cosmos DB checkpoint store using managed identity authentication. + /// + /// The type of objects to store as checkpoint values. + /// The Cosmos DB account endpoint URI. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// A new instance of . + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] + public static CosmosCheckpointStore CreateCheckpointStoreUsingManagedIdentity( + string accountEndpoint, + string databaseId, + string containerId) + { + if (string.IsNullOrWhiteSpace(accountEndpoint)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint)); + } + + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(containerId)); + } + + return new CosmosCheckpointStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId); + } + + /// + /// Creates a generic Cosmos DB checkpoint store using an existing . + /// + /// The type of objects to store as checkpoint values. + /// The instance to use for Cosmos DB operations. + /// The identifier of the Cosmos DB database. + /// The identifier of the Cosmos DB container. + /// A new instance of . + /// Thrown when any required parameter is null. + /// Thrown when any string parameter is null or whitespace. + [RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")] + [RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")] + public static CosmosCheckpointStore CreateCheckpointStore( + CosmosClient cosmosClient, + string databaseId, + string containerId) + { + if (cosmosClient is null) + { + throw new ArgumentNullException(nameof(cosmosClient)); + } + + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException("Cannot be null or whitespace", nameof(containerId)); + } + + return new CosmosCheckpointStore(cosmosClient, databaseId, containerId); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/Microsoft.Agents.AI.CosmosNoSql.csproj b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/Microsoft.Agents.AI.CosmosNoSql.csproj new file mode 100644 index 00000000000..7e13ec5998e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/Microsoft.Agents.AI.CosmosNoSql.csproj @@ -0,0 +1,41 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI + $(NoWarn);MEAI001 + preview + + + + true + true + true + true + true + true + + + + + + + Microsoft Agent Framework Cosmos DB NoSQL Integration + Provides Cosmos DB NoSQL implementations for Microsoft Agent Framework storage abstractions including ChatMessageStore and CheckpointStore. + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs new file mode 100644 index 00000000000..808bf76462f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/AgentBotElementYaml.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Yaml; +using Microsoft.Extensions.Configuration; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Helper methods for creating from YAML. +/// +internal static class AgentBotElementYaml +{ + /// + /// Convert the given YAML text to a model. + /// + /// YAML representation of the to use to create the prompt function. + /// Optional instance which provides environment variables to the template. + [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] + public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null) + { + Throw.IfNullOrEmpty(text); + + using var yamlReader = new StringReader(text); + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new InvalidDataException("Text does not contain a valid agent definition."); + + if (rootElement is not GptComponentMetadata promptAgent) + { + throw new InvalidDataException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(GptComponentMetadata)}."); + } + + var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration); + + return botDefinition.Descendants().OfType().First(); + } + + #region private + private sealed class AgentFeatureConfiguration : IFeatureConfiguration + { + public long GetInt64Value(string settingName, long defaultValue) => defaultValue; + + public string GetStringValue(string settingName, string defaultValue) => defaultValue; + + public bool IsEnvironmentFeatureEnabled(string featureName, bool defaultValue) => true; + + public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => defaultValue; + } + + public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null) + { + var botBuilder = + new BotDefinition.Builder + { + Components = + { + new GptComponent.Builder + { + SchemaName = "default-schema", + Metadata = element.ToBuilder(), + } + } + }; + + if (configuration is not null) + { + foreach (var kvp in configuration.AsEnumerable().Where(kvp => kvp.Value is not null)) + { + botBuilder.EnvironmentVariables.Add(new EnvironmentVariableDefinition.Builder() + { + SchemaName = kvp.Key, + Id = Guid.NewGuid(), + DisplayName = kvp.Key, + ValueComponent = new EnvironmentVariableValue.Builder() + { + Id = Guid.NewGuid(), + Value = kvp.Value!, + }, + }); + } + } + + return botBuilder.Build(); + } + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs new file mode 100644 index 00000000000..49027367f1c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides a which aggregates multiple agent factories. +/// +public sealed class AggregatorPromptAgentFactory : PromptAgentFactory +{ + private readonly PromptAgentFactory[] _agentFactories; + + /// Initializes the instance. + /// Ordered instances to aggregate. + /// + /// Where multiple instances are provided, the first factory that supports the will be used. + /// + public AggregatorPromptAgentFactory(params PromptAgentFactory[] agentFactories) + { + Throw.IfNullOrEmpty(agentFactories); + + foreach (PromptAgentFactory agentFactory in agentFactories) + { + Throw.IfNull(agentFactory, nameof(agentFactories)); + } + + this._agentFactories = agentFactories; + } + + /// + public override async Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + Throw.IfNull(promptAgent); + + foreach (var agentFactory in this._agentFactories) + { + var agent = await agentFactory.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false); + if (agent is not null) + { + return agent; + } + } + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs new file mode 100644 index 00000000000..a7918de0510 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an which creates instances of . +/// +public sealed class ChatClientPromptAgentFactory : PromptAgentFactory +{ + /// + /// Creates a new instance of the class. + /// + public ChatClientPromptAgentFactory(IChatClient chatClient, IList? functions = null, RecalcEngine? engine = null, IConfiguration? configuration = null, ILoggerFactory? loggerFactory = null) : base(engine, configuration) + { + Throw.IfNull(chatClient); + + this._chatClient = chatClient; + this._functions = functions; + this._loggerFactory = loggerFactory; + } + + /// + public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + Throw.IfNull(promptAgent); + + var options = new ChatClientAgentOptions() + { + Name = promptAgent.Name, + Description = promptAgent.Description, + ChatOptions = promptAgent.GetChatOptions(this.Engine, this._functions), + }; + + var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory); + + return Task.FromResult(agent); + } + + #region private + private readonly IChatClient _chatClient; + private readonly IList? _functions; + private readonly ILoggerFactory? _loggerFactory; + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs new file mode 100644 index 00000000000..9926e0e6bee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/BoolExpressionExtensions.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class BoolExpressionExtensions +{ + /// + /// Evaluates the given using the provided . + /// + /// Expression to evaluate. + /// Recalc engine to use for evaluation. + /// The evaluated boolean value, or null if the expression is null or cannot be evaluated. + internal static bool? Eval(this BoolExpression? expression, RecalcEngine? engine) + { + if (expression is null) + { + return null; + } + + if (expression.IsLiteral) + { + return expression.LiteralValue; + } + + if (engine is null) + { + return null; + } + + if (expression.IsExpression) + { + return engine.Eval(expression.ExpressionText!).AsBoolean(); + } + else if (expression.IsVariableReference) + { + var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + if (formulaValue is BooleanValue booleanValue) + { + return booleanValue.Value; + } + + if (formulaValue is StringValue stringValue && bool.TryParse(stringValue.Value, out bool result)) + { + return result; + } + } + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/CodeInterpreterToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/CodeInterpreterToolExtensions.cs new file mode 100644 index 00000000000..e6f13d5f547 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/CodeInterpreterToolExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class CodeInterpreterToolExtensions +{ + /// + /// Creates a from a . + /// + /// Instance of + internal static HostedCodeInterpreterTool AsCodeInterpreterTool(this CodeInterpreterTool tool) + { + Throw.IfNull(tool); + + return new HostedCodeInterpreterTool(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/FileSearchToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/FileSearchToolExtensions.cs new file mode 100644 index 00000000000..5e1cb1bb5fd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/FileSearchToolExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class FileSearchToolExtensions +{ + /// + /// Create a from a . + /// + /// Instance of + internal static HostedFileSearchTool CreateFileSearchTool(this FileSearchTool tool) + { + Throw.IfNull(tool); + + return new HostedFileSearchTool() + { + MaximumResultCount = (int?)tool.MaximumResultCount?.LiteralValue, + Inputs = tool.VectorStoreIds?.LiteralValue.Select(id => (AIContent)new HostedVectorStoreContent(id)).ToList(), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/FunctionToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/FunctionToolExtensions.cs new file mode 100644 index 00000000000..2c54d7e7491 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/FunctionToolExtensions.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class FunctionToolExtensions +{ + /// + /// Creates a from a . + /// + /// + /// If a matching function already exists in the provided list, it will be returned. + /// Otherwise, a new function declaration will be created. + /// + /// Instance of + /// Instance of + internal static AITool CreateOrGetAITool(this InvokeClientTaskAction tool, IList? functions) + { + Throw.IfNull(tool); + Throw.IfNull(tool.Name); + + // use the tool from the provided list if it exists + if (functions is not null) + { + var function = functions.FirstOrDefault(f => tool.Matches(f)); + + if (function is not null) + { + return function; + } + } + + return AIFunctionFactory.CreateDeclaration( + name: tool.Name, + description: tool.Description, + jsonSchema: tool.ClientActionInputSchema?.GetSchema() ?? s_defaultSchema); + } + + /// + /// Checks if a matches an . + /// + /// Instance of + /// Instance of + internal static bool Matches(this InvokeClientTaskAction tool, AIFunction aiFunc) + { + Throw.IfNull(tool); + Throw.IfNull(aiFunc); + + return tool.Name == aiFunc.Name; + } + + private static readonly JsonElement s_defaultSchema = JsonDocument.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").RootElement; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs new file mode 100644 index 00000000000..479d6ccea39 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/IntExpressionExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class IntExpressionExtensions +{ + /// + /// Evaluates the given using the provided . + /// + /// Expression to evaluate. + /// Recalc engine to use for evaluation. + /// The evaluated integer value, or null if the expression is null or cannot be evaluated. + internal static long? Eval(this IntExpression? expression, RecalcEngine? engine) + { + if (expression is null) + { + return null; + } + + if (expression.IsLiteral) + { + return expression.LiteralValue; + } + + if (engine is null) + { + return null; + } + + if (expression.IsExpression) + { + return (long)engine.Eval(expression.ExpressionText!).AsDouble(); + } + else if (expression.IsVariableReference) + { + var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + if (formulaValue is NumberValue numberValue) + { + return (long)numberValue.Value; + } + + if (formulaValue is StringValue stringValue && int.TryParse(stringValue.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)) + { + return result; + } + } + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/McpServerToolApprovalModeExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/McpServerToolApprovalModeExtensions.cs new file mode 100644 index 00000000000..ee5632368b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/McpServerToolApprovalModeExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class McpServerToolApprovalModeExtensions +{ + /// + /// Converts a to a . + /// + /// Instance of + internal static HostedMcpServerToolApprovalMode AsHostedMcpServerToolApprovalMode(this McpServerToolApprovalMode mode) + { + return mode switch + { + McpServerToolNeverRequireApprovalMode => HostedMcpServerToolApprovalMode.NeverRequire, + McpServerToolAlwaysRequireApprovalMode => HostedMcpServerToolApprovalMode.AlwaysRequire, + McpServerToolRequireSpecificApprovalMode specificMode => + HostedMcpServerToolApprovalMode.RequireSpecific( + specificMode?.AlwaysRequireApprovalToolNames?.LiteralValue ?? [], + specificMode?.NeverRequireApprovalToolNames?.LiteralValue ?? [] + ), + _ => HostedMcpServerToolApprovalMode.AlwaysRequire, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/McpServerToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/McpServerToolExtensions.cs new file mode 100644 index 00000000000..763e4026250 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/McpServerToolExtensions.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class McpServerToolExtensions +{ + /// + /// Creates a from a . + /// + /// Instance of + internal static HostedMcpServerTool CreateHostedMcpTool(this McpServerTool tool) + { + Throw.IfNull(tool); + Throw.IfNull(tool.ServerName?.LiteralValue); + Throw.IfNull(tool.Connection); + + var connection = tool.Connection as AnonymousConnection ?? throw new ArgumentException("Only AnonymousConnection is supported for MCP Server Tool connections.", nameof(tool)); + var serverUrl = connection.Endpoint?.LiteralValue; + Throw.IfNullOrEmpty(serverUrl, nameof(connection.Endpoint)); + + return new HostedMcpServerTool(tool.ServerName.LiteralValue, serverUrl) + { + ServerDescription = tool.ServerDescription?.LiteralValue, + AllowedTools = tool.AllowedTools?.LiteralValue, + ApprovalMode = tool.ApprovalMode?.AsHostedMcpServerToolApprovalMode(), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/ModelOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/ModelOptionsExtensions.cs new file mode 100644 index 00000000000..7ad4d26a6b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/ModelOptionsExtensions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class ModelOptionsExtensions +{ + /// + /// Converts the 'chatToolMode' property from a to a . + /// + /// Instance of + internal static ChatToolMode? AsChatToolMode(this ModelOptions modelOptions) + { + Throw.IfNull(modelOptions); + + var mode = modelOptions.ExtensionData?.GetPropertyOrNull(InitializablePropertyPath.Create("chatToolMode"))?.Value; + if (mode is null) + { + return null; + } + + return mode switch + { + "auto" => ChatToolMode.Auto, + "none" => ChatToolMode.None, + "require_any" => ChatToolMode.RequireAny, + _ => ChatToolMode.RequireSpecific(mode), + }; + } + + /// + /// Retrieves the 'additional_properties' property from a . + /// + /// Instance of + /// List of properties which should not be included in additional properties. + internal static AdditionalPropertiesDictionary? GetAdditionalProperties(this ModelOptions modelOptions, string[] excludedProperties) + { + Throw.IfNull(modelOptions); + + var options = modelOptions.ExtensionData; + if (options is null || options.Properties.Count == 0) + { + return null; + } + + var additionalProperties = options.Properties + .Where(kvp => !excludedProperties.Contains(kvp.Key)) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.ToObject()); + + if (additionalProperties is null || additionalProperties.Count == 0) + { + return null; + } + + return new AdditionalPropertiesDictionary(additionalProperties); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs new file mode 100644 index 00000000000..cfa36185cce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/NumberExpressionExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class NumberExpressionExtensions +{ + /// + /// Evaluates the given using the provided . + /// + /// Expression to evaluate. + /// Recalc engine to use for evaluation. + /// The evaluated number value, or null if the expression is null or cannot be evaluated. + internal static double? Eval(this NumberExpression? expression, RecalcEngine? engine) + { + if (expression is null) + { + return null; + } + + if (expression.IsLiteral) + { + return expression.LiteralValue; + } + + if (engine is null) + { + return null; + } + + if (expression.IsExpression) + { + return engine.Eval(expression.ExpressionText!).AsDouble(); + } + else if (expression.IsVariableReference) + { + var formulaValue = engine.Eval(expression.VariableReference!.VariableName); + if (formulaValue is NumberValue numberValue) + { + return numberValue.Value; + } + + if (formulaValue is StringValue stringValue && double.TryParse(stringValue.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + { + return result; + } + } + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs new file mode 100644 index 00000000000..1597c0c54b9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PromptAgentExtensions.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +public static class PromptAgentExtensions +{ + /// + /// Retrieves the 'options' property from a as a instance. + /// + /// Instance of + /// Instance of + /// Instance of + public static ChatOptions? GetChatOptions(this GptComponentMetadata promptAgent, RecalcEngine? engine, IList? functions) + { + Throw.IfNull(promptAgent); + + var outputSchema = promptAgent.OutputType; + var modelOptions = promptAgent.Model?.Options; + + var tools = promptAgent.GetAITools(functions); + + if (modelOptions is null && tools is null) + { + return null; + } + + return new ChatOptions() + { + Instructions = promptAgent.Instructions?.ToTemplateString(), + Temperature = (float?)modelOptions?.Temperature?.Eval(engine), + MaxOutputTokens = (int?)modelOptions?.MaxOutputTokens?.Eval(engine), + TopP = (float?)modelOptions?.TopP?.Eval(engine), + TopK = (int?)modelOptions?.TopK?.Eval(engine), + FrequencyPenalty = (float?)modelOptions?.FrequencyPenalty?.Eval(engine), + PresencePenalty = (float?)modelOptions?.PresencePenalty?.Eval(engine), + Seed = modelOptions?.Seed?.Eval(engine), + ResponseFormat = outputSchema?.AsChatResponseFormat(), + ModelId = promptAgent.Model?.ModelNameHint, + StopSequences = modelOptions?.StopSequences, + AllowMultipleToolCalls = modelOptions?.AllowMultipleToolCalls?.Eval(engine), + ToolMode = modelOptions?.AsChatToolMode(), + Tools = tools, + AdditionalProperties = modelOptions?.GetAdditionalProperties(s_chatOptionProperties), + }; + } + + /// + /// Retrieves the 'tools' property from a . + /// + /// Instance of + /// Instance of + internal static List? GetAITools(this GptComponentMetadata promptAgent, IList? functions) + { + return promptAgent.Tools.Select(tool => + { + return tool switch + { + CodeInterpreterTool => ((CodeInterpreterTool)tool).AsCodeInterpreterTool(), + InvokeClientTaskAction => ((InvokeClientTaskAction)tool).CreateOrGetAITool(functions), + McpServerTool => ((McpServerTool)tool).CreateHostedMcpTool(), + FileSearchTool => ((FileSearchTool)tool).CreateFileSearchTool(), + WebSearchTool => ((WebSearchTool)tool).CreateWebSearchTool(), + _ => throw new NotSupportedException($"Unable to create tool definition because of unsupported tool type: {tool.Kind}, supported tool types are: {string.Join(",", s_validToolKinds)}"), + }; + }).ToList() ?? []; + } + + #region private + private const string CodeInterpreterKind = "codeInterpreter"; + private const string FileSearchKind = "fileSearch"; + private const string FunctionKind = "function"; + private const string WebSearchKind = "webSearch"; + private const string McpKind = "mcp"; + + private static readonly string[] s_validToolKinds = + [ + CodeInterpreterKind, + FileSearchKind, + FunctionKind, + WebSearchKind, + McpKind + ]; + + private static readonly string[] s_chatOptionProperties = + [ + "allowMultipleToolCalls", + "conversationId", + "chatToolMode", + "frequencyPenalty", + "additionalInstructions", + "maxOutputTokens", + "modelId", + "presencePenalty", + "responseFormat", + "seed", + "stopSequences", + "temperature", + "topK", + "topP", + "toolMode", + "tools", + ]; + + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PropertyInfoExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PropertyInfoExtensions.cs new file mode 100644 index 00000000000..a62fddec88c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/PropertyInfoExtensions.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +public static class PropertyInfoExtensions +{ + /// + /// Creates a of and + /// from an of and . + /// + /// A read-only dictionary of property names and their corresponding objects. + public static Dictionary AsObjectDictionary(this IReadOnlyDictionary properties) + { + var result = new Dictionary(); + + foreach (var property in properties) + { + result[property.Key] = BuildPropertySchema(property.Value); + } + + return result; + } + + #region private + private static Dictionary BuildPropertySchema(PropertyInfo propertyInfo) + { + var propertySchema = new Dictionary(); + + // Map the DataType to JSON schema type and add type-specific properties + switch (propertyInfo.Type) + { + case StringDataType: + propertySchema["type"] = "string"; + break; + case NumberDataType: + propertySchema["type"] = "number"; + break; + case BooleanDataType: + propertySchema["type"] = "boolean"; + break; + case DateTimeDataType: + propertySchema["type"] = "string"; + propertySchema["format"] = "date-time"; + break; + case DateDataType: + propertySchema["type"] = "string"; + propertySchema["format"] = "date"; + break; + case TimeDataType: + propertySchema["type"] = "string"; + propertySchema["format"] = "time"; + break; + case RecordDataType nestedRecordType: +#pragma warning disable IL2026, IL3050 + // For nested records, recursively build the schema + var nestedSchema = nestedRecordType.GetSchema(); + var nestedJson = JsonSerializer.Serialize(nestedSchema, ElementSerializer.CreateOptions()); + var nestedDict = JsonSerializer.Deserialize>(nestedJson, ElementSerializer.CreateOptions()); +#pragma warning restore IL2026, IL3050 + if (nestedDict != null) + { + return nestedDict; + } + propertySchema["type"] = "object"; + break; + case TableDataType tableType: + propertySchema["type"] = "array"; + // TableDataType has Properties like RecordDataType + propertySchema["items"] = new Dictionary + { + ["type"] = "object", + ["properties"] = AsObjectDictionary(tableType.Properties), + ["additionalProperties"] = false + }; + break; + default: + propertySchema["type"] = "string"; + break; + } + + // Add description if available + if (!string.IsNullOrEmpty(propertyInfo.Description)) + { + propertySchema["description"] = propertyInfo.Description; + } + + return propertySchema; + } + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/RecordDataTypeExtensions.cs new file mode 100644 index 00000000000..b5c5793cab7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +public static class RecordDataTypeExtensions +{ + /// + /// Creates a from a . + /// + /// Instance of + internal static ChatResponseFormat? AsChatResponseFormat(this RecordDataType recordDataType) + { + Throw.IfNull(recordDataType); + + if (recordDataType.Properties.Count == 0) + { + return null; + } + + // TODO: Consider adding schemaName and schemaDescription parameters to this method. + return ChatResponseFormat.ForJsonSchema( + schema: recordDataType.GetSchema(), + schemaName: recordDataType.GetSchemaName(), + schemaDescription: recordDataType.GetSchemaDescription()); + } + + /// + /// Converts a to a . + /// + /// Instance of +#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code +#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. + public static JsonElement GetSchema(this RecordDataType recordDataType) + { + Throw.IfNull(recordDataType); + + var schemaObject = new Dictionary + { + ["type"] = "object", + ["properties"] = recordDataType.Properties.AsObjectDictionary(), + ["additionalProperties"] = false + }; + + var json = JsonSerializer.Serialize(schemaObject, ElementSerializer.CreateOptions()); + return JsonSerializer.Deserialize(json); + } +#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. +#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code + + /// + /// Retrieves the 'schemaName' property from a . + /// + private static string? GetSchemaName(this RecordDataType recordDataType) + { + Throw.IfNull(recordDataType); + + return recordDataType.ExtensionData?.GetPropertyOrNull(InitializablePropertyPath.Create("schemaName"))?.Value; + } + + /// + /// Retrieves the 'schemaDescription' property from a . + /// + private static string? GetSchemaDescription(this RecordDataType recordDataType) + { + Throw.IfNull(recordDataType); + + return recordDataType.ExtensionData?.GetPropertyOrNull(InitializablePropertyPath.Create("schemaDescription"))?.Value; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/RecordDataValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/RecordDataValueExtensions.cs new file mode 100644 index 00000000000..6351b7badbe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/RecordDataValueExtensions.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +public static class RecordDataValueExtensions +{ + /// + /// Retrieves a 'number' property from a + /// + /// Instance of + /// Path of the property to retrieve + public static decimal? GetNumber(this RecordDataValue recordData, string propertyPath) + { + Throw.IfNull(recordData); + + var numberValue = recordData.GetPropertyOrNull(InitializablePropertyPath.Create(propertyPath)); + return numberValue?.Value; + } + + /// + /// Retrieves a nullable boolean value from the specified property path within the given record data. + /// + /// Instance of + /// Path of the property to retrieve + public static bool? GetBoolean(this RecordDataValue recordData, string propertyPath) + { + Throw.IfNull(recordData); + + var booleanValue = recordData.GetPropertyOrNull(InitializablePropertyPath.Create(propertyPath)); + return booleanValue?.Value; + } + + /// + /// Converts a to a . + /// + /// Instance of + public static IReadOnlyDictionary ToDictionary(this RecordDataValue recordData) + { + Throw.IfNull(recordData); + + return recordData.Properties.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.ToString() ?? string.Empty + ); + } + + /// + /// Retrieves the 'schema' property from a . + /// + /// Instance of +#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code +#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. + public static JsonElement? GetSchema(this RecordDataValue recordData) + { + Throw.IfNull(recordData); + + try + { + var schemaStr = recordData.GetPropertyOrNull(InitializablePropertyPath.Create("json_schema.schema")); + if (schemaStr?.Value is not null) + { + return JsonSerializer.Deserialize(schemaStr.Value); + } + } + catch (InvalidCastException) + { + // Ignore and try next + } + + var responseFormRec = recordData.GetPropertyOrNull(InitializablePropertyPath.Create("json_schema.schema")); + if (responseFormRec is not null) + { + var json = JsonSerializer.Serialize(responseFormRec, ElementSerializer.CreateOptions()); + return JsonSerializer.Deserialize(json); + } + + return null; + } +#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. +#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code + + internal static object? ToObject(this DataValue? value) + { + if (value is null) + { + return null; + } + return value switch + { + StringDataValue s => s.Value, + NumberDataValue n => n.Value, + BooleanDataValue b => b.Value, + TableDataValue t => t.Values.Select(v => v.ToObject()).ToList(), + RecordDataValue r => r.Properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToObject()), + _ => throw new NotSupportedException($"Unsupported DataValue type: {value.GetType().FullName}"), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs new file mode 100644 index 00000000000..40c1b7c9c8a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/StringExpressionExtensions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +public static class StringExpressionExtensions +{ + /// + /// Evaluates the given using the provided . + /// + /// Expression to evaluate. + /// Recalc engine to use for evaluation. + /// The evaluated string value, or null if the expression is null or cannot be evaluated. + public static string? Eval(this StringExpression? expression, RecalcEngine? engine) + { + if (expression is null) + { + return null; + } + + if (expression.IsLiteral) + { + return expression.LiteralValue?.ToString(); + } + + if (engine is null) + { + return null; + } + + if (expression.IsExpression) + { + return engine.Eval(expression.ExpressionText!).ToString(); + } + else if (expression.IsVariableReference) + { + var stringValue = engine.Eval(expression.VariableReference!.VariableName) as StringValue; + return stringValue?.Value; + } + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/WebSearchToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/WebSearchToolExtensions.cs new file mode 100644 index 00000000000..e6ee360308e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/WebSearchToolExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Bot.ObjectModel; + +/// +/// Extension methods for . +/// +internal static class WebSearchToolExtensions +{ + /// + /// Create a from a . + /// + /// Instance of + internal static HostedWebSearchTool CreateWebSearchTool(this WebSearchTool tool) + { + Throw.IfNull(tool); + + return new HostedWebSearchTool(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs new file mode 100644 index 00000000000..1cc24055d90 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Extensions/YamlAgentFactoryExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Extension methods for to support YAML based agent definitions. +/// +public static class YamlAgentFactoryExtensions +{ + /// + /// Create a from the given agent YAML. + /// + /// which will be used to create the agent. + /// Text string containing the YAML representation of an . + /// Optional cancellation token + [RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")] + public static Task CreateFromYamlAsync(this PromptAgentFactory agentFactory, string agentYaml, CancellationToken cancellationToken = default) + { + Throw.IfNull(agentFactory); + Throw.IfNullOrEmpty(agentYaml); + + var agentDefinition = AgentBotElementYaml.FromYaml(agentYaml); + + return agentFactory.CreateAsync( + agentDefinition, + cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj new file mode 100644 index 00000000000..306ba27e97b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj @@ -0,0 +1,45 @@ + + + + preview + $(NoWarn);MEAI001 + false + + + + true + true + true + + + + + + + Microsoft Agent Framework Declarative Agents + Provides Microsoft Agent Framework support for declarative agents. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs new file mode 100644 index 00000000000..cb277b06da4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a factory for creating instances. +/// +public abstract class PromptAgentFactory +{ + /// + /// Initializes a new instance of the class. + /// + /// Optional , if none is provided a default instance will be created. + /// Optional configuration to be added as variables to the . + protected PromptAgentFactory(RecalcEngine? engine = null, IConfiguration? configuration = null) + { + this.Engine = engine ?? new RecalcEngine(); + + if (configuration is not null) + { + foreach (var kvp in configuration.AsEnumerable()) + { + this.Engine.UpdateVariable(kvp.Key, kvp.Value ?? string.Empty); + } + } + } + + /// + /// Gets the Power Fx recalculation engine used to evaluate expressions in agent definitions. + /// This engine is configured with variables from the provided during construction. + /// + protected RecalcEngine Engine { get; } + + /// + /// Create a from the specified . + /// + /// Definition of the agent to create. + /// Optional cancellation token. + /// The created , if null the agent type is not supported. + public async Task CreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + Throw.IfNull(promptAgent); + + var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false); + return agent ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported."); + } + + /// + /// Tries to create a from the specified . + /// + /// Definition of the agent to create. + /// Optional cancellation token. + /// The created , if null the agent type is not supported. + public abstract Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIMiddleware.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIMiddleware.cs index fc6dd512ecd..a2b210ca4d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIMiddleware.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIMiddleware.cs @@ -4,6 +4,7 @@ using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -13,8 +14,11 @@ namespace Microsoft.Agents.AI.DevUI; /// /// Handler that serves embedded DevUI resource files from the 'resources' directory. /// -internal sealed class DevUIMiddleware +internal sealed partial class DevUIMiddleware { + [GeneratedRegex(@"[\r\n]+")] + private static partial Regex NewlineRegex(); + private const string GZipEncodingValue = "gzip"; private static readonly StringValues s_gzipEncodingHeader = new(GZipEncodingValue); private static readonly Assembly s_assembly = typeof(DevUIMiddleware).Assembly; @@ -70,7 +74,7 @@ public async Task HandleRequestAsync(HttpContext context) // This ensures relative URLs in the HTML work correctly if (string.Equals(path, this._basePath, StringComparison.OrdinalIgnoreCase) && !path.EndsWith('/')) { - var redirectUrl = $"{path}/"; + var redirectUrl = this._basePath + "/"; if (context.Request.QueryString.HasValue) { redirectUrl += context.Request.QueryString.Value; @@ -78,7 +82,8 @@ public async Task HandleRequestAsync(HttpContext context) context.Response.StatusCode = StatusCodes.Status301MovedPermanently; context.Response.Headers.Location = redirectUrl; - this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", path, redirectUrl); + + this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", NewlineRegex().Replace(path, ""), NewlineRegex().Replace(redirectUrl, "")); return; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs index 6e1260cdc74..df717c6952e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs @@ -55,7 +55,7 @@ internal sealed record MetaResponse /// - "openai_proxy": Whether the server can proxy requests to OpenAI /// [JsonPropertyName("capabilities")] - public Dictionary Capabilities { get; init; } = new(); + public Dictionary Capabilities { get; init; } = []; /// /// Gets a value indicating whether Bearer token authentication is required for API access. diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj b/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj index 6c9c5bd9e35..30943cb5c42 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj @@ -1,8 +1,7 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable enable Microsoft.Agents.AI.DevUI @@ -28,10 +27,6 @@ - - - - Microsoft Agent Framework Developer UI diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 1a117aff143..49c55be5dac 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -99,6 +99,8 @@ public override async Task RunAsync( } RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames); + request.OrchestrationId = this._context.InstanceId; + try { return await this._context.Entities.CallEntityAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj index 85f790d17b6..41284e10856 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj @@ -1,8 +1,7 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable @@ -29,6 +28,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs index 60e5a7f83ce..0fc7ffc7b45 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/RunRequest.cs @@ -36,6 +36,13 @@ public record RunRequest [JsonInclude] internal string CorrelationId { get; set; } = Guid.NewGuid().ToString("N"); + /// + /// Gets or sets the ID of the orchestration that initiated this request (if any). + /// + [JsonInclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + internal string? OrchestrationId { get; set; } + /// /// Initializes a new instance of the class for a single message. /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs index f0a12e4099a..35aef335444 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentState.cs @@ -23,5 +23,5 @@ internal sealed class DurableAgentState /// The version is specified in semver (i.e. "major.minor.patch") format. /// [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; init; } = "1.0.0"; + public string SchemaVersion { get; init; } = "1.1.0"; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs index 2684fcd3e15..4ad9a62835c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateJsonContext.cs @@ -37,6 +37,4 @@ namespace Microsoft.Agents.AI.DurableTask.State; [JsonSerializable(typeof(TimeSpan))] [JsonSerializable(typeof(DateTime))] [JsonSerializable(typeof(DateTimeOffset))] -internal sealed partial class DurableAgentStateJsonContext : JsonSerializerContext -{ -} +internal sealed partial class DurableAgentStateJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs index cb8f3c137ce..6349b97c615 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateRequest.cs @@ -11,6 +11,12 @@ namespace Microsoft.Agents.AI.DurableTask.State; /// internal sealed class DurableAgentStateRequest : DurableAgentStateEntry { + /// + /// Gets the ID of the orchestration that initiated this request (if any). + /// + [JsonPropertyName("orchestrationId")] + public string? OrchestrationId { get; init; } + /// /// Gets the expected response type for this request (e.g. "json" or "text"). /// @@ -41,6 +47,7 @@ public static DurableAgentStateRequest FromRunRequest(RunRequest request) return new DurableAgentStateRequest() { CorrelationId = request.CorrelationId, + OrchestrationId = request.OrchestrationId, Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text", diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index c23796ad56e..093c5e0cfb6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A.AspNetCore preview @@ -11,11 +10,12 @@ - - - + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index f300483f633..a0d66cc1d57 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A preview Microsoft Agent Framework Hosting A2A @@ -17,9 +16,6 @@ - - - diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 869b931a20f..8f6ac4de240 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.AGUI.AspNetCore preview $(DefineConstants);ASPNETCORE @@ -24,9 +23,6 @@ - - - @@ -34,6 +30,10 @@ + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 291f042db56..10b1bc54ff6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -22,11 +22,8 @@ public async ValueTask ExecuteAsync(FunctionContext context) ArgumentNullException.ThrowIfNull(context); // Acquire the input binding feature (fail fast if missing rather than null-forgiving operator). - IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get(); - if (functionInputBindingFeature == null) - { + IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get() ?? throw new InvalidOperationException("Function input binding feature is not available on the current context."); - } FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context); if (inputBindingResults is not { Values: { } values }) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj index bb9ccc6ca0b..ce67c9621ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj @@ -1,8 +1,7 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable $(NoWarn);CA2007 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs index 5f50251f745..3158d87848c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; internal static class ChatClientAgentRunOptionsConverter { - private static readonly JsonElement s_emptyJson = JsonDocument.Parse("{}").RootElement; + private static readonly JsonElement s_emptyJson = JsonElement.Parse("{}"); public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs index 11b9dd9f0a5..d537f33eb9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Conversations/InMemoryConversationStorage.cs @@ -210,11 +210,10 @@ private sealed class ConversationState #if NET9_0_OR_GREATER private readonly OrderedDictionary _items = []; private readonly object _lock = new(); - private Conversation _conversation; public ConversationState(Conversation conversation) { - this._conversation = conversation; + this.Conversation = conversation; } public Conversation Conversation @@ -223,16 +222,18 @@ public Conversation Conversation { lock (this._lock) { - return this._conversation; + return field; } } + + private set; } public void UpdateConversation(Conversation conversation) { lock (this._lock) { - this._conversation = conversation; + this.Conversation = conversation; } } @@ -274,11 +275,10 @@ public bool RemoveItem(string itemId) #else private readonly List _items = []; private readonly object _lock = new(); - private Conversation _conversation; public ConversationState(Conversation conversation) { - this._conversation = conversation; + this.Conversation = conversation; } public Conversation Conversation @@ -287,16 +287,18 @@ public Conversation Conversation { lock (this._lock) { - return this._conversation; + return field; } } + + private set; } public void UpdateConversation(Conversation conversation) { lock (this._lock) { - this._conversation = conversation; + this.Conversation = conversation; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs index bd35fa83083..5741e8d161c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGenerator.cs @@ -146,6 +146,9 @@ private static string GetRandomString(int stringLength, Random? random) const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; if (random is not null) { +#if NET10_0_OR_GREATER + return random.GetString(Chars, stringLength); +#else // Use deterministic random generation when seed is provided return string.Create(stringLength, random, static (destination, random) => { @@ -154,6 +157,7 @@ private static string GetRandomString(int stringLength, Random? random) destination[i] = Chars[random.Next(Chars.Length)]; } }); +#endif } // Use cryptographically secure random generation when no seed is provided diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj index 707cc4fe68e..923f8e3eb61 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj @@ -1,8 +1,7 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) $(NoWarn);OPENAI001;MEAI001 Microsoft.Agents.AI.Hosting.OpenAI alpha @@ -22,12 +21,13 @@ - + + + - - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs index 32262d2e2c6..2476ce2fbd3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemContentConverter.cs @@ -140,10 +140,7 @@ DataContent audioData when audioData.HasTopLevelMediaType("audio") => _ => null }; - if (result is not null) - { - result.RawRepresentation = content; - } + result?.RawRepresentation = content; return result; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs index 029be0752a1..c1ede61188c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/InputMessage.cs @@ -40,7 +40,7 @@ public ChatMessage ToChatMessage() { if (this.Content.IsText) { - return new ChatMessage(this.Role, this.Content.Text!); + return new ChatMessage(this.Role, this.Content.Text); } else if (this.Content.IsContents) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index e8a55b3baae..d3a437663aa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -52,13 +52,8 @@ public static IHostedAgentBuilder WithThreadStore(this IHostedAgentBuilder build Throw.IfNull(key); var keyString = key as string; Throw.IfNullOrEmpty(keyString); - var store = createAgentThreadStore(sp, keyString); - if (store is null) - { + return createAgentThreadStore(sp, keyString) ?? throw new InvalidOperationException($"The agent thread store factory did not return a valid {nameof(AgentThreadStore)} instance for key '{keyString}'."); - } - - return store; }); return builder; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs index ea8d8ad74e8..8c87803db39 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Hosting.Local; internal sealed class LocalAgentToolRegistry { - private readonly Dictionary> _toolsByAgentName = new(); + private readonly Dictionary> _toolsByAgentName = []; public void AddTool(string agentName, AITool tool) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj index 86f709877d4..70c690bfdf5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs index ad8120c4026..c3be7c6262d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs @@ -71,7 +71,7 @@ public async Task> SearchAsync(string? applicationId, string var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); #endif var searchResponseItems = JsonSerializer.Deserialize(response, Mem0SourceGenerationContext.Default.SearchResponseItemArray); - return searchResponseItems?.Select(item => item.Memory) ?? Array.Empty(); + return searchResponseItems?.Select(item => item.Memory) ?? []; } /// @@ -94,14 +94,14 @@ public async Task CreateMemoryAsync(string? applicationId, string? agentId, stri AgentId = agentId, RunId = threadId, UserId = userId, - Messages = new[] - { + Messages = + [ new CreateMemoryMessage { Content = messageContent, Role = messageRole.ToLowerInvariant() } - } + ] }; #pragma warning restore CA1308 diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index d18ed2b4603..98bed507d5c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -28,6 +28,7 @@ public sealed class Mem0Provider : AIContextProvider private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; private readonly string _contextPrompt; + private readonly bool _enableSensitiveTelemetryData; private readonly Mem0Client _client; private readonly ILogger? _logger; @@ -64,6 +65,7 @@ public Mem0Provider(HttpClient httpClient, Mem0ProviderScope storageScope, Mem0P this._client = new Mem0Client(httpClient); this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; + this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false; this._storageScope = new Mem0ProviderScope(Throw.IfNull(storageScope)); this._searchScope = searchScope ?? storageScope; @@ -114,6 +116,7 @@ public Mem0Provider(HttpClient httpClient, JsonElement serializedState, JsonSeri this._client = new Mem0Client(httpClient); this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; + this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false; var jso = jsonSerializerOptions ?? Mem0JsonUtilities.DefaultOptions; var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(Mem0State))) as Mem0State; @@ -158,17 +161,17 @@ public override async ValueTask InvokingAsync(InvokingContext context this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); if (outputMessageText is not null) { this._logger.LogTrace( "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", - queryText, - outputMessageText, + this.SanitizeLogData(queryText), + this.SanitizeLogData(outputMessageText), this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); } } @@ -189,7 +192,7 @@ public override async ValueTask InvokingAsync(InvokingContext context this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); return new AIContext(); } } @@ -215,7 +218,7 @@ public override async ValueTask InvokedAsync(InvokedContext context, Cancellatio this._storageScope.ApplicationId, this._storageScope.AgentId, this._storageScope.ThreadId, - this._storageScope.UserId); + this.SanitizeLogData(this._storageScope.UserId)); } } @@ -282,4 +285,6 @@ public Mem0State(Mem0ProviderScope storageScope, Mem0ProviderScope searchScope) public Mem0ProviderScope StorageScope { get; set; } public Mem0ProviderScope SearchScope { get; set; } } + + private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs index 34b0392bec2..f2d3d89e166 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs @@ -12,4 +12,10 @@ public sealed class Mem0ProviderOptions /// /// Defaults to "## Memories\nConsider the following memories when answering user questions:". public string? ContextPrompt { get; set; } + + /// + /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. + /// + /// Defaults to . + public bool EnableSensitiveTelemetryData { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj index e78e93c9555..19a5019843a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs index 71f9b5436bb..fb464fdc398 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs @@ -28,19 +28,21 @@ public static class OpenAIAssistantClientExtensions /// The client result containing the assistant. /// Optional chat options. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the assistant. public static ChatClientAgent GetAIAgent( this AssistantClient assistantClient, ClientResult assistantClientResult, ChatOptions? chatOptions = null, - Func? clientFactory = null) + Func? clientFactory = null, + IServiceProvider? services = null) { if (assistantClientResult is null) { throw new ArgumentNullException(nameof(assistantClientResult)); } - return assistantClient.GetAIAgent(assistantClientResult.Value, chatOptions, clientFactory); + return assistantClient.GetAIAgent(assistantClientResult.Value, chatOptions, clientFactory, services); } /// @@ -50,12 +52,14 @@ public static ChatClientAgent GetAIAgent( /// The assistant metadata. /// Optional chat options. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the assistant. public static ChatClientAgent GetAIAgent( this AssistantClient assistantClient, Assistant assistantMetadata, ChatOptions? chatOptions = null, - Func? clientFactory = null) + Func? clientFactory = null, + IServiceProvider? services = null) { if (assistantMetadata is null) { @@ -73,14 +77,19 @@ public static ChatClientAgent GetAIAgent( chatClient = clientFactory(chatClient); } + if (!string.IsNullOrWhiteSpace(assistantMetadata.Instructions) && chatOptions?.Instructions is null) + { + chatOptions ??= new ChatOptions(); + chatOptions.Instructions = assistantMetadata.Instructions; + } + return new ChatClientAgent(chatClient, options: new() { Id = assistantMetadata.Id, Name = assistantMetadata.Name, Description = assistantMetadata.Description, - Instructions = assistantMetadata.Instructions, ChatOptions = chatOptions - }); + }, services: services); } /// @@ -90,6 +99,7 @@ public static ChatClientAgent GetAIAgent( /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the assistant agent. public static ChatClientAgent GetAIAgent( @@ -97,6 +107,7 @@ public static ChatClientAgent GetAIAgent( string agentId, ChatOptions? chatOptions = null, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (assistantClient is null) @@ -110,7 +121,7 @@ public static ChatClientAgent GetAIAgent( } var assistant = assistantClient.GetAssistant(agentId, cancellationToken); - return assistantClient.GetAIAgent(assistant, chatOptions, clientFactory); + return assistantClient.GetAIAgent(assistant, chatOptions, clientFactory, services); } /// @@ -120,6 +131,7 @@ public static ChatClientAgent GetAIAgent( /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the assistant agent. public static async Task GetAIAgentAsync( @@ -127,6 +139,7 @@ public static async Task GetAIAgentAsync( string agentId, ChatOptions? chatOptions = null, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (assistantClient is null) @@ -140,7 +153,7 @@ public static async Task GetAIAgentAsync( } var assistantResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); - return assistantClient.GetAIAgent(assistantResponse, chatOptions, clientFactory); + return assistantClient.GetAIAgent(assistantResponse, chatOptions, clientFactory, services); } /// @@ -150,20 +163,22 @@ public static async Task GetAIAgentAsync( /// The client result containing the assistant. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the assistant. /// or is . public static ChatClientAgent GetAIAgent( this AssistantClient assistantClient, ClientResult assistantClientResult, ChatClientAgentOptions options, - Func? clientFactory = null) + Func? clientFactory = null, + IServiceProvider? services = null) { if (assistantClientResult is null) { throw new ArgumentNullException(nameof(assistantClientResult)); } - return assistantClient.GetAIAgent(assistantClientResult.Value, options, clientFactory); + return assistantClient.GetAIAgent(assistantClientResult.Value, options, clientFactory, services); } /// @@ -173,13 +188,15 @@ public static ChatClientAgent GetAIAgent( /// The assistant metadata. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the assistant. /// or is . public static ChatClientAgent GetAIAgent( this AssistantClient assistantClient, Assistant assistantMetadata, ChatClientAgentOptions options, - Func? clientFactory = null) + Func? clientFactory = null, + IServiceProvider? services = null) { if (assistantMetadata is null) { @@ -203,19 +220,24 @@ public static ChatClientAgent GetAIAgent( chatClient = clientFactory(chatClient); } + if (string.IsNullOrWhiteSpace(options.ChatOptions?.Instructions) && !string.IsNullOrWhiteSpace(assistantMetadata.Instructions)) + { + options.ChatOptions ??= new ChatOptions(); + options.ChatOptions.Instructions = assistantMetadata.Instructions; + } + var mergedOptions = new ChatClientAgentOptions() { Id = assistantMetadata.Id, Name = options.Name ?? assistantMetadata.Name, Description = options.Description ?? assistantMetadata.Description, - Instructions = options.Instructions ?? assistantMetadata.Instructions, ChatOptions = options.ChatOptions, AIContextProviderFactory = options.AIContextProviderFactory, ChatMessageStoreFactory = options.ChatMessageStoreFactory, UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs }; - return new ChatClientAgent(chatClient, mergedOptions); + return new ChatClientAgent(chatClient, mergedOptions, services: services); } /// @@ -225,6 +247,7 @@ public static ChatClientAgent GetAIAgent( /// The ID of the server side agent to create a for. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the assistant agent. /// or is . @@ -234,6 +257,7 @@ public static ChatClientAgent GetAIAgent( string agentId, ChatClientAgentOptions options, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (assistantClient is null) @@ -252,7 +276,7 @@ public static ChatClientAgent GetAIAgent( } var assistant = assistantClient.GetAssistant(agentId, cancellationToken); - return assistantClient.GetAIAgent(assistant, options, clientFactory); + return assistantClient.GetAIAgent(assistant, options, clientFactory, services); } /// @@ -262,6 +286,7 @@ public static ChatClientAgent GetAIAgent( /// The ID of the server side agent to create a for. /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. + /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the assistant agent. /// or is . @@ -271,6 +296,7 @@ public static async Task GetAIAgentAsync( string agentId, ChatClientAgentOptions options, Func? clientFactory = null, + IServiceProvider? services = null, CancellationToken cancellationToken = default) { if (assistantClient is null) @@ -289,7 +315,7 @@ public static async Task GetAIAgentAsync( } var assistantResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); - return assistantClient.GetAIAgent(assistantResponse, options, clientFactory); + return assistantClient.GetAIAgent(assistantResponse, options, clientFactory, services); } /// @@ -303,6 +329,7 @@ public static async Task GetAIAgentAsync( /// Optional collection of AI tools that the agent can use during conversations. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. /// An instance backed by the OpenAI Assistant service. /// Thrown when or is . /// Thrown when is empty or whitespace. @@ -314,21 +341,23 @@ public static ChatClientAgent CreateAIAgent( string? description = null, IList? tools = null, Func? clientFactory = null, - ILoggerFactory? loggerFactory = null) => + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) => client.CreateAIAgent( model, new ChatClientAgentOptions() { Name = name, Description = description, - Instructions = instructions, - ChatOptions = tools is null ? null : new ChatOptions() + ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions() { Tools = tools, + Instructions = instructions } }, clientFactory, - loggerFactory); + loggerFactory, + services); /// /// Creates an AI agent from an using the OpenAI Assistant API. @@ -338,6 +367,7 @@ public static ChatClientAgent CreateAIAgent( /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. /// An instance backed by the OpenAI Assistant service. /// Thrown when or or is . /// Thrown when is empty or whitespace. @@ -346,7 +376,8 @@ public static ChatClientAgent CreateAIAgent( string model, ChatClientAgentOptions options, Func? clientFactory = null, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) { Throw.IfNull(client); Throw.IfNullOrEmpty(model); @@ -356,7 +387,7 @@ public static ChatClientAgent CreateAIAgent( { Name = options.Name, Description = options.Description, - Instructions = options.Instructions, + Instructions = options.ChatOptions?.Instructions, }; // Convert AITools to ToolDefinitions and ToolResources @@ -387,7 +418,7 @@ public static ChatClientAgent CreateAIAgent( options.ChatOptions ??= new ChatOptions(); options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; - return new ChatClientAgent(chatClient, agentOptions, loggerFactory); + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); } /// @@ -401,6 +432,8 @@ public static ChatClientAgent CreateAIAgent( /// Optional collection of AI tools that the agent can use during conversations. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// The to monitor for cancellation requests. The default is . /// An instance backed by the OpenAI Assistant service. /// Thrown when or is . /// Thrown when is empty or whitespace. @@ -412,20 +445,24 @@ public static async Task CreateAIAgentAsync( string? description = null, IList? tools = null, Func? clientFactory = null, - ILoggerFactory? loggerFactory = null) => + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) => await client.CreateAIAgentAsync(model, new ChatClientAgentOptions() { Name = name, Description = description, - Instructions = instructions, - ChatOptions = tools is null ? null : new ChatOptions() + ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions() { Tools = tools, + Instructions = instructions, } }, clientFactory, - loggerFactory).ConfigureAwait(false); + loggerFactory, + services, + cancellationToken).ConfigureAwait(false); /// /// Creates an AI agent from an using the OpenAI Assistant API. @@ -435,6 +472,8 @@ await client.CreateAIAgentAsync(model, /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// The to monitor for cancellation requests. The default is . /// An instance backed by the OpenAI Assistant service. /// Thrown when or is . /// Thrown when is empty or whitespace. @@ -443,7 +482,9 @@ public static async Task CreateAIAgentAsync( string model, ChatClientAgentOptions options, Func? clientFactory = null, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) { Throw.IfNull(client); Throw.IfNull(model); @@ -453,7 +494,7 @@ public static async Task CreateAIAgentAsync( { Name = options.Name, Description = options.Description, - Instructions = options.Instructions, + Instructions = options.ChatOptions?.Instructions, }; // Convert AITools to ToolDefinitions and ToolResources @@ -468,7 +509,7 @@ public static async Task CreateAIAgentAsync( } // Create the assistant in the assistant service. - var assistantCreateResult = await client.CreateAssistantAsync(model, assistantOptions).ConfigureAwait(false); + var assistantCreateResult = await client.CreateAssistantAsync(model, assistantOptions, cancellationToken).ConfigureAwait(false); var assistantId = assistantCreateResult.Value.Id; // Build the local agent object. @@ -483,7 +524,7 @@ public static async Task CreateAIAgentAsync( options.ChatOptions ??= new ChatOptions(); options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; - return new ChatClientAgent(chatClient, agentOptions, loggerFactory); + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); } private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) @@ -500,7 +541,7 @@ private static (List? ToolDefinitions, ToolResources? ToolResour { case HostedCodeInterpreterTool codeTool: - toolDefinitions ??= new(); + toolDefinitions ??= []; toolDefinitions.Add(new CodeInterpreterToolDefinition()); if (codeTool.Inputs is { Count: > 0 }) @@ -521,7 +562,7 @@ private static (List? ToolDefinitions, ToolResources? ToolResour break; case HostedFileSearchTool fileSearchTool: - toolDefinitions ??= new(); + toolDefinitions ??= []; toolDefinitions.Add(new FileSearchToolDefinition { MaxResults = fileSearchTool.MaximumResultCount, @@ -544,7 +585,7 @@ private static (List? ToolDefinitions, ToolResources? ToolResour break; default: - functionToolsAndOtherTools ??= new(); + functionToolsAndOtherTools ??= []; functionToolsAndOtherTools.Add(tool); break; } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs index 36114d009c7..b51679e42ef 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs @@ -47,9 +47,9 @@ public static ChatClientAgent CreateAIAgent( { Name = name, Description = description, - Instructions = instructions, - ChatOptions = tools is null ? null : new ChatOptions() + ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions() { + Instructions = instructions, Tools = tools, } }, diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index c9f27432290..dd25d460477 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -30,6 +30,7 @@ public static class OpenAIResponseClientExtensions /// Optional collection of AI tools that the agent can use during conversations. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. /// An instance backed by the OpenAI Response service. /// Thrown when is . public static ChatClientAgent CreateAIAgent( @@ -39,7 +40,8 @@ public static ChatClientAgent CreateAIAgent( string? description = null, IList? tools = null, Func? clientFactory = null, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) { Throw.IfNull(client); @@ -48,14 +50,15 @@ public static ChatClientAgent CreateAIAgent( { Name = name, Description = description, - Instructions = instructions, - ChatOptions = tools is null ? null : new ChatOptions() + ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions() { + Instructions = instructions, Tools = tools, } }, clientFactory, - loggerFactory); + loggerFactory, + services); } /// @@ -65,13 +68,15 @@ public static ChatClientAgent CreateAIAgent( /// Full set of options to configure the agent. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. + /// An optional to use for resolving services required by the instances being invoked. /// An instance backed by the OpenAI Response service. /// Thrown when or is . public static ChatClientAgent CreateAIAgent( this OpenAIResponseClient client, ChatClientAgentOptions options, Func? clientFactory = null, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) { Throw.IfNull(client); Throw.IfNull(options); @@ -83,6 +88,6 @@ public static ChatClientAgent CreateAIAgent( chatClient = clientFactory(chatClient); } - return new ChatClientAgent(chatClient, options, loggerFactory); + return new ChatClientAgent(chatClient, options, loggerFactory, services); } } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj index 3c79bb3071d..bfcf6e5263e 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview $(NoWarn);OPENAI001; enable diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIChatClientAgent.cs index b529e1151b6..5870e2fdcc0 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIChatClientAgent.cs @@ -32,7 +32,7 @@ public OpenAIChatClientAgent( { Name = name, Description = description, - Instructions = instructions, + ChatOptions = new ChatOptions() { Instructions = instructions }, }, loggerFactory) { } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIResponseClientAgent.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIResponseClientAgent.cs index 8c5603fb05b..9d554e6a841 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIResponseClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/OpenAIResponseClientAgent.cs @@ -32,7 +32,7 @@ public OpenAIResponseClientAgent( { Name = name, Description = description, - Instructions = instructions, + ChatOptions = new ChatOptions() { Instructions = instructions }, }, loggerFactory) { } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs index d55c5a6a661..54690790152 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs @@ -41,9 +41,7 @@ public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purvie { await this.RunJobAsync(job).ConfigureAwait(false); } - catch (Exception e) when ( - !(e is OperationCanceledException) && - !(e is SystemException)) + catch (Exception e) when (e is not OperationCanceledException and not SystemException) { this._logger.LogError(e, "Error running background job {BackgroundJobError}.", e.Message); } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs index ed3111fb3f3..746014b7006 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ChannelHandler.cs @@ -71,16 +71,9 @@ public void QueueJob(BackgroundJobBase job) } } } - catch (Exception e) + catch (Exception e) when (this._purviewSettings.IgnoreExceptions) { - if (this._purviewSettings.IgnoreExceptions) - { - this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message); - } - else - { - throw; - } + this._logger.LogError(e, "Error queuing job: {ExceptionMessage}", e.Message); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj index 20eca86359b..75c19ad7c99 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) alpha diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs index 9619d27fc8d..6a2a92226d7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ContentBase.cs @@ -15,7 +15,7 @@ internal abstract class ContentBase : GraphDataTypeBase /// Creates a new instance of the class. /// /// The graph data type of the content. - public ContentBase(string dataType) : base(dataType) + protected ContentBase(string dataType) : base(dataType) { } } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs index b4334fdb439..df54240662d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/GraphDataTypeBase.cs @@ -13,7 +13,7 @@ internal abstract class GraphDataTypeBase /// Create a new instance of the class. /// /// The data type of the graph object. - public GraphDataTypeBase(string dataType) + protected GraphDataTypeBase(string dataType) { this.DataType = dataType; } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs index 51f4936e828..a401288127b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs @@ -21,7 +21,7 @@ internal abstract class ProcessContentMetadataBase : GraphDataTypeBase /// The unique identifier for the content. /// Indicates if the content is truncated. /// The name of the content. - public ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) + protected ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) { this.Identifier = identifier; this.IsTruncated = isTruncated; diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs index ab8cc8a5887..d3c93176284 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/BackgroundJobBase.cs @@ -5,6 +5,4 @@ namespace Microsoft.Agents.AI.Purview.Models.Jobs; /// /// Abstract base class for background jobs. /// -internal abstract class BackgroundJobBase -{ -} +internal abstract class BackgroundJobBase; diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs index cdeb395d671..4095345d998 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs @@ -112,11 +112,7 @@ public static Func PurviewAgentMiddleware(TokenCredential toke /// The id of the owner of the message. public static void SetUserId(this ChatMessage message, Guid userId) { - if (message.AdditionalProperties == null) - { - message.AdditionalProperties = new AdditionalPropertiesDictionary(); - } - + message.AdditionalProperties ??= []; message.AdditionalProperties[Constants.UserId] = userId.ToString(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index da9e61a22e0..d094ec2c312 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -101,7 +101,7 @@ private static bool TryGetUserIdFromPayload(IEnumerable messages, o /// A list of process content requests. private async Task> MapMessageToPCRequestsAsync(IEnumerable messages, string? threadId, Activity activity, PurviewSettings settings, string? userId, CancellationToken cancellationToken) { - List pcRequests = new(); + List pcRequests = []; TokenInfo? tokenInfo = null; bool needUserId = userId == null && TryGetUserIdFromPayload(messages, out userId); @@ -162,7 +162,7 @@ private async Task> MapMessageToPCRequestsAsync(IEnu OperatingSystemVersion = "Unknown" } }; - ContentToProcess contentToProcess = new(new List { conversationmetadata }, activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + ContentToProcess contentToProcess = new([conversationmetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); if (userId == null && tokenInfo?.UserId != null) @@ -279,7 +279,7 @@ private static (bool shouldProcess, List dlpActions, ExecutionMod string locationType = locationSegments.Length > 0 ? locationSegments[locationSegments.Length - 1] : pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; string locationValue = pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation.Value; - List dlpActions = new(); + List dlpActions = []; bool shouldProcess = false; ExecutionMode executionMode = ExecutionMode.EvaluateOffline; @@ -325,7 +325,7 @@ private static ProtectionScopesRequest CreateProtectionScopesRequest(ProcessCont return new ProtectionScopesRequest(userId, tenantId) { Activities = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity), - Locations = new List { pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation }, + Locations = [pcRequest.ContentToProcess.ProtectedAppMetadata.ApplicationLocation], DeviceMetadata = pcRequest.ContentToProcess.DeviceMetadata, IntegratedAppMetadata = pcRequest.ContentToProcess.IntegratedAppMetadata, CorrelationId = correlationId diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj index c43c28aaf48..1370b6fdcad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview $(NoWarn);MEAI001;OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CodeTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CodeTemplate.cs index 87d9ab748bf..af201deb4f2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CodeTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CodeTemplate.cs @@ -13,9 +13,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen; internal abstract class CodeTemplate { - private StringBuilder? _generationEnvironmentField; - private CompilerErrorCollection? _errorsField; - private List? _indentLengthsField; private bool _endsWithNewline; private string CurrentIndentField { get; set; } = string.Empty; @@ -146,22 +143,19 @@ public StringBuilder GenerationEnvironment { get { - return this._generationEnvironmentField ??= new StringBuilder(); - } - set - { - this._generationEnvironmentField = value; + return field ??= new StringBuilder(); } + set; } /// /// The error collection for the generation process /// - public CompilerErrorCollection Errors => this._errorsField ??= []; + public CompilerErrorCollection Errors => field ??= []; /// /// A list of the lengths of each indent that was added with PushIndent /// - private List indentLengths => this._indentLengthsField ??= []; + private List IndentLengths { get => field ??= []; } /// /// Gets the current indent we use when adding lines to the output @@ -288,7 +282,7 @@ public void PushIndent(string indent) throw new ArgumentNullException(nameof(indent)); } this.CurrentIndentField += indent; - this.indentLengths.Add(indent.Length); + this.IndentLengths.Add(indent.Length); } /// @@ -297,10 +291,10 @@ public void PushIndent(string indent) public string PopIndent() { string returnValue = string.Empty; - if (this.indentLengths.Count > 0) + if (this.IndentLengths.Count > 0) { - int indentLength = this.indentLengths[this.indentLengths.Count - 1]; - this.indentLengths.RemoveAt(this.indentLengths.Count - 1); + int indentLength = this.IndentLengths[this.IndentLengths.Count - 1]; + this.IndentLengths.RemoveAt(this.IndentLengths.Count - 1); if (indentLength > 0) { returnValue = this.CurrentIndentField.Substring(this.CurrentIndentField.Length - indentLength); @@ -315,7 +309,7 @@ public string PopIndent() /// public void ClearIndent() { - this.indentLengths.Clear(); + this.IndentLengths.Clear(); this.CurrentIndentField = string.Empty; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index 279fec3e6de..1aa9a6ef716 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -20,7 +20,7 @@ public static TableValue ToTable(this IEnumerable messages) => public static IEnumerable? ToChatMessages(this DataValue? messages) { - if (messages is null || messages is BlankDataValue) + if (messages is null or BlankDataValue) { return null; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs index a520593144e..9d4d18db732 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -117,7 +117,7 @@ public static Type ToClrType(this DataType type) => public static IList? AsList(this DataValue? value) { - if (value is null || value is BlankDataValue) + if (value is null or BlankDataValue) { return null; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 704a555159a..2ad605803e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -24,7 +24,6 @@ internal abstract class DeclarativeActionExecutor(TAction model, Workfl internal abstract class DeclarativeActionExecutor : Executor, IResettableExecutor, IModeledAction { - private string? _parentId; private readonly WorkflowFormulaState _state; protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState state) @@ -42,7 +41,7 @@ protected DeclarativeActionExecutor(DialogAction model, WorkflowFormulaState sta public DialogAction Model { get; } - public string ParentId => this._parentId ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); + public string ParentId { get => field ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); } public RecalcEngine Engine => this._state.Engine; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 1f466aac4ef..0b3f41ec9b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview $(NoWarn);MEAI001;OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 41f0d834f00..c5272e39eac 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -139,7 +139,7 @@ private static Workflow BuildConcurrentCore( aggregator ??= static lists => (from list in lists where list.Count > 0 select list.Last()).ToList(); Func> endFactory = - (string _, string __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator)); + (_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator)); ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs index 56fb326338b..238734b598b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs @@ -26,7 +26,7 @@ public class ChatProtocolExecutorOptions /// public abstract class ChatProtocolExecutor : StatefulExecutor> { - private readonly static Func> s_initFunction = () => []; + private static readonly Func> s_initFunction = () => []; private readonly ChatRole? _stringMessageChatRole; /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs index c7ac339a0c3..c9936ce6837 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Execution; internal static class AsyncRunHandleExtensions { - public async static ValueTask> WithCheckpointingAsync(this AsyncRunHandle runHandle, Func> prepareFunc) + public static async ValueTask> WithCheckpointingAsync(this AsyncRunHandle runHandle, Func> prepareFunc) { TRunType run = await prepareFunc().ConfigureAwait(false); return new Checkpointed(run, runHandle); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs index aaae42f2f1e..306373f4b7f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/NonThrowingChannelReaderAsyncEnumerable.cs @@ -16,8 +16,7 @@ internal sealed class NonThrowingChannelReaderAsyncEnumerable(ChannelReader reader, CancellationToken cancellationToken) : IAsyncEnumerator { - private T? _current; - public T Current => this._current ?? throw new InvalidOperationException("Enumeration not started."); + public T Current { get => field ?? throw new InvalidOperationException("Enumeration not started."); private set; } public ValueTask DisposeAsync() { @@ -36,7 +35,7 @@ public async ValueTask MoveNextAsync() bool hasData = await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false); if (hasData) { - this._current = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + this.Current = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); return true; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs index e1c50ab1a3f..93960f0f9a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs @@ -51,7 +51,7 @@ public bool ContainsKey(string key) Throw.IfNullOrEmpty(key); if (this._stateData.TryGetValue(key, out PortableValue? value)) { - if (typeof(T) == typeof(PortableValue) && !value.TypeId.IsMatch(typeof(PortableValue))) + if (typeof(T) == typeof(PortableValue) && !value.TypeId.IsMatch()) { // value is PortableValue, and we do not need to unwrap a PortableValue instance inside of it // Unfortunately we need to cast through object here. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index e0b53429f93..647dbcd852c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -93,18 +93,17 @@ protected virtual ISet ConfigureYieldTypes() return new HashSet(); } - private MessageRouter? _router; internal MessageRouter Router { get { - if (this._router is null) + if (field is null) { RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder()); - this._router = routeBuilder.Build(); + field = routeBuilder.Build(); } - return this._router; + return field; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs index 9d3d55b33fd..d16a4b5b43d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs @@ -13,8 +13,6 @@ namespace Microsoft.Agents.AI.Workflows; /// public abstract class GroupChatManager { - private int _maximumIterationCount = 40; - /// /// Initializes a new instance of the class. /// @@ -34,9 +32,9 @@ protected GroupChatManager() { } /// public int MaximumIterationCount { - get => this._maximumIterationCount; - set => this._maximumIterationCount = Throw.IfLessThan(value, 1); - } + get; + set => field = Throw.IfLessThan(value, 1); + } = 40; /// /// Selects the next agent to participate in the group chat based on the provided chat history and team. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs index c02a609f75c..12b0f9c7072 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -53,7 +53,7 @@ public Workflow Build() Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); Func> groupChatHostFactory = - (string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory)); + (id, runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory)); ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost)); WorkflowBuilder builder = new(host); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 9c100ecbbf2..8c7149b0be5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -225,7 +225,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep, CancellationT // subworkflow's input queue. In order to actually process the message and align the supersteps correctly, // we need to drive the superstep of the subworkflow here. // TODO: Investigate if we can fully pull in the subworkflow execution into the WorkflowHostExecutor itself. - List subworkflowTasks = new(); + List subworkflowTasks = []; foreach (ISuperStepRunner subworkflowRunner in this.RunContext.JoinedSubworkflowRunners) { subworkflowTasks.Add(subworkflowRunner.RunSuperStepAsync(cancellationToken).AsTask()); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj index ff2e9dee649..7379d9a6ac6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs index c0c6b8c8caa..f25f896db93 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; internal static class IMessageHandlerReflection { - private const string Nameof_HandleAsync = nameof(IMessageHandler.HandleAsync); + private const string Nameof_HandleAsync = nameof(IMessageHandler<>.HandleAsync); internal static readonly MethodInfo HandleAsync_1 = typeof(IMessageHandler<>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; internal static readonly MethodInfo HandleAsync_2 = typeof(IMessageHandler<,>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ValueTaskTypeErasure.cs index f8aa22b8b66..90e184c30e1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ValueTaskTypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ValueTaskTypeErasure.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; internal static class ValueTaskReflection { - private const string Nameof_AsTask = nameof(ValueTask.AsTask); + private const string Nameof_AsTask = nameof(ValueTask<>.AsTask); internal static readonly MethodInfo AsTask = typeof(ValueTask<>).GetMethod(Nameof_AsTask, BindingFlags.Public | BindingFlags.Instance)!; internal static MethodInfo ReflectAsTask(this Type specializedType) @@ -25,7 +25,7 @@ internal static MethodInfo ReflectAsTask(this Type specializedType) internal static class TaskReflection { - private const string Nameof_Result = nameof(Task.Result); + private const string Nameof_Result = nameof(Task<>.Result); internal static readonly MethodInfo Result_get = typeof(Task<>).GetProperty(Nameof_Result)!.GetMethod!; internal static MethodInfo ReflectResult_get(this Type specializedType) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index afb07507f98..932cf297a3c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -10,13 +10,11 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; -internal sealed class RequestPortOptions -{ -} +internal sealed class RequestPortOptions; internal sealed class RequestInfoExecutor : Executor { - private readonly Dictionary _wrappedRequests = new(); + private readonly Dictionary _wrappedRequests = []; private RequestPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index a4f6be12100..456838b9eb7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -120,7 +120,7 @@ internal void CheckOwnership(object? existingOwnershipSignoff = null) throw new InvalidOperationException($"Existing ownership does not match check value. {Summarize(maybeOwned)} vs. {Summarize(existingOwnershipSignoff)}"); } - string Summarize(object? maybeOwnerToken) => maybeOwnerToken switch + static string Summarize(object? maybeOwnerToken) => maybeOwnerToken switch { string s => $"'{s}'", null => "", @@ -168,11 +168,8 @@ internal void TakeOwnership(object ownerToken, bool subworkflow = false, object? Justification = "Does not exist in NetFx 4.7.2")] internal async ValueTask ReleaseOwnershipAsync(object ownerToken) { - object? originalToken = Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken); - if (originalToken == null) - { + object? originalToken = Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken) ?? throw new InvalidOperationException("Attempting to release ownership of a Workflow that is not owned."); - } if (!ReferenceEquals(originalToken, ownerToken)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs index ffa044791fd..d27de6bd5cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs @@ -68,9 +68,6 @@ public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflow public CheckpointInfo? LastCheckpoint { get; set; } - protected override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) - => this.MessageStore.AddMessagesAsync(newMessages, cancellationToken); - public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) { JsonMarshaller marshaller = new(jsonSerializerOptions); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index d04d9bb9fb4..df7477241cd 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -59,13 +59,13 @@ public ChatClientAgent(IChatClient chatClient, string? instructions = null, stri chatClient, new ChatClientAgentOptions { - Name = name, - Description = description, - Instructions = instructions, - ChatOptions = tools is null ? null : new ChatOptions + ChatOptions = (tools is null && string.IsNullOrWhiteSpace(instructions)) ? null : new ChatOptions { Tools = tools, - } + Instructions = instructions + }, + Name = name, + Description = description }, loggerFactory, services) @@ -141,7 +141,7 @@ public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options, /// These instructions are typically provided to the AI model as system messages to establish /// the context and expected behavior for the agent's responses. /// - public string? Instructions => this._agentOptions?.Instructions; + public string? Instructions => this._agentOptions?.ChatOptions?.Instructions; /// /// Gets of the default used by the agent. @@ -204,6 +204,8 @@ public override async IAsyncEnumerable RunStreamingAsync (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) = await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); + ValidateStreamResumptionAllowed(chatOptions?.ContinuationToken, safeThread); + var chatClient = this.ChatClient; chatClient = ApplyRunOptionsTransformations(options, chatClient); @@ -270,7 +272,7 @@ public override async IAsyncEnumerable RunStreamingAsync this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId); // To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request. - await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); + await NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); @@ -289,6 +291,7 @@ public override async IAsyncEnumerable RunStreamingAsync public override AgentThread GetNewThread() => new ChatClientAgentThread { + MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }), AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }) }; @@ -316,6 +319,34 @@ public AgentThread GetNewThread(string conversationId) AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }) }; + /// + /// Creates a new agent thread instance using an existing to continue a conversation. + /// + /// The instance to use for managing the conversation's message history. + /// + /// A new instance configured to work with the provided . + /// + /// + /// + /// This method creates threads that do not support server-side conversation storage. + /// Some AI services require server-side conversation storage to function properly, and creating a thread + /// with a may not be compatible with these services. + /// + /// + /// Where a service requires server-side conversation storage, use . + /// + /// + /// If the agent detects, during the first run, that the underlying AI service requires server-side conversation storage, + /// the thread will throw an exception to indicate that it cannot continue using the provided . + /// + /// + public AgentThread GetNewThread(ChatMessageStore chatMessageStore) + => new ChatClientAgentThread() + { + MessageStore = Throw.IfNull(chatMessageStore), + AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }) + }; + /// public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) { @@ -384,7 +415,7 @@ private async Task RunCoreAsync inputMessagesForChatClient = []; IList? aiContextProviderMessages = null; @@ -648,12 +691,6 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider """); } - if (!string.IsNullOrWhiteSpace(this.Instructions)) - { - chatOptions ??= new(); - chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? this.Instructions : $"{this.Instructions}\n{chatOptions.Instructions}"; - } - // Only create or update ChatOptions if we have an id on the thread and we don't have the same one already in ChatOptions. if (!string.IsNullOrWhiteSpace(typedThread.ConversationId) && typedThread.ConversationId != chatOptions?.ConversationId) { @@ -682,9 +719,45 @@ private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, else { // If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and - // the thread has no MessageStore yet, and we have a custom messages store, we should update the thread - // with the custom MessageStore so that it has somewhere to store the chat history. - thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }); + // the thread has no MessageStore yet, we should update the thread with the custom MessageStore or + // default InMemoryMessageStore so that it has somewhere to store the chat history. + thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }) ?? new InMemoryChatMessageStore(); + } + } + + private static Task NotifyMessageStoreOfNewMessagesAsync(ChatClientAgentThread thread, IEnumerable newMessages, CancellationToken cancellationToken) + { + var messageStore = thread.MessageStore; + + // Only notify the message store if we have one. + // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. + if (messageStore is not null) + { + return messageStore.AddMessagesAsync(newMessages, cancellationToken); + } + + return Task.CompletedTask; + } + + private static void ValidateStreamResumptionAllowed(ResponseContinuationToken? continuationToken, ChatClientAgentThread safeThread) + { + if (continuationToken is null) + { + return; + } + + // Streaming resumption is only supported with chat history managed by the agent service because, currently, there's no good solution + // to collect updates received in failed runs and pass them to the last successful run so it can store them to the message store. + if (safeThread.ConversationId is null) + { + throw new NotSupportedException("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service."); + } + + // Similarly, streaming resumption is not supported when a context provider is used because, currently, there's no good solution + // to collect updates received in failed runs and pass them to the last successful run so it can notify the context provider of the updates. + if (safeThread.AIContextProvider is not null) + { + throw new NotSupportedException("Using context provider with streaming resumption is not supported."); } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index f83e6912d5c..4a72d66f2d4 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Text.Json; using Microsoft.Extensions.AI; @@ -15,37 +14,8 @@ namespace Microsoft.Agents.AI; /// identifier, display name, operational instructions, and a descriptive summary. It can be used to store and transfer /// agent-related metadata within a chat application. /// -public class ChatClientAgentOptions +public sealed class ChatClientAgentOptions { - /// - /// Initializes a new instance of the class. - /// - public ChatClientAgentOptions() - { - } - - /// - /// Initializes a new instance of the class with the specified parameters. - /// - /// If is provided, a new instance is created - /// with the specified instructions and tools. - /// The instructions or guidelines for the chat client agent. Can be if not specified. - /// The name of the chat client agent. Can be if not specified. - /// The description of the chat client agent. Can be if not specified. - /// A list of instances available to the chat client agent. Can be if no - /// tools are specified. - public ChatClientAgentOptions(string? instructions, string? name = null, string? description = null, IList? tools = null) - { - this.Name = name; - this.Instructions = instructions; - this.Description = description; - - if (tools is not null) - { - (this.ChatOptions ??= new()).Tools = tools; - } - } - /// /// Gets or sets the agent id. /// @@ -56,11 +26,6 @@ public ChatClientAgentOptions(string? instructions, string? name = null, string? /// public string? Name { get; set; } - /// - /// Gets or sets the agent instructions. - /// - public string? Instructions { get; set; } - /// /// Gets or sets the agent description. /// @@ -106,7 +71,6 @@ public ChatClientAgentOptions Clone() { Id = this.Id, Name = this.Name, - Instructions = this.Instructions, Description = this.Description, ChatOptions = this.ChatOptions?.Clone(), ChatMessageStoreFactory = this.ChatMessageStoreFactory, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs index f0f51895b2b..7f0ce9a1ea4 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs @@ -1,12 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -17,7 +13,6 @@ namespace Microsoft.Agents.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public class ChatClientAgentThread : AgentThread { - private string? _conversationId; private ChatMessageStore? _messageStore; /// @@ -94,10 +89,10 @@ internal ChatClientAgentThread( /// Attempted to set a conversation ID but a is already set. public string? ConversationId { - get => this._conversationId; + get; internal set { - if (string.IsNullOrWhiteSpace(this._conversationId) && string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(field) && string.IsNullOrWhiteSpace(value)) { return; } @@ -110,7 +105,7 @@ internal set throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported."); } - this._conversationId = Throw.IfNullOrWhitespace(value); + field = Throw.IfNullOrWhitespace(value); } } @@ -141,7 +136,7 @@ internal set return; } - if (!string.IsNullOrWhiteSpace(this._conversationId)) + if (!string.IsNullOrWhiteSpace(this.ConversationId)) { // If we have a conversation id already, we shouldn't switch the thread to use a message store // since it means that the thread will not work with the original agent anymore. @@ -182,36 +177,9 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio ?? this.AIContextProvider?.GetService(serviceType, serviceKey) ?? this.MessageStore?.GetService(serviceType, serviceKey); - /// - protected override async Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) - { - switch (this) - { - case { ConversationId: not null }: - // If the thread messages are stored in the service - // there is nothing to do here, since invoking the - // service should already update the thread. - break; - - case { MessageStore: null }: - // If there is no conversation id, and no store we can createa a default in memory store and add messages to it. - this._messageStore = new InMemoryChatMessageStore(); - await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false); - break; - - case { MessageStore: not null }: - // If a store has been provided, we need to add the messages to the store. - await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false); - break; - - default: - throw new UnreachableException(); - } - } - [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => - this._conversationId is { } conversationId ? $"ConversationId = {conversationId}" : + this.ConversationId is { } conversationId ? $"ConversationId = {conversationId}" : this._messageStore is InMemoryChatMessageStore inMemoryStore ? $"Count = {inMemoryStore.Count}" : this._messageStore is { } store ? $"Store = {store.GetType().Name}" : "Count = 0"; diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index 6d90c877e88..c232b2d554e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -46,6 +46,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable private readonly VectorStoreCollection> _collection; private readonly int _maxResults; private readonly string _contextPrompt; + private readonly bool _enableSensitiveTelemetryData; private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime; private readonly AITool[] _tools; private readonly ILogger? _logger; @@ -130,6 +131,7 @@ private ChatHistoryMemoryProvider( options ??= new ChatHistoryMemoryProviderOptions(); this._maxResults = options.MaxResults.HasValue ? Throw.IfLessThanOrEqual(options.MaxResults.Value, 0) : DefaultMaxResults; this._contextPrompt = options.ContextPrompt ?? DefaultContextPrompt; + this._enableSensitiveTelemetryData = options.EnableSensitiveTelemetryData; this._searchTime = options.SearchTime; this._logger = loggerFactory?.CreateLogger(); @@ -153,8 +155,8 @@ private ChatHistoryMemoryProvider( // Create a definition so that we can use the dimensions provided at runtime. var definition = new VectorStoreCollectionDefinition { - Properties = new List - { + Properties = + [ new VectorStoreKeyProperty("Key", typeof(Guid)), new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true }, new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true }, @@ -166,7 +168,7 @@ private ChatHistoryMemoryProvider( new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true }, new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true }, new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1)) - } + ] }; this._collection = this._vectorStore.GetDynamicCollection(Throw.IfNullOrWhitespace(collectionName), definition); @@ -216,7 +218,7 @@ public override async ValueTask InvokingAsync(InvokingContext context this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); return new AIContext(); } } @@ -268,7 +270,7 @@ public override async ValueTask InvokedAsync(InvokedContext context, Cancellatio this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); } } @@ -302,12 +304,12 @@ internal async Task SearchTextAsync(string userQuestion, CancellationTok this._logger?.LogTrace( "ChatHistoryMemoryProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\n ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", - userQuestion, - formatted, + this.SanitizeLogData(userQuestion), + this.SanitizeLogData(formatted), this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); return formatted; } @@ -387,7 +389,7 @@ internal async Task SearchTextAsync(string userQuestion, CancellationTok this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, - this._searchScope.UserId); + this.SanitizeLogData(this._searchScope.UserId)); return results; } @@ -475,6 +477,8 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio return serializedState.Deserialize(jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState))) as ChatHistoryMemoryProviderState; } + private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + internal sealed class ChatHistoryMemoryProviderState { public ChatHistoryMemoryProviderScope? StorageScope { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs index 55f06d74292..e09de68a597 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs @@ -38,6 +38,12 @@ public sealed class ChatHistoryMemoryProviderOptions /// public int? MaxResults { get; set; } + /// + /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. + /// + /// Defaults to . + public bool EnableSensitiveTelemetryData { get; set; } + /// /// Behavior choices for the provider. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index e3d7f00aa1b..ad5b2e0fddb 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) preview $(NoWarn);MEAI001 @@ -34,6 +32,7 @@ + diff --git a/dotnet/src/Shared/IntegrationTests/AnthropicConfiguration.cs b/dotnet/src/Shared/IntegrationTests/AnthropicConfiguration.cs new file mode 100644 index 00000000000..2230be95ed4 --- /dev/null +++ b/dotnet/src/Shared/IntegrationTests/AnthropicConfiguration.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Shared.IntegrationTests; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +internal sealed class AnthropicConfiguration +{ + public string? ServiceId { get; set; } + + public string ChatModelId { get; set; } + + public string ChatReasoningModelId { get; set; } + + public string ApiKey { get; set; } +} diff --git a/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj b/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj index 90347f3ce89..5ac895d63cf 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj +++ b/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj @@ -1,7 +1,6 @@ - $(ProjectsTargetFrameworks) false @@ -11,7 +10,10 @@ - + + + + diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj new file mode 100644 index 00000000000..929eafe998c --- /dev/null +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj @@ -0,0 +1,20 @@ + + + + True + + + + + + + + + + + + + + + + diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionChatClientAgentRunStreamingTests.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionChatClientAgentRunStreamingTests.cs new file mode 100644 index 00000000000..992db5380b7 --- /dev/null +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionChatClientAgentRunStreamingTests.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AnthropicChatCompletion.IntegrationTests; + +public abstract class SkipAllChatClientRunStreaming(Func func) : ChatClientAgentRunStreamingTests(func) +{ + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync() + => base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + => base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); +} + +public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: true)); + +public class AnthropicBetaChatCompletionChatClientAgentRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: false, useBeta: true)); + +public class AnthropicChatCompletionChatClientAgentRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: false, useBeta: false)); + +public class AnthropicChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: false)); diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionChatClientAgentRunTests.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionChatClientAgentRunTests.cs new file mode 100644 index 00000000000..e2ce6e5d041 --- /dev/null +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionChatClientAgentRunTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AnthropicChatCompletion.IntegrationTests; + +public abstract class SkipAllChatClientAgentRun(Func func) : ChatClientAgentRunTests(func) +{ + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync() + => base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + => base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); +} + +public class AnthropicBetaChatCompletionChatClientAgentRunTests() + : SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: true)); + +public class AnthropicBetaChatCompletionChatClientAgentReasoningRunTests() + : SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: true)); + +public class AnthropicChatCompletionChatClientAgentRunTests() + : SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: false)); + +public class AnthropicChatCompletionChatClientAgentReasoningRunTests() + : SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: false)); diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs new file mode 100644 index 00000000000..72c0b14ae2e --- /dev/null +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Anthropic; +using Anthropic.Models.Beta.Messages; +using Anthropic.Models.Messages; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AnthropicChatCompletion.IntegrationTests; + +public class AnthropicChatCompletionFixture : IChatClientAgentFixture +{ + // All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup. + internal const string SkipReason = "Integrations tests for local execution only"; + + private static readonly AnthropicConfiguration s_config = TestConfiguration.LoadSection(); + private readonly bool _useReasoningModel; + private readonly bool _useBeta; + + private ChatClientAgent _agent = null!; + + public AnthropicChatCompletionFixture(bool useReasoningChatModel, bool useBeta) + { + this._useReasoningModel = useReasoningChatModel; + this._useBeta = useBeta; + } + + public AIAgent Agent => this._agent; + + public IChatClient ChatClient => this._agent.ChatClient; + + public async Task> GetChatHistoryAsync(AgentThread thread) + { + var typedThread = (ChatClientAgentThread)thread; + + return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList(); + } + + public Task CreateChatClientAgentAsync( + string name = "HelpfulAssistant", + string instructions = "You are a helpful assistant.", + IList? aiTools = null) + { + var anthropicClient = new AnthropicClient() { APIKey = s_config.ApiKey }; + + IChatClient? chatClient = this._useBeta + ? anthropicClient + .Beta + .AsIChatClient() + .AsBuilder() + .ConfigureOptions(options + => options.RawRepresentationFactory = _ + => new Anthropic.Models.Beta.Messages.MessageCreateParams() + { + Model = options.ModelId ?? (this._useReasoningModel ? s_config.ChatReasoningModelId : s_config.ChatModelId), + MaxTokens = options.MaxOutputTokens ?? 4096, + Messages = [], + Thinking = this._useReasoningModel + ? new BetaThinkingConfigParam(new BetaThinkingConfigEnabled(2048)) + : new BetaThinkingConfigParam(new BetaThinkingConfigDisabled()) + }).Build() + + : anthropicClient + .AsIChatClient() + .AsBuilder() + .ConfigureOptions(options + => options.RawRepresentationFactory = _ + => new Anthropic.Models.Messages.MessageCreateParams() + { + Model = options.ModelId ?? (this._useReasoningModel ? s_config.ChatReasoningModelId : s_config.ChatModelId), + MaxTokens = options.MaxOutputTokens ?? 4096, + Messages = [], + Thinking = this._useReasoningModel + ? new ThinkingConfigParam(new ThinkingConfigEnabled(2048)) + : new ThinkingConfigParam(new ThinkingConfigDisabled()) + }).Build(); + + return Task.FromResult(new ChatClientAgent(chatClient, options: new() + { + Name = name, + ChatOptions = new() { Instructions = instructions, Tools = aiTools } + })); + } + + public Task DeleteAgentAsync(ChatClientAgent agent) => + // Chat Completion does not require/support deleting agents, so this is a no-op. + Task.CompletedTask; + + public Task DeleteThreadAsync(AgentThread thread) => + // Chat Completion does not require/support deleting threads, so this is a no-op. + Task.CompletedTask; + + public async Task InitializeAsync() => + this._agent = await this.CreateChatClientAgentAsync(); + + public Task DisposeAsync() => + Task.CompletedTask; +} diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionRunStreamingTests.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionRunStreamingTests.cs new file mode 100644 index 00000000000..f1bbbe47e91 --- /dev/null +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionRunStreamingTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AnthropicChatCompletion.IntegrationTests; + +public abstract class SkipAllRunStreaming(Func func) : RunStreamingTests(func) +{ + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithNoMessageDoesNotFailAsync() => base.RunWithNoMessageDoesNotFailAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithChatMessagesReturnsExpectedResultAsync() => base.RunWithChatMessagesReturnsExpectedResultAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithStringReturnsExpectedResultAsync() => base.RunWithStringReturnsExpectedResultAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task ThreadMaintainsHistoryAsync() => base.ThreadMaintainsHistoryAsync(); +} + +public class AnthropicBetaChatCompletionRunStreamingTests() + : SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: true)); + +public class AnthropicBetaChatCompletionReasoningRunStreamingTests() + : SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: true)); + +public class AnthropicChatCompletionRunStreamingTests() + : SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: false)); + +public class AnthropicChatCompletionReasoningRunStreamingTests() + : SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: false)); diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionRunTests.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionRunTests.cs new file mode 100644 index 00000000000..aadbf747c24 --- /dev/null +++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionRunTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AnthropicChatCompletion.IntegrationTests; + +public abstract class SkipAllRun(Func func) : RunTests(func) +{ + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithNoMessageDoesNotFailAsync() => base.RunWithNoMessageDoesNotFailAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithChatMessagesReturnsExpectedResultAsync() => base.RunWithChatMessagesReturnsExpectedResultAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task RunWithStringReturnsExpectedResultAsync() => base.RunWithStringReturnsExpectedResultAsync(); + + [Fact(Skip = AnthropicChatCompletionFixture.SkipReason)] + public override Task ThreadMaintainsHistoryAsync() => base.ThreadMaintainsHistoryAsync(); +} + +public class AnthropicBetaChatCompletionRunTests() + : SkipAllRun(() => new(useReasoningChatModel: false, useBeta: true)); + +public class AnthropicBetaChatCompletionReasoningRunTests() + : SkipAllRun(() => new(useReasoningChatModel: true, useBeta: true)); + +public class AnthropicChatCompletionRunTests() + : SkipAllRun(() => new(useReasoningChatModel: false, useBeta: false)); + +public class AnthropicChatCompletionReasoningRunTests() + : SkipAllRun(() => new(useReasoningChatModel: true, useBeta: false)); diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs index bb74c18ba61..f6267364187 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -28,7 +28,7 @@ public class AIProjectClientCreateTests public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism) { // Arrange. - const string AgentName = "IntegrationTestAgent"; + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("IntegrationTestAgent"); const string AgentDescription = "An agent created during integration tests"; const string AgentInstructions = "You are an integration test agent"; @@ -37,16 +37,20 @@ public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string create { "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( model: s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - name: AgentName, - description: AgentDescription)), + options: new ChatClientAgentOptions() + { + Name = AgentName, + Description = AgentDescription, + ChatOptions = new() { Instructions = AgentInstructions } + }), "CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent( model: s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - name: AgentName, - description: AgentDescription)), + options: new ChatClientAgentOptions() + { + Name = AgentName, + Description = AgentDescription, + ChatOptions = new() { Instructions = AgentInstructions } + }), "CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync( name: AgentName, creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }), @@ -86,7 +90,7 @@ public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string create public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism) { // Arrange. - const string AgentName = "VectorStoreAgent"; + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("VectorStoreAgent"); const string AgentInstructions = """ You are a helpful agent that can help fetch data from files you know about. Use the File Search Tool to look up codes for words. @@ -159,7 +163,7 @@ You are a helpful agent that can help fetch data from files you know about. public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism) { // Arrange. - const string AgentName = "CodeInterpreterAgent"; + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("CodeInterpreterAgent"); const string AgentInstructions = """ You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file and report the SECRET_NUMBER value it prints. Respond only with the number. @@ -229,7 +233,7 @@ and report the SECRET_NUMBER value it prints. Respond only with the number. public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) { // Arrange. - const string AgentName = "WeatherAgent"; + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("WeatherAgent"); const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; @@ -239,16 +243,18 @@ public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string create { "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( model: s_config.DeploymentName, - options: new ChatClientAgentOptions( - name: AgentName, - instructions: AgentInstructions, - tools: [weatherFunction])), + options: new ChatClientAgentOptions() + { + Name = AgentName, + ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] } + }), "CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent( s_config.DeploymentName, - options: new ChatClientAgentOptions( - name: AgentName, - instructions: AgentInstructions, - tools: [weatherFunction])), + options: new ChatClientAgentOptions() + { + Name = AgentName, + ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] } + }), _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") }; diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index f8d6d14b91a..e982c8081f8 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -114,7 +114,7 @@ public async Task CreateChatClientAgentAsync( return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools); } - private static string GenerateUniqueAgentName(string baseName) => + public static string GenerateUniqueAgentName(string baseName) => $"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; public Task DeleteAgentAsync(ChatClientAgent agent) => diff --git a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj b/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj index 8da1981f51e..83f65051d2e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj +++ b/dotnet/tests/AzureAI.IntegrationTests/AzureAI.IntegrationTests.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) True diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj index 966ea64020e..40783424104 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) True diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs index e3e9969a439..a10cc11d79c 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.IO; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; @@ -34,16 +35,20 @@ public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string create { "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - name: AgentName, - description: AgentDescription)), + options: new ChatClientAgentOptions() + { + ChatOptions = new() { Instructions = AgentInstructions }, + Name = AgentName, + Description = AgentDescription + }), "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - name: AgentName, - description: AgentDescription)), + options: new ChatClientAgentOptions() + { + ChatOptions = new() { Instructions = AgentInstructions }, + Name = AgentName, + Description = AgentDescription + }), "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, instructions: AgentInstructions, @@ -104,19 +109,32 @@ You are a helpful agent that can help fetch data from files you know about. ); var vectorStoreMetadata = await this._persistentAgentsClient.VectorStores.CreateVectorStoreAsync([uploadedAgentFile.Id], name: "WordCodeLookup_VectorStore"); + // Wait for vector store indexing to complete before using it + await this.WaitForVectorStoreReadyAsync(this._persistentAgentsClient, vectorStoreMetadata.Value.Id); + // Act. var agent = createMechanism switch { "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }] + } + }), "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }] + } + }), "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, instructions: AgentInstructions, @@ -179,15 +197,24 @@ and report the SECRET_NUMBER value it prints. Respond only with the number. // Hosted tool path (tools supplied via ChatClientAgentOptions) "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }] + } + }), "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }])), - // Foundry (definitions + resources provided directly) + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }] + } + }), "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, instructions: AgentInstructions, @@ -232,14 +259,24 @@ public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string create { "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [weatherFunction])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [weatherFunction] + } + }), "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( s_config.DeploymentName, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [weatherFunction])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [weatherFunction] + } + }), _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") }; @@ -259,4 +296,42 @@ public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string create await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); } } + + /// + /// Waits for a vector store to complete indexing by polling its status. + /// + /// The persistent agents client. + /// The ID of the vector store. + /// Maximum time to wait in seconds (default: 30). + /// A task that completes when the vector store is ready or throws on timeout/failure. + private async Task WaitForVectorStoreReadyAsync( + PersistentAgentsClient client, + string vectorStoreId, + int maxWaitSeconds = 30) + { + Stopwatch sw = Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < maxWaitSeconds) + { + PersistentAgentsVectorStore vectorStore = await client.VectorStores.GetVectorStoreAsync(vectorStoreId); + + if (vectorStore.Status == VectorStoreStatus.Completed) + { + if (vectorStore.FileCounts.Failed > 0) + { + throw new InvalidOperationException("Vector store indexing failed for some files"); + } + + return; + } + + if (vectorStore.Status == VectorStoreStatus.Expired) + { + throw new InvalidOperationException("Vector store has expired"); + } + + await Task.Delay(1000); + } + + throw new TimeoutException($"Vector store did not complete indexing within {maxWaitSeconds}s"); + } } diff --git a/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj index afbcc54f014..5f535eb7bdc 100644 --- a/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj +++ b/dotnet/tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) True true diff --git a/dotnet/tests/Directory.Build.props b/dotnet/tests/Directory.Build.props index 6c5a318e863..e6c285595e8 100644 --- a/dotnet/tests/Directory.Build.props +++ b/dotnet/tests/Directory.Build.props @@ -6,7 +6,7 @@ false true false - net472;net9.0 + net10.0;net472 b7762d10-e29b-4bb1-8b74-b6d69a667dd4 $(NoWarn);Moq1410;xUnit2023 diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 9399d99528b..f079fc5ed89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -367,6 +367,7 @@ public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync() // Act & Assert await foreach (var _ in this._agent.RunStreamingAsync(inputMessages)) { + // Just iterate through to trigger the logic } } @@ -396,15 +397,490 @@ public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync() Assert.Equal("https://example.com/file.pdf", ((FilePart)message.Parts[1]).File.Uri?.ToString()); } + [Fact] + public async Task RunAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; + + // Act & Assert + await Assert.ThrowsAsync(() => this._agent.RunAsync(inputMessages, null, options)); + } + + [Fact] + public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-123", + ContextId = "context-123" + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; + + // Act + await this._agent.RunAsync([], options: options); + + // Assert + Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method); + Assert.Equal("task-123", this._handler.CapturedTaskIdParams?.Id); + } + + [Fact] + public async Task RunAsync_WithTaskInThreadAndMessage_AddTaskAsReferencesToMessageAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response to task" }] + }; + + var thread = (A2AAgentThread)this._agent.GetNewThread(); + thread.TaskId = "task-123"; + + var inputMessage = new ChatMessage(ChatRole.User, "Please make the background transparent"); + + // Act + await this._agent.RunAsync(inputMessage, thread); + + // Assert + var message = this._handler.CapturedMessageSendParams?.Message; + Assert.Null(message?.TaskId); + Assert.NotNull(message?.ReferenceTaskIds); + Assert.Contains("task-123", message.ReferenceTaskIds); + } + + [Fact] + public async Task RunAsync_WithAgentTask_UpdatesThreadTaskIdAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + }; + + var thread = this._agent.GetNewThread(); + + // Act + await this._agent.RunAsync("Start a task", thread); + + // Assert + var a2aThread = (A2AAgentThread)thread; + Assert.Equal("task-456", a2aThread.TaskId); + } + + [Fact] + public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-789", + ContextId = "context-456", + Status = new() { State = TaskState.Submitted }, + Metadata = new Dictionary + { + { "key1", JsonSerializer.SerializeToElement("value1") }, + { "count", JsonSerializer.SerializeToElement(42) } + } + }; + + var thread = this._agent.GetNewThread(); + + // Act + var result = await this._agent.RunAsync("Start a long-running task", thread); + + // Assert - verify task is converted correctly + Assert.NotNull(result); + Assert.Equal(this._agent.Id, result.AgentId); + Assert.Equal("task-789", result.ResponseId); + + Assert.NotNull(result.RawRepresentation); + Assert.IsType(result.RawRepresentation); + Assert.Equal("task-789", ((AgentTask)result.RawRepresentation).Id); + + // Assert - verify continuation token is set for submitted task + Assert.NotNull(result.ContinuationToken); + Assert.IsType(result.ContinuationToken); + Assert.Equal("task-789", ((A2AContinuationToken)result.ContinuationToken).TaskId); + + // Assert - verify thread is updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal("context-456", a2aThread.ContextId); + Assert.Equal("task-789", a2aThread.TaskId); + + // Assert - verify metadata is preserved + Assert.NotNull(result.AdditionalProperties); + Assert.NotNull(result.AdditionalProperties["key1"]); + Assert.Equal("value1", ((JsonElement)result.AdditionalProperties["key1"]!).GetString()); + Assert.NotNull(result.AdditionalProperties["count"]); + Assert.Equal(42, ((JsonElement)result.AdditionalProperties["count"]!).GetInt32()); + } + + [Theory] + [InlineData(TaskState.Submitted)] + [InlineData(TaskState.Working)] + [InlineData(TaskState.Completed)] + [InlineData(TaskState.Failed)] + [InlineData(TaskState.Canceled)] + public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState) + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-123", + ContextId = "context-123", + Status = new() { State = taskState } + }; + + // Act + var result = await this._agent.RunAsync("Test message"); + + // Assert + if (taskState == TaskState.Submitted || taskState == TaskState.Working) + { + Assert.NotNull(result.ContinuationToken); + } + else + { + Assert.Null(result.ContinuationToken); + } + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options)) + { + // Just iterate through to trigger the exception + } + }); + } + + [Fact] + public async Task RunStreamingAsync_WithTaskInThreadAndMessage_AddTaskAsReferencesToMessageAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response to task" }] + }; + + var thread = (A2AAgentThread)this._agent.GetNewThread(); + thread.TaskId = "task-123"; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync("Please make the background transparent", thread)) + { + // Just iterate through to trigger the logic + } + + // Assert + var message = this._handler.CapturedMessageSendParams?.Message; + Assert.Null(message?.TaskId); + Assert.NotNull(message?.ReferenceTaskIds); + Assert.Contains("task-123", message.ReferenceTaskIds); + } + + [Fact] + public async Task RunStreamingAsync_WithAgentTask_UpdatesThreadTaskIdAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + }; + + var thread = this._agent.GetNewThread(); + + // Act + await foreach (var _ in this._agent.RunStreamingAsync("Start a task", thread)) + { + // Just iterate through to trigger the logic + } + + // Assert + var a2aThread = (A2AAgentThread)thread; + Assert.Equal("task-456", a2aThread.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync() + { + // Arrange + const string MessageId = "msg-123"; + const string ContextId = "ctx-456"; + const string MessageText = "Hello from agent!"; + + this._handler.StreamingResponseToReturn = new AgentMessage + { + MessageId = MessageId, + Role = MessageRole.Agent, + ContextId = ContextId, + Parts = + [ + new TextPart { Text = MessageText } + ] + }; + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Test message")) + { + updates.Add(update); + } + + // Assert - one update should be yielded + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(MessageId, update0.MessageId); + Assert.Equal(MessageId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.Equal(MessageText, update0.Text); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId); + } + + [Fact] + public async Task RunStreamingAsync_WithAgentTask_YieldsResponseUpdateAsync() + { + // Arrange + const string TaskId = "task-789"; + const string ContextId = "ctx-012"; + + this._handler.StreamingResponseToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Submitted }, + Artifacts = [ + new() + { + ArtifactId = "art-123", + Parts = [new TextPart { Text = "Task artifact content" }] + } + ] + }; + + var thread = this._agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Start long-running task", thread)) + { + updates.Add(update); + } + + // Assert - one update should be yielded from artifact + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id); + + // Assert - thread should be updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal(ContextId, a2aThread.ContextId); + Assert.Equal(TaskId, a2aThread.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithTaskStatusUpdateEvent_YieldsResponseUpdateAsync() + { + // Arrange + const string TaskId = "task-status-123"; + const string ContextId = "ctx-status-456"; + + this._handler.StreamingResponseToReturn = new TaskStatusUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Working } + }; + + var thread = this._agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Check task status", thread)) + { + updates.Add(update); + } + + // Assert - one update should be yielded + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.IsType(update0.RawRepresentation); + + // Assert - thread should be updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal(ContextId, a2aThread.ContextId); + Assert.Equal(TaskId, a2aThread.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithTaskArtifactUpdateEvent_YieldsResponseUpdateAsync() + { + // Arrange + const string TaskId = "task-artifact-123"; + const string ContextId = "ctx-artifact-456"; + const string ArtifactContent = "Task artifact data"; + + this._handler.StreamingResponseToReturn = new TaskArtifactUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Artifact = new() + { + ArtifactId = "artifact-789", + Parts = [new TextPart { Text = ArtifactContent }] + } + }; + + var thread = this._agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Process artifact", thread)) + { + updates.Add(update); + } + + // Assert - one update should be yielded + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.IsType(update0.RawRepresentation); + + // Assert - artifact content should be in the update + Assert.NotEmpty(update0.Contents); + Assert.Equal(ArtifactContent, update0.Text); + + // Assert - thread should be updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal(ContextId, a2aThread.ContextId); + Assert.Equal(TaskId, a2aThread.TaskId); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesAndNoThread_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { AllowBackgroundResponses = true }; + + // Act & Assert + await Assert.ThrowsAsync(() => this._agent.RunAsync(inputMessages, null, options)); + } + + [Fact] + public async Task RunStreamingAsync_WithAllowBackgroundResponsesAndNoThread_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { AllowBackgroundResponses = true }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options)) + { + // Just iterate through to trigger the exception + } + }); + } + + [Fact] + public async Task RunAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + // Create a thread from a different agent type + var invalidThread = new CustomAgentThread(); + + // Act & Assert + await Assert.ThrowsAsync(() => this._agent.RunAsync(invalidThread)); + } + + [Fact] + public async Task RunStreamingAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Create a thread from a different agent type + var invalidThread = new CustomAgentThread(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await this._agent.RunStreamingAsync(inputMessages, invalidThread).ToListAsync()); + } + public void Dispose() { this._handler.Dispose(); this._httpClient.Dispose(); } + + /// + /// Custom agent thread class for testing invalid thread type scenario. + /// + private sealed class CustomAgentThread : AgentThread; + internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler { + public JsonRpcRequest? CapturedJsonRpcRequest { get; set; } + public MessageSendParams? CapturedMessageSendParams { get; set; } + public TaskIdParams? CapturedTaskIdParams { get; set; } + public A2AEvent? ResponseToReturn { get; set; } public A2AEvent? StreamingResponseToReturn { get; set; } @@ -416,9 +892,19 @@ protected override async Task SendAsync(HttpRequestMessage var content = await request.Content!.ReadAsStringAsync(); #pragma warning restore CA2016 - var jsonRpcRequest = JsonSerializer.Deserialize(content)!; + this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content); - this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize(); + try + { + this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + } + catch { /* Ignore deserialization errors for non-MessageSendParams requests */ } + + try + { + this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + } + catch { /* Ignore deserialization errors for non-TaskIdParams requests */ } // Return the pre-configured non-streaming response if (this.ResponseToReturn is not null) diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs new file mode 100644 index 00000000000..90b65aa5ac5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentThreadTests +{ + [Fact] + public void Constructor_RoundTrip_SerializationPreservesState() + { + // Arrange + const string ContextId = "context-rt-001"; + const string TaskId = "task-rt-002"; + + A2AAgentThread originalThread = new() { ContextId = ContextId, TaskId = TaskId }; + + // Act + JsonElement serialized = originalThread.Serialize(); + + A2AAgentThread deserializedThread = new(serialized); + + // Assert + Assert.Equal(originalThread.ContextId, deserializedThread.ContextId); + Assert.Equal(originalThread.TaskId, deserializedThread.TaskId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs new file mode 100644 index 00000000000..1bb0d99e00e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AContinuationTokenTests +{ + [Fact] + public void Constructor_WithValidTaskId_InitializesTaskIdProperty() + { + // Arrange + const string TaskId = "task-123"; + + // Act + var token = new A2AContinuationToken(TaskId); + + // Assert + Assert.Equal(TaskId, token.TaskId); + } + + [Fact] + public void ToBytes_WithValidToken_SerializesToJsonBytes() + { + // Arrange + const string TaskId = "task-456"; + var token = new A2AContinuationToken(TaskId); + + // Act + var bytes = token.ToBytes(); + + // Assert + Assert.NotEqual(0, bytes.Length); + var jsonString = System.Text.Encoding.UTF8.GetString(bytes.ToArray()); + using var jsonDoc = JsonDocument.Parse(jsonString); + var root = jsonDoc.RootElement; + Assert.True(root.TryGetProperty("taskId", out var taskIdElement)); + Assert.Equal(TaskId, taskIdElement.GetString()); + } + + [Fact] + public void FromToken_WithA2AContinuationToken_ReturnsSameInstance() + { + // Arrange + const string TaskId = "task-direct"; + var originalToken = new A2AContinuationToken(TaskId); + + // Act + var resultToken = A2AContinuationToken.FromToken(originalToken); + + // Assert + Assert.Same(originalToken, resultToken); + Assert.Equal(TaskId, resultToken.TaskId); + } + + [Fact] + public void FromToken_WithSerializedToken_DeserializesCorrectly() + { + // Arrange + const string TaskId = "task-deserialized"; + var originalToken = new A2AContinuationToken(TaskId); + var serialized = originalToken.ToBytes(); + + // Create a mock token wrapper to pass to FromToken + var mockToken = new MockResponseContinuationToken(serialized); + + // Act + var resultToken = A2AContinuationToken.FromToken(mockToken); + + // Assert + Assert.Equal(TaskId, resultToken.TaskId); + Assert.IsType(resultToken); + } + + [Fact] + public void FromToken_RoundTrip_PreservesTaskId() + { + // Arrange + const string TaskId = "task-roundtrip-123"; + var originalToken = new A2AContinuationToken(TaskId); + var serialized = originalToken.ToBytes(); + var mockToken = new MockResponseContinuationToken(serialized); + + // Act + var deserializedToken = A2AContinuationToken.FromToken(mockToken); + var reserialized = deserializedToken.ToBytes(); + var mockToken2 = new MockResponseContinuationToken(reserialized); + var deserializedAgain = A2AContinuationToken.FromToken(mockToken2); + + // Assert + Assert.Equal(TaskId, deserializedAgain.TaskId); + } + + [Fact] + public void FromToken_WithEmptyData_ThrowsArgumentException() + { + // Arrange + var emptyToken = new MockResponseContinuationToken(ReadOnlyMemory.Empty); + + // Act & Assert + Assert.Throws(() => A2AContinuationToken.FromToken(emptyToken)); + } + + [Fact] + public void FromToken_WithMissingTaskIdProperty_ThrowsException() + { + // Arrange + var jsonWithoutTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"someOtherProperty\": \"value\" }").AsMemory(); + var mockToken = new MockResponseContinuationToken(jsonWithoutTaskId); + + // Act & Assert + Assert.Throws(() => A2AContinuationToken.FromToken(mockToken)); + } + + [Fact] + public void FromToken_WithValidTaskId_ParsesTaskIdCorrectly() + { + // Arrange + const string TaskId = "task-multi-prop"; + var json = System.Text.Encoding.UTF8.GetBytes($"{{ \"taskId\": \"{TaskId}\" }}").AsMemory(); + var mockToken = new MockResponseContinuationToken(json); + + // Act + var resultToken = A2AContinuationToken.FromToken(mockToken); + + // Assert + Assert.Equal(TaskId, resultToken.TaskId); + } + + /// + /// Mock implementation of ResponseContinuationToken for testing. + /// + private sealed class MockResponseContinuationToken : ResponseContinuationToken + { + private readonly ReadOnlyMemory _data; + + public MockResponseContinuationToken(ReadOnlyMemory data) + { + this._data = data; + } + + public override ReadOnlyMemory ToBytes() + { + return this._data; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs new file mode 100644 index 00000000000..97c9ca7c058 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using A2A; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentTaskExtensionsTests +{ + [Fact] + public void ToChatMessages_WithNullAgentTask_ThrowsArgumentNullException() + { + // Arrange + AgentTask agentTask = null!; + + // Act & Assert + Assert.Throws(() => agentTask.ToChatMessages()); + } + + [Fact] + public void ToAIContents_WithNullAgentTask_ThrowsArgumentNullException() + { + // Arrange + AgentTask agentTask = null!; + + // Act & Assert + Assert.Throws(() => agentTask.ToAIContents()); + } + + [Fact] + public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToChatMessages(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToChatMessages_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = null, + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToChatMessages(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToAIContents_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToAIContents(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToAIContents_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = null, + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToAIContents(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToChatMessages_WithValidArtifact_ReturnsChatMessages() + { + // Arrange + var artifact = new Artifact + { + Parts = [new TextPart { Text = "response" }], + }; + + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [artifact], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToChatMessages(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.All(result, msg => Assert.Equal(ChatRole.Assistant, msg.Role)); + Assert.Equal("response", result[0].Contents[0].ToString()); + } + + [Fact] + public void ToAIContents_WithMultipleArtifacts_FlattenAllContents() + { + // Arrange + var artifact1 = new Artifact + { + Parts = [new TextPart { Text = "content1" }], + }; + + var artifact2 = new Artifact + { + Parts = + [ + new TextPart { Text = "content2" }, + new TextPart { Text = "content3" } + ], + }; + + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [artifact1, artifact2], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToAIContents(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(3, result.Count); + Assert.Equal("content1", result[0].ToString()); + Assert.Equal("content2", result[1].ToString()); + Assert.Equal("content3", result[2].ToString()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs new file mode 100644 index 00000000000..659c6780343 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using A2A; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AArtifactExtensionsTests +{ + [Fact] + public void ToChatMessage_WithMultiplePartsMetadataAndRawRepresentation_ReturnsCorrectChatMessage() + { + // Arrange + var artifact = new Artifact + { + ArtifactId = "artifact-comprehensive", + Name = "comprehensive-artifact", + Parts = + [ + new TextPart { Text = "First part" }, + new TextPart { Text = "Second part" }, + new TextPart { Text = "Third part" } + ], + Metadata = new Dictionary + { + { "key1", JsonSerializer.SerializeToElement("value1") }, + { "key2", JsonSerializer.SerializeToElement(42) } + } + }; + + // Act + var result = artifact.ToChatMessage(); + + // Assert - Verify multiple parts + Assert.NotNull(result); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal(3, result.Contents.Count); + Assert.All(result.Contents, content => Assert.IsType(content)); + Assert.Equal("First part", ((TextContent)result.Contents[0]).Text); + Assert.Equal("Second part", ((TextContent)result.Contents[1]).Text); + Assert.Equal("Third part", ((TextContent)result.Contents[2]).Text); + + // Assert - Verify metadata conversion to AdditionalProperties + Assert.NotNull(result.AdditionalProperties); + Assert.Equal(2, result.AdditionalProperties.Count); + Assert.True(result.AdditionalProperties.ContainsKey("key1")); + Assert.True(result.AdditionalProperties.ContainsKey("key2")); + + // Assert - Verify RawRepresentation is set to artifact + Assert.NotNull(result.RawRepresentation); + Assert.Same(artifact, result.RawRepresentation); + } + + [Fact] + public void ToAIContents_WithMultipleParts_ReturnsCorrectList() + { + // Arrange + var artifact = new Artifact + { + ArtifactId = "artifact-ai-multi", + Name = "test", + Parts = new List + { + new TextPart { Text = "Part 1" }, + new TextPart { Text = "Part 2" }, + new TextPart { Text = "Part 3" } + }, + Metadata = null + }; + + // Act + var result = artifact.ToAIContents(); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.All(result, content => Assert.IsType(content)); + Assert.Equal("Part 1", ((TextContent)result[0]).Text); + Assert.Equal("Part 2", ((TextContent)result[1]).Text); + Assert.Equal("Part 3", ((TextContent)result[2]).Text); + } + + [Fact] + public void ToAIContents_WithEmptyParts_ReturnsEmptyList() + { + // Arrange + var artifact = new Artifact + { + ArtifactId = "artifact-empty", + Name = "test", + Parts = new List(), + Metadata = null + }; + + // Act + var result = artifact.ToAIContents(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj index f654f3eeecb..d33de0613bd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj @@ -1,14 +1,5 @@ - - $(ProjectsTargetFrameworks) - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs index 6ce89101a0d..0eeacaf1611 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs @@ -19,15 +19,15 @@ public sealed class AGUIAgentTests public async Task RunAsync_AggregatesStreamingUpdates_ReturnsCompleteMessagesAsync() { // Arrange - using HttpClient httpClient = this.CreateMockHttpClient(new BaseEvent[] - { + using HttpClient httpClient = this.CreateMockHttpClient( + [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, new TextMessageContentEvent { MessageId = "msg1", Delta = " World" }, new TextMessageEndEvent { MessageId = "msg1" }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - }); + ]); var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); @@ -182,16 +182,16 @@ public async Task RunStreamingAsync_GeneratesUniqueRunId_ForEachInvocationAsync( { // Arrange var handler = new TestDelegatingHandler(); - handler.AddResponseWithCapture(new BaseEvent[] - { + handler.AddResponseWithCapture( + [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - }); - handler.AddResponseWithCapture(new BaseEvent[] - { + ]); + handler.AddResponseWithCapture( + [ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - }); + ]); using HttpClient httpClient = new(handler); var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); @@ -1584,7 +1584,7 @@ public async Task GetStreamingResponseAsync_ReceivesStateSnapshot_AsDataContentW Assert.Equal("application/json", dataContent.MediaType); string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); - JsonElement deserializedState = JsonSerializer.Deserialize(jsonText); + JsonElement deserializedState = JsonElement.Parse(jsonText); Assert.Equal("abc123", deserializedState.GetProperty("sessionId").GetString()); Assert.Equal(5, deserializedState.GetProperty("step").GetInt32()); } @@ -1593,7 +1593,7 @@ public async Task GetStreamingResponseAsync_ReceivesStateSnapshot_AsDataContentW internal sealed class TestDelegatingHandler : DelegatingHandler { private readonly Queue>> _responseFactories = new(); - private readonly List _capturedRunIds = new(); + private readonly List _capturedRunIds = []; public IReadOnlyList CapturedRunIds => this._capturedRunIds; @@ -1701,7 +1701,7 @@ protected override async Task SendAsync(HttpRequestMessage this.RequestWasMade = true; // Capture the state and message count from the request -#if NET472 || NETSTANDARD2_0 +#if !NET string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false); #else string requestBody = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); @@ -1709,7 +1709,7 @@ protected override async Task SendAsync(HttpRequestMessage RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput); if (input != null) { - if (input.State.ValueKind != JsonValueKind.Undefined && input.State.ValueKind != JsonValueKind.Null) + if (input.State.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null) { this.CapturedState = input.State; } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs index 4a8d7908e94..bc3a73fb4cf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs @@ -29,9 +29,7 @@ public sealed class WeatherResponse [JsonSerializable(typeof(WeatherRequest))] [JsonSerializable(typeof(WeatherResponse))] [JsonSerializable(typeof(Dictionary))] -internal sealed partial class CustomTypesContext : JsonSerializerContext -{ -} +internal sealed partial class CustomTypesContext : JsonSerializerContext; /// /// Unit tests for the class. diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs index ec4f34db149..b06913c8373 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs @@ -22,16 +22,16 @@ public sealed class AGUIHttpServiceTests public async Task PostRunAsync_SendsRequestAndParsesSSEStream_SuccessfullyAsync() { // Arrange - BaseEvent[] events = new BaseEvent[] - { + BaseEvent[] events = + [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, new TextMessageEndEvent { MessageId = "msg1" }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - }; + ]; - HttpClient httpClient = this.CreateMockHttpClient(events, HttpStatusCode.OK); + HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK); AGUIHttpService service = new(httpClient, "http://localhost/agent"); RunAgentInput input = new() { @@ -60,7 +60,7 @@ public async Task PostRunAsync_SendsRequestAndParsesSSEStream_SuccessfullyAsync( public async Task PostRunAsync_WithNonSuccessStatusCode_ThrowsHttpRequestExceptionAsync() { // Arrange - HttpClient httpClient = this.CreateMockHttpClient([], HttpStatusCode.InternalServerError); + HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.InternalServerError); AGUIHttpService service = new(httpClient, "http://localhost/agent"); RunAgentInput input = new() { @@ -83,14 +83,14 @@ await Assert.ThrowsAsync(async () => public async Task PostRunAsync_DeserializesMultipleEventTypes_CorrectlyAsync() { // Arrange - BaseEvent[] events = new BaseEvent[] - { + BaseEvent[] events = + [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new RunErrorEvent { Message = "Error occurred", Code = "ERR001" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonDocument.Parse("\"Success\"").RootElement.Clone() } - }; + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Success\"") } + ]; - HttpClient httpClient = this.CreateMockHttpClient(events, HttpStatusCode.OK); + HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK); AGUIHttpService service = new(httpClient, "http://localhost/agent"); RunAgentInput input = new() { @@ -120,7 +120,7 @@ public async Task PostRunAsync_DeserializesMultipleEventTypes_CorrectlyAsync() public async Task PostRunAsync_WithEmptyEventStream_CompletesSuccessfullyAsync() { // Arrange - HttpClient httpClient = this.CreateMockHttpClient([], HttpStatusCode.OK); + HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.OK); AGUIHttpService service = new(httpClient, "http://localhost/agent"); RunAgentInput input = new() { @@ -175,9 +175,9 @@ await Assert.ThrowsAsync(async () => }); } - private HttpClient CreateMockHttpClient(BaseEvent[] events, HttpStatusCode statusCode) + private static HttpClient CreateMockHttpClient(BaseEvent[] events, HttpStatusCode statusCode) { - string sseContent = string.Join("", events.Select(e => + string sseContent = string.Concat(events.Select(e => $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n")); Mock handlerMock = new(MockBehavior.Strict); diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs index 566e69d992f..33f259a681f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs @@ -25,7 +25,7 @@ public void RunAgentInput_Serializes_WithAllRequiredFields() // Act string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("threadId", out JsonElement threadIdProp)); @@ -150,7 +150,7 @@ public void RunStartedEvent_Serializes_WithCorrectEventType() string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunStartedEvent); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); Assert.Equal(AGUIEventTypes.RunStarted, jsonElement.GetProperty("type").GetString()); } @@ -162,7 +162,7 @@ public void RunStartedEvent_Includes_ThreadIdAndRunIdInOutput() // Act string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunStartedEvent); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("threadId", out JsonElement threadIdProp)); @@ -219,7 +219,7 @@ public void RunFinishedEvent_Serializes_WithCorrectEventType() string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunFinishedEvent); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); Assert.Equal(AGUIEventTypes.RunFinished, jsonElement.GetProperty("type").GetString()); } @@ -227,11 +227,11 @@ public void RunFinishedEvent_Serializes_WithCorrectEventType() public void RunFinishedEvent_Includes_ThreadIdRunIdAndOptionalResult() { // Arrange - RunFinishedEvent evt = new() { ThreadId = "thread1", RunId = "run1", Result = JsonDocument.Parse("\"Success\"").RootElement.Clone() }; + RunFinishedEvent evt = new() { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Success\"") }; // Act string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunFinishedEvent); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("threadId", out JsonElement threadIdProp)); @@ -269,7 +269,7 @@ public void RunFinishedEvent_Deserializes_FromJsonCorrectly() public void RunFinishedEvent_RoundTrip_PreservesData() { // Arrange - RunFinishedEvent original = new() { ThreadId = "thread1", RunId = "run1", Result = JsonDocument.Parse("\"Done\"").RootElement.Clone() }; + RunFinishedEvent original = new() { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Done\"") }; // Act string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.RunFinishedEvent); @@ -292,7 +292,7 @@ public void RunErrorEvent_Serializes_WithCorrectEventType() string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunErrorEvent); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); Assert.Equal(AGUIEventTypes.RunError, jsonElement.GetProperty("type").GetString()); } @@ -304,7 +304,7 @@ public void RunErrorEvent_Includes_MessageAndOptionalCode() // Act string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunErrorEvent); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("message", out JsonElement messageProp)); @@ -360,7 +360,7 @@ public void TextMessageStartEvent_Serializes_WithCorrectEventType() string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageStartEvent); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); Assert.Equal(AGUIEventTypes.TextMessageStart, jsonElement.GetProperty("type").GetString()); } @@ -372,7 +372,7 @@ public void TextMessageStartEvent_Includes_MessageIdAndRole() // Act string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageStartEvent); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("messageId", out JsonElement msgIdProp)); @@ -428,7 +428,7 @@ public void TextMessageContentEvent_Serializes_WithCorrectEventType() string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageContentEvent); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); Assert.Equal(AGUIEventTypes.TextMessageContent, jsonElement.GetProperty("type").GetString()); } @@ -440,7 +440,7 @@ public void TextMessageContentEvent_Includes_MessageIdAndDelta() // Act string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageContentEvent); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("messageId", out JsonElement msgIdProp)); @@ -496,7 +496,7 @@ public void TextMessageEndEvent_Serializes_WithCorrectEventType() string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageEndEvent); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); Assert.Equal(AGUIEventTypes.TextMessageEnd, jsonElement.GetProperty("type").GetString()); } @@ -508,7 +508,7 @@ public void TextMessageEndEvent_Includes_MessageId() // Act string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageEndEvent); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("messageId", out JsonElement msgIdProp)); @@ -557,7 +557,7 @@ public void AGUIMessage_Serializes_WithIdRoleAndContent() // Act string json = JsonSerializer.Serialize(message, AGUIJsonSerializerContext.Default.AGUIMessage); - JsonElement jsonElement = JsonSerializer.Deserialize(json); + JsonElement jsonElement = JsonElement.Parse(json); // Assert Assert.True(jsonElement.TryGetProperty("id", out JsonElement idProp)); diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs index 515695a8a6c..ebedd68f334 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs @@ -87,7 +87,7 @@ public void AsAGUITools_FiltersOutNonAIFunctionTools() // Arrange - mix of AIFunction and non-function tools AIFunction function = AIFunctionFactory.Create(() => "Result", "TestTool"); // Create a custom AITool that's not an AIFunction - var declaration = AIFunctionFactory.CreateDeclaration("DeclarationOnly", "Description", JsonDocument.Parse("{}").RootElement); + var declaration = AIFunctionFactory.CreateDeclaration("DeclarationOnly", "Description", JsonElement.Parse("{}")); List tools = [function, declaration]; @@ -107,7 +107,7 @@ public void AsAITools_WithAGUITool_ConvertsToAIFunctionDeclarationCorrectly() { Name = "TestTool", Description = "Test description", - Parameters = JsonDocument.Parse("{\"type\":\"object\",\"properties\":{}}").RootElement + Parameters = JsonElement.Parse("""{"type":"object","properties":{}}""") }; List aguiTools = [aguiTool]; @@ -116,7 +116,7 @@ public void AsAITools_WithAGUITool_ConvertsToAIFunctionDeclarationCorrectly() // Assert AITool tool = Assert.Single(tools); - Assert.IsAssignableFrom(tool); + Assert.IsType(tool, exactMatch: false); var declaration = (AIFunctionDeclaration)tool; Assert.Equal("TestTool", declaration.Name); Assert.Equal("Test description", declaration.Description); @@ -128,9 +128,9 @@ public void AsAITools_WithMultipleAGUITools_ConvertsAllCorrectly() // Arrange List aguiTools = [ - new AGUITool { Name = "Tool1", Description = "Desc1", Parameters = JsonDocument.Parse("{}").RootElement }, - new AGUITool { Name = "Tool2", Description = "Desc2", Parameters = JsonDocument.Parse("{}").RootElement }, - new AGUITool { Name = "Tool3", Description = "Desc3", Parameters = JsonDocument.Parse("{}").RootElement } + new AGUITool { Name = "Tool1", Description = "Desc1", Parameters = JsonElement.Parse("{}") }, + new AGUITool { Name = "Tool2", Description = "Desc2", Parameters = JsonElement.Parse("{}") }, + new AGUITool { Name = "Tool3", Description = "Desc3", Parameters = JsonElement.Parse("{}") } ]; // Act @@ -138,7 +138,7 @@ public void AsAITools_WithMultipleAGUITools_ConvertsAllCorrectly() // Assert Assert.Equal(3, tools.Count); - Assert.All(tools, t => Assert.IsAssignableFrom(t)); + Assert.All(tools, t => Assert.IsType(t, exactMatch: false)); } [Fact] @@ -176,7 +176,7 @@ public void AsAITools_CreatesDeclarationsOnly_NotInvokableFunctions() { Name = "RemoteTool", Description = "Tool implemented on server", - Parameters = JsonDocument.Parse("{\"type\":\"object\"}").RootElement + Parameters = JsonElement.Parse("""{"type":"object"}""") }; // Act @@ -185,7 +185,7 @@ public void AsAITools_CreatesDeclarationsOnly_NotInvokableFunctions() // Assert // The tool should be a declaration, not an executable function - Assert.IsAssignableFrom(tool); + Assert.IsType(tool, exactMatch: false); // AIFunctionDeclaration cannot be invoked (no implementation) // This is correct since the actual implementation exists on the client side } @@ -206,7 +206,7 @@ public void RoundTrip_AIFunctionToAGUIToolBackToDeclaration_PreservesMetadata() AITool reconstructed = aguiToolsList.AsAITools().Single(); // Assert - Assert.IsAssignableFrom(reconstructed); + Assert.IsType(reconstructed, exactMatch: false); var declaration = (AIFunctionDeclaration)reconstructed; Assert.Equal("FormatPerson", declaration.Name); Assert.Equal("Formats person information", declaration.Description); diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs index 3f6df1eeebd..7d40cc014db 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -400,7 +400,7 @@ public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataCo // Verify the JSON content string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); - JsonElement deserializedState = JsonSerializer.Deserialize(jsonText); + JsonElement deserializedState = JsonElement.Parse(jsonText); Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32()); Assert.Equal("active", deserializedState.GetProperty("status").GetString()); @@ -484,7 +484,7 @@ public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJ ChatResponseUpdate stateUpdate = updates.First(); DataContent dataContent = Assert.IsType(stateUpdate.Contents[0]); string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); - JsonElement roundTrippedState = JsonSerializer.Deserialize(jsonText); + JsonElement roundTrippedState = JsonElement.Parse(jsonText); Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString()); Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32()); @@ -555,7 +555,7 @@ public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataConte // Verify the JSON Patch content string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); - JsonElement deserializedDelta = JsonSerializer.Deserialize(jsonText); + JsonElement deserializedDelta = JsonElement.Parse(jsonText); Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind); Assert.Equal(2, deserializedDelta.GetArrayLength()); diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj index 96eff596881..0dab0aa9e41 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj @@ -1,13 +1,6 @@ - - $(ProjectsTargetFrameworks) - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs index bfa14a89d44..5111a97ad1b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; -using Moq.Protected; namespace Microsoft.Agents.AI.Abstractions.UnitTests; @@ -222,21 +221,6 @@ public void ValidateAgentIDIsIdempotent() Assert.Equal(id, agent.Id); } - [Fact] - public async Task NotifyThreadOfNewMessagesNotifiesThreadAsync() - { - var cancellationToken = default(CancellationToken); - - var messages = new[] { new ChatMessage(ChatRole.User, "msg1"), new ChatMessage(ChatRole.User, "msg2") }; - - var threadMock = new Mock { CallBase = true }; - threadMock.SetupAllProperties(); - - await MockAgent.NotifyThreadOfNewMessagesAsync(threadMock.Object, messages, cancellationToken); - - threadMock.Protected().Verify("MessagesReceivedAsync", Times.Once(), messages, cancellationToken); - } - #region GetService Method Tests /// @@ -360,9 +344,6 @@ public abstract class TestAgentThread : AgentThread; private sealed class MockAgent : AIAgent { - public static new Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IEnumerable messages, CancellationToken cancellationToken) => - AIAgent.NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken); - public override AgentThread GetNewThread() => throw new NotImplementedException(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs index 0b8f41f1bb5..b287c8b304e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs @@ -2,7 +2,6 @@ using System; using System.Collections.ObjectModel; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -162,10 +161,5 @@ public override ValueTask InvokingAsync(InvokingContext context, Canc { return default; } - - public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) - { - return base.Serialize(jsonSerializerOptions); - } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 32560949fb6..7460ea4623d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -17,7 +17,7 @@ public void CloningConstructorCopiesProperties() // Arrange var options = new AgentRunOptions { - ContinuationToken = new object(), + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), AllowBackgroundResponses = true, AdditionalProperties = new AdditionalPropertiesDictionary { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateTests.cs index 42d3fdf199f..32b7acd6734 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateTests.cs @@ -42,7 +42,7 @@ public void ConstructorWithChatResponseUpdateRoundtrips() RawRepresentation = new object(), ResponseId = "responseId", Role = ChatRole.Assistant, - ContinuationToken = new object(), + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), }; AgentRunResponseUpdate response = new(chatResponseUpdate); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentThreadTests.cs index 4d7c4ad219c..e75cb4caa1f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentThreadTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; -using Microsoft.Extensions.AI; #pragma warning disable CA1861 // Avoid constant arrays as arguments @@ -21,15 +19,6 @@ public void Serialize_ReturnsDefaultJsonElement() Assert.Equal(default, result); } - [Fact] - public void MessagesReceivedAsync_ReturnsCompletedTask() - { - var thread = new TestAgentThread(); - var messages = new List { new(ChatRole.User, "hello") }; - var result = thread.MessagesReceivedAsync(messages); - Assert.True(result.IsCompleted); - } - #region GetService Method Tests /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj index b7c5412a53f..1e5db6ed299 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj @@ -1,7 +1,6 @@ - $(ProjectsTargetFrameworks) $(NoWarn);MEAI001 @@ -13,9 +12,8 @@ - - - + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs index e451359c233..1da79344d49 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs @@ -115,7 +115,5 @@ public TestServiceIdAgentThread(JsonElement serializedThreadState) : base(serial } // Helper class to represent empty objects - internal sealed class EmptyObject - { - } + internal sealed class EmptyObject; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs new file mode 100644 index 00000000000..400bcf54565 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0052 // Remove unread private members + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Anthropic; +using Anthropic.Core; +using Anthropic.Services; +using Microsoft.Extensions.AI; +using Moq; +using IBetaMessageService = Anthropic.Services.Beta.IMessageService; +using IMessageService = Anthropic.Services.IMessageService; + +namespace Microsoft.Agents.AI.Anthropic.UnitTests.Extensions; + +/// +/// Unit tests for the AnthropicClientExtensions class. +/// +public sealed class AnthropicBetaServiceExtensionsTests +{ + /// + /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + var testChatClient = new TestChatClient(chatClient.Beta.AsIChatClient()); + + // Act + var agent = chatClient.Beta.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + name: "Test Agent", + description: "Test description", + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + TestChatClient? testChatClient = null; + + // Act + var agent = chatClient.Beta.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + clientFactory: (innerClient) => + innerClient.AsBuilder().Use((innerClient) => testChatClient = new TestChatClient(innerClient)).Build()); + + // Assert + Assert.NotNull(agent); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + var testChatClient = new TestChatClient(chatClient.Beta.AsIChatClient()); + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + ChatOptions = new() { Instructions = "Test instructions" } + }; + + // Act + var agent = chatClient.Beta.CreateAIAgent( + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + + // Act + var agent = chatClient.Beta.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + name: "Test Agent"); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + + // Act + var agent = chatClient.Beta.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + name: "Test Agent", + clientFactory: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((IBetaService)null!).CreateAIAgent("test-model")); + + Assert.Equal("betaService", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + + // Act & Assert + var exception = Assert.Throws(() => + chatClient.Beta.CreateAIAgent((ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : IChatClient + { + private readonly IChatClient _innerClient; + + public TestChatClient(IChatClient innerClient) + { + this._innerClient = innerClient; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._innerClient.GetResponseAsync(messages, options, cancellationToken); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + // Return this instance when requested + if (serviceType == typeof(TestChatClient)) + { + return this; + } + + return this._innerClient.GetService(serviceType, serviceKey); + } + + public void Dispose() => this._innerClient.Dispose(); + } + + /// + /// Creates a test ChatClient implementation for testing. + /// + private sealed class TestAnthropicChatClient : IAnthropicClient + { + public TestAnthropicChatClient() + { + this.BetaService = new TestBetaService(this); + } + + public HttpClient HttpClient { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public Uri BaseUrl { get => new("http://localhost"); init => throw new NotImplementedException(); } + public bool ResponseValidation { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public int? MaxRetries { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public TimeSpan? Timeout { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public string? APIKey { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public string? AuthToken { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + + public IMessageService Messages => throw new NotImplementedException(); + + public IModelService Models => throw new NotImplementedException(); + + public IBetaService Beta => this.BetaService; + + public IBetaService BetaService { get; } + + IMessageService IAnthropicClient.Messages => new Mock().Object; + + public Task Execute(HttpRequest request, CancellationToken cancellationToken = default) where T : ParamsBase + { + throw new NotImplementedException(); + } + + public IAnthropicClient WithOptions(Func modifier) + { + throw new NotImplementedException(); + } + + private sealed class TestBetaService : IBetaService + { + private readonly IAnthropicClient _client; + + public TestBetaService(IAnthropicClient client) + { + this._client = client; + } + + public global::Anthropic.Services.Beta.IModelService Models => throw new NotImplementedException(); + + public global::Anthropic.Services.Beta.IFileService Files => throw new NotImplementedException(); + + public global::Anthropic.Services.Beta.ISkillService Skills => throw new NotImplementedException(); + + public IBetaMessageService Messages => new Mock().Object; + + public IBetaService WithOptions(Func modifier) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicClientExtensionsTests.cs new file mode 100644 index 00000000000..c8bf4d6a5ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicClientExtensionsTests.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Anthropic; +using Anthropic.Core; +using Anthropic.Services; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Anthropic.UnitTests.Extensions; + +/// +/// Unit tests for the AnthropicClientExtensions class. +/// +public sealed class AnthropicClientExtensionsTests +{ + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : IChatClient + { + private readonly IChatClient _innerClient; + + public TestChatClient(IChatClient innerClient) + { + this._innerClient = innerClient; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._innerClient.GetResponseAsync(messages, options, cancellationToken); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + // Return this instance when requested + if (serviceType == typeof(TestChatClient)) + { + return this; + } + + return this._innerClient.GetService(serviceType, serviceKey); + } + + public void Dispose() => this._innerClient.Dispose(); + } + + /// + /// Creates a test ChatClient implementation for testing. + /// + private sealed class TestAnthropicChatClient : IAnthropicClient + { + public TestAnthropicChatClient() + { + } + + public HttpClient HttpClient { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public Uri BaseUrl { get => new("http://localhost"); init => throw new NotImplementedException(); } + public bool ResponseValidation { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public int? MaxRetries { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public TimeSpan? Timeout { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public string? APIKey { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + public string? AuthToken { get => throw new NotImplementedException(); init => throw new NotImplementedException(); } + + public IMessageService Messages => throw new NotImplementedException(); + + public IModelService Models => throw new NotImplementedException(); + + public IBetaService Beta => throw new NotImplementedException(); + + public Task Execute(HttpRequest request, CancellationToken cancellationToken = default) where T : ParamsBase + { + throw new NotImplementedException(); + } + + public IAnthropicClient WithOptions(Func modifier) + { + throw new NotImplementedException(); + } + } + + /// + /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + var testChatClient = new TestChatClient(chatClient.AsIChatClient()); + + // Act + var agent = chatClient.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + name: "Test Agent", + description: "Test description", + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + TestChatClient? testChatClient = null; + + // Act + var agent = chatClient.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + clientFactory: (innerClient) => + innerClient.AsBuilder().Use((innerClient) => testChatClient = new TestChatClient(innerClient)).Build()); + + // Assert + Assert.NotNull(agent); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + var testChatClient = new TestChatClient(chatClient.AsIChatClient()); + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + ChatOptions = new() { Instructions = "Test instructions" } + }; + + // Act + var agent = chatClient.CreateAIAgent( + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + + // Act + var agent = chatClient.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + name: "Test Agent"); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + + // Act + var agent = chatClient.CreateAIAgent( + model: "test-model", + instructions: "Test instructions", + name: "Test Agent", + clientFactory: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((TestAnthropicChatClient)null!).CreateAIAgent("test-model")); + + Assert.Equal("client", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var chatClient = new TestAnthropicChatClient(); + + // Act & Assert + var exception = Assert.Throws(() => + chatClient.CreateAIAgent((ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj new file mode 100644 index 00000000000..291c56f8792 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + true + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs index 56b89d2df87..b661a392be3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Azure; @@ -309,7 +310,7 @@ public void GetAIAgent_WithResponseAndOptions_WorksCorrectly() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -336,7 +337,7 @@ public void GetAIAgent_WithPersistentAgentAndOptions_WorksCorrectly() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -385,7 +386,7 @@ public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -412,7 +413,7 @@ public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -556,7 +557,7 @@ public void CreateAIAgent_WithOptions_WorksCorrectly() { Name = "Test Agent", Description = "Test description", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; // Act @@ -583,7 +584,7 @@ public async Task CreateAIAgentAsync_WithOptions_WorksCorrectlyAsync() { Name = "Test Agent", Description = "Test description", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; // Act @@ -726,6 +727,159 @@ public async Task CreateAIAgentAsync_WithEmptyModel_ThrowsArgumentExceptionAsync Assert.Equal("model", exception.ParamName); } + /// + /// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void CreateAIAgent_WithServices_PassesServicesToAgent() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var serviceProvider = new TestServiceProvider(); + const string Model = "test-model"; + + // Act + var agent = client.CreateAIAgent( + Model, + instructions: "Test instructions", + name: "Test Agent", + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that CreateAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public async Task CreateAIAgentAsync_WithServices_PassesServicesToAgentAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var serviceProvider = new TestServiceProvider(); + const string Model = "test-model"; + + // Act + var agent = await client.CreateAIAgentAsync( + Model, + instructions: "Test instructions", + name: "Test Agent", + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that GetAIAgent with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void GetAIAgent_WithServices_PassesServicesToAgent() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var serviceProvider = new TestServiceProvider(); + + // Act + var agent = client.GetAIAgent("agent_abc123", services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that GetAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public async Task GetAIAgentAsync_WithServices_PassesServicesToAgentAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var serviceProvider = new TestServiceProvider(); + + // Act + var agent = await client.GetAIAgentAsync("agent_abc123", services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that CreateAIAgent with both clientFactory and services works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var serviceProvider = new TestServiceProvider(); + TestChatClient? testChatClient = null; + const string Model = "test-model"; + + // Act + var agent = client.CreateAIAgent( + Model, + instructions: "Test instructions", + name: "Test Agent", + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient), + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the custom chat client was applied + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + + // Verify the IServiceProvider was passed through + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Uses reflection to access the FunctionInvocationServices property which is not public. + /// + private static IServiceProvider? GetFunctionInvocationServices(FunctionInvokingChatClient client) + { + var property = typeof(FunctionInvokingChatClient).GetProperty( + "FunctionInvocationServices", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return property?.GetValue(client) as IServiceProvider; + } + /// /// Test custom chat client that can be used to verify clientFactory functionality. /// @@ -736,6 +890,14 @@ public TestChatClient(IChatClient innerClient) : base(innerClient) } } + /// + /// A simple test IServiceProvider implementation for testing. + /// + private sealed class TestServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + public sealed class FakePersistentAgentsAdministrationClient : PersistentAgentsAdministrationClient { public FakePersistentAgentsAdministrationClient() @@ -761,7 +923,7 @@ private static PersistentAgentsClient CreateFakePersistentAgentsClient() { var client = new PersistentAgentsClient("https://any.com", DelegatedTokenCredential.Create((_, _) => new AccessToken())); - ((System.Reflection.TypeInfo)typeof(PersistentAgentsClient)).DeclaredFields.First(f => f.Name == "_client") + ((TypeInfo)typeof(PersistentAgentsClient)).DeclaredFields.First(f => f.Name == "_client") .SetValue(client, new FakePersistentAgentsAdministrationClient()); return client; } diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj index 80c00866750..ca33d52d6bc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj @@ -1,9 +1,5 @@ - - $(ProjectsTargetFrameworks) - - diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs index ede9b379191..1136d2b1f48 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -752,7 +752,7 @@ public void CreateAIAgent_WithModelAndOptions_CreatesValidAgent() var options = new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; // Act @@ -775,7 +775,7 @@ public void CreateAIAgent_WithModelAndOptions_WithClientFactory_AppliesFactoryCo var options = new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; TestChatClient? testChatClient = null; @@ -803,7 +803,7 @@ public async Task CreateAIAgentAsync_WithModelAndOptions_CreatesValidAgentAsync( var options = new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; // Act @@ -826,7 +826,7 @@ public async Task CreateAIAgentAsync_WithModelAndOptions_WithClientFactory_Appli var options = new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; TestChatClient? testChatClient = null; @@ -1575,8 +1575,8 @@ public void GetAIAgent_WithOptions_PreservesCustomProperties() var options = new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Custom instructions", - Description = "Custom description" + Description = "Custom description", + ChatOptions = new ChatOptions { Instructions = "Custom instructions" } }; // Act @@ -1610,8 +1610,7 @@ public void CreateAIAgent_WithOptionsAndTools_GeneratesCorrectOptions() var options = new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test", - ChatOptions = new ChatOptions { Tools = tools } + ChatOptions = new ChatOptions { Instructions = "Test", Tools = tools } }; // Act @@ -2736,7 +2735,7 @@ public override bool TryGetValues(string name, out IEnumerable? values) { if (this._headers.TryGetValue(name, out var value)) { - values = new[] { value }; + values = [value]; return true; } diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs index 647beb4451b..eee9f520b62 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs @@ -49,8 +49,7 @@ public async Task ChatClient_UsesDefaultConversationIdAsync() new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions", - ChatOptions = new() { ConversationId = "conv_12345" } + ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_12345" } }); // Act @@ -99,7 +98,7 @@ public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversat new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions", + ChatOptions = new() { Instructions = "Test instructions" }, }); // Act @@ -148,8 +147,7 @@ public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConvers new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions", - ChatOptions = new() { ConversationId = "conv_should_not_use_default" } + ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_should_not_use_default" } }); // Act @@ -198,7 +196,7 @@ public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixe new ChatClientAgentOptions { Name = "test-agent", - Instructions = "Test instructions", + ChatOptions = new() { Instructions = "Test instructions" }, }); // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj index 79bc5776615..193a7d47daa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj @@ -1,9 +1,5 @@ - - $(ProjectsTargetFrameworks) - - diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/.editorconfig b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/.editorconfig new file mode 100644 index 00000000000..83e05f582a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/.editorconfig @@ -0,0 +1,9 @@ +# EditorConfig overrides for Cosmos DB Unit Tests +# Multi-targeting (net472 + net9.0) causes false positives for IDE0005 (unnecessary using directives) + +root = false + +[*.cs] +# Suppress IDE0005 for this project - multi-targeting causes false positives +# These using directives ARE necessary but appear unnecessary in one target framework +dotnet_diagnostic.IDE0005.severity = none diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs new file mode 100644 index 00000000000..6f2a256206e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs @@ -0,0 +1,760 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.AI; +using Xunit; + +namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; + +/// +/// Contains tests for . +/// +/// Test Modes: +/// - Default Mode: Cleans up all test data after each test run (deletes database) +/// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer +/// +/// To enable Preserve Mode, set environment variable: COSMOS_PRESERVE_CONTAINERS=true +/// Example: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test +/// +/// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at: +/// https://localhost:8081/_explorer/index.html +/// Database: AgentFrameworkTests +/// Container: ChatMessages +/// +/// Environment Variable Reference: +/// | Variable | Values | Description | +/// |----------|--------|-------------| +/// | COSMOS_PRESERVE_CONTAINERS | true / false | Controls whether to preserve test data after completion | +/// +/// Usage Examples: +/// - Run all tests in preserve mode: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ +/// - Run specific test category in preserve mode: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ --filter "Category=CosmosDB" +/// - Reset to cleanup mode: $env:COSMOS_PRESERVE_CONTAINERS=""; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ +/// +[Collection("CosmosDB")] +public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable +{ + // Cosmos DB Emulator connection settings + private const string EmulatorEndpoint = "https://localhost:8081"; + private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + private const string TestContainerId = "ChatMessages"; + private const string HierarchicalTestContainerId = "HierarchicalChatMessages"; + // Use unique database ID per test class instance to avoid conflicts +#pragma warning disable CA1802 // Use literals where appropriate + private static readonly string s_testDatabaseId = $"AgentFrameworkTests-ChatStore-{Guid.NewGuid():N}"; +#pragma warning restore CA1802 + + private string _connectionString = string.Empty; + private bool _emulatorAvailable; + private bool _preserveContainer; + private CosmosClient? _setupClient; // Only used for test setup/cleanup + + public async Task InitializeAsync() + { + // Check environment variable to determine if we should preserve containers + // Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection + this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase); + + this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}"; + + try + { + // Only create CosmosClient for test setup - the actual tests will use connection string constructors + this._setupClient = new CosmosClient(EmulatorEndpoint, EmulatorKey); + + // Test connection by attempting to create database + var databaseResponse = await this._setupClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId); + + // Create container for simple partitioning tests + await databaseResponse.Database.CreateContainerIfNotExistsAsync( + TestContainerId, + "/conversationId", + throughput: 400); + + // Create container for hierarchical partitioning tests with hierarchical partition key + var hierarchicalContainerProperties = new ContainerProperties(HierarchicalTestContainerId, new List { "/tenantId", "/userId", "/sessionId" }); + await databaseResponse.Database.CreateContainerIfNotExistsAsync( + hierarchicalContainerProperties, + throughput: 400); + + this._emulatorAvailable = true; + } + catch (Exception) + { + // Emulator not available, tests will be skipped + this._emulatorAvailable = false; + this._setupClient?.Dispose(); + this._setupClient = null; + } + } + + public async Task DisposeAsync() + { + if (this._setupClient != null && this._emulatorAvailable) + { + try + { + if (this._preserveContainer) + { + // Preserve mode: Don't delete the database/container, keep data for inspection + // This allows viewing data in the Cosmos DB Emulator Data Explorer + // No cleanup needed - data persists for debugging + } + else + { + // Clean mode: Delete the test database and all data + var database = this._setupClient.GetDatabase(s_testDatabaseId); + await database.DeleteAsync(); + } + } + catch (Exception ex) + { + // Ignore cleanup errors during test teardown + Console.WriteLine($"Warning: Cleanup failed: {ex.Message}"); + } + finally + { + this._setupClient.Dispose(); + } + } + } + + public void Dispose() + { + this._setupClient?.Dispose(); + GC.SuppressFinalize(this); + } + + private void SkipIfEmulatorNotAvailable() + { + // In CI: Skip if COSMOS_EMULATOR_AVAILABLE is not set to "true" + // Locally: Skip if emulator connection check failed + var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOS_EMULATOR_AVAILABLE"), "true", StringComparison.OrdinalIgnoreCase); + + Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available"); + } + + #region Constructor Tests + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithConnectionString_ShouldCreateInstance() + { + // Arrange & Act + this.SkipIfEmulatorNotAvailable(); + + // Act + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, "test-conversation"); + + // Assert + Assert.NotNull(store); + Assert.Equal("test-conversation", store.ConversationId); + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(TestContainerId, store.ContainerId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithConnectionStringNoConversationId_ShouldCreateInstance() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + + // Act + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId); + + // Assert + Assert.NotNull(store); + Assert.NotNull(store.ConversationId); + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(TestContainerId, store.ContainerId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithNullConnectionString_ShouldThrowArgumentException() + { + // Arrange & Act & Assert + Assert.Throws(() => + new CosmosChatMessageStore((string)null!, s_testDatabaseId, TestContainerId, "test-conversation")); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithEmptyConversationId_ShouldThrowArgumentException() + { + // Arrange & Act & Assert + this.SkipIfEmulatorNotAvailable(); + + Assert.Throws(() => + new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, "")); + } + + #endregion + + #region AddMessagesAsync Tests + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + var conversationId = Guid.NewGuid().ToString(); + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); + var message = new ChatMessage(ChatRole.User, "Hello, world!"); + + // Act + await store.AddMessagesAsync([message]); + + // Wait a moment for eventual consistency + await Task.Delay(100); + + // Assert + var messages = await store.GetMessagesAsync(); + var messageList = messages.ToList(); + + // Simple assertion - if this fails, we know the deserialization is the issue + if (messageList.Count == 0) + { + // Let's check if we can find ANY items in the container for this conversation + var directQuery = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId") + .WithParameter("@conversationId", conversationId); + var countIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId) + .GetItemQueryIterator(directQuery, requestOptions: new QueryRequestOptions + { + PartitionKey = new PartitionKey(conversationId) + }); + + var countResponse = await countIterator.ReadNextAsync(); + var count = countResponse.FirstOrDefault(); + + // Debug: Let's see what the raw query returns + var rawQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId") + .WithParameter("@conversationId", conversationId); + var rawIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId) + .GetItemQueryIterator(rawQuery, requestOptions: new QueryRequestOptions + { + PartitionKey = new PartitionKey(conversationId) + }); + + List rawResults = new(); + while (rawIterator.HasMoreResults) + { + var rawResponse = await rawIterator.ReadNextAsync(); + rawResults.AddRange(rawResponse); + } + + string rawJson = rawResults.Count > 0 ? Newtonsoft.Json.JsonConvert.SerializeObject(rawResults[0], Newtonsoft.Json.Formatting.Indented) : "null"; + Assert.Fail($"GetMessagesAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}"); + } + + Assert.Single(messageList); + Assert.Equal("Hello, world!", messageList[0].Text); + Assert.Equal(ChatRole.User, messageList[0].Role); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task AddMessagesAsync_WithMultipleMessages_ShouldAddAllMessagesAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + var conversationId = Guid.NewGuid().ToString(); + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); + var messages = new[] + { + new ChatMessage(ChatRole.User, "First message"), + new ChatMessage(ChatRole.Assistant, "Second message"), + new ChatMessage(ChatRole.User, "Third message") + }; + + // Act + await store.AddMessagesAsync(messages); + + // Assert + var retrievedMessages = await store.GetMessagesAsync(); + var messageList = retrievedMessages.ToList(); + Assert.Equal(3, messageList.Count); + Assert.Equal("First message", messageList[0].Text); + Assert.Equal("Second message", messageList[1].Text); + Assert.Equal("Third message", messageList[2].Text); + } + + #endregion + + #region GetMessagesAsync Tests + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task GetMessagesAsync_WithNoMessages_ShouldReturnEmptyAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString()); + + // Act + var messages = await store.GetMessagesAsync(); + + // Assert + Assert.Empty(messages); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + var conversation1 = Guid.NewGuid().ToString(); + var conversation2 = Guid.NewGuid().ToString(); + + using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation1); + using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation2); + + await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 1")]); + await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 2")]); + + // Act + var messages1 = await store1.GetMessagesAsync(); + var messages2 = await store2.GetMessagesAsync(); + + // Assert + var messageList1 = messages1.ToList(); + var messageList2 = messages2.ToList(); + Assert.Single(messageList1); + Assert.Single(messageList2); + Assert.Equal("Message for conversation 1", messageList1[0].Text); + Assert.Equal("Message for conversation 2", messageList2[0].Text); + } + + #endregion + + #region Integration Tests + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + var conversationId = $"test-conversation-{Guid.NewGuid():N}"; // Use unique conversation ID + using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); + + var messages = new[] + { + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello!"), + new ChatMessage(ChatRole.Assistant, "Hi there! How can I help you today?"), + new ChatMessage(ChatRole.User, "What's the weather like?"), + new ChatMessage(ChatRole.Assistant, "I'm sorry, I don't have access to current weather data.") + }; + + // Act 1: Add messages + await originalStore.AddMessagesAsync(messages); + + // Act 2: Verify messages were added + var retrievedMessages = await originalStore.GetMessagesAsync(); + var retrievedList = retrievedMessages.ToList(); + Assert.Equal(5, retrievedList.Count); + + // Act 3: Create new store instance for same conversation (test persistence) + using var newStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId); + var persistedMessages = await newStore.GetMessagesAsync(); + var persistedList = persistedMessages.ToList(); + + // Assert final state + Assert.Equal(5, persistedList.Count); + Assert.Equal("You are a helpful assistant.", persistedList[0].Text); + Assert.Equal("Hello!", persistedList[1].Text); + Assert.Equal("Hi there! How can I help you today?", persistedList[2].Text); + Assert.Equal("What's the weather like?", persistedList[3].Text); + Assert.Equal("I'm sorry, I don't have access to current weather data.", persistedList[4].Text); + } + + #endregion + + #region Disposal Tests + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Dispose_AfterUse_ShouldNotThrow() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString()); + + // Act & Assert + store.Dispose(); // Should not throw + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Dispose_MultipleCalls_ShouldNotThrow() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString()); + + // Act & Assert + store.Dispose(); // First call + store.Dispose(); // Second call - should not throw + } + + #endregion + + #region Hierarchical Partitioning Tests + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithHierarchicalConnectionString_ShouldCreateInstance() + { + // Arrange & Act + this.SkipIfEmulatorNotAvailable(); + + // Act + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789"); + + // Assert + Assert.NotNull(store); + Assert.Equal("session-789", store.ConversationId); + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(HierarchicalTestContainerId, store.ContainerId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithHierarchicalEndpoint_ShouldCreateInstance() + { + // Arrange & Act + this.SkipIfEmulatorNotAvailable(); + + // Act + TokenCredential credential = new DefaultAzureCredential(); + using var store = new CosmosChatMessageStore(EmulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789"); + + // Assert + Assert.NotNull(store); + Assert.Equal("session-789", store.ConversationId); + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(HierarchicalTestContainerId, store.ContainerId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithHierarchicalCosmosClient_ShouldCreateInstance() + { + // Arrange & Act + this.SkipIfEmulatorNotAvailable(); + + using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey); + using var store = new CosmosChatMessageStore(cosmosClient, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789"); + + // Assert + Assert.NotNull(store); + Assert.Equal("session-789", store.ConversationId); + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(HierarchicalTestContainerId, store.ContainerId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithHierarchicalNullTenantId_ShouldThrowArgumentException() + { + // Arrange & Act & Assert + this.SkipIfEmulatorNotAvailable(); + + Assert.Throws(() => + new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, null!, "user-456", "session-789")); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithHierarchicalEmptyUserId_ShouldThrowArgumentException() + { + // Arrange & Act & Assert + this.SkipIfEmulatorNotAvailable(); + + Assert.Throws(() => + new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "", "session-789")); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public void Constructor_WithHierarchicalWhitespaceSessionId_ShouldThrowArgumentException() + { + // Arrange & Act & Assert + this.SkipIfEmulatorNotAvailable(); + + Assert.Throws(() => + new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", " ")); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string TenantId = "tenant-123"; + const string UserId = "user-456"; + const string SessionId = "session-789"; + // Test hierarchical partitioning constructor with connection string + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId); + var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!"); + + // Act + await store.AddMessagesAsync([message]); + + // Wait a moment for eventual consistency + await Task.Delay(100); + + // Assert + var messages = await store.GetMessagesAsync(); + var messageList = messages.ToList(); + + Assert.Single(messageList); + Assert.Equal("Hello from hierarchical partitioning!", messageList[0].Text); + Assert.Equal(ChatRole.User, messageList[0].Role); + + // Verify that the document is stored with hierarchical partitioning metadata + var directQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type") + .WithParameter("@conversationId", SessionId) + .WithParameter("@type", "ChatMessage"); + + var iterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(HierarchicalTestContainerId) + .GetItemQueryIterator(directQuery, requestOptions: new QueryRequestOptions + { + PartitionKey = new PartitionKeyBuilder().Add(TenantId).Add(UserId).Add(SessionId).Build() + }); + + var response = await iterator.ReadNextAsync(); + var document = response.FirstOrDefault(); + + Assert.NotNull(document); + // The document should have hierarchical metadata + Assert.Equal(SessionId, (string)document!.conversationId); + Assert.Equal(TenantId, (string)document!.tenantId); + Assert.Equal(UserId, (string)document!.userId); + Assert.Equal(SessionId, (string)document!.sessionId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string TenantId = "tenant-batch"; + const string UserId = "user-batch"; + const string SessionId = "session-batch"; + // Test hierarchical partitioning constructor with connection string + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId); + var messages = new[] + { + new ChatMessage(ChatRole.User, "First hierarchical message"), + new ChatMessage(ChatRole.Assistant, "Second hierarchical message"), + new ChatMessage(ChatRole.User, "Third hierarchical message") + }; + + // Act + await store.AddMessagesAsync(messages); + + // Wait a moment for eventual consistency + await Task.Delay(100); + + // Assert + var retrievedMessages = await store.GetMessagesAsync(); + var messageList = retrievedMessages.ToList(); + + Assert.Equal(3, messageList.Count); + Assert.Equal("First hierarchical message", messageList[0].Text); + Assert.Equal("Second hierarchical message", messageList[1].Text); + Assert.Equal("Third hierarchical message", messageList[2].Text); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string TenantId = "tenant-isolation"; + const string UserId1 = "user-1"; + const string UserId2 = "user-2"; + const string SessionId = "session-isolation"; + + // Different userIds create different hierarchical partitions, providing proper isolation + using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId1, SessionId); + using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId2, SessionId); + + // Add messages to both stores + await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 1")]); + await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 2")]); + + // Wait a moment for eventual consistency + await Task.Delay(100); + + // Act & Assert + var messages1 = await store1.GetMessagesAsync(); + var messageList1 = messages1.ToList(); + + var messages2 = await store2.GetMessagesAsync(); + var messageList2 = messages2.ToList(); + + // With true hierarchical partitioning, each user sees only their own messages + Assert.Single(messageList1); + Assert.Single(messageList2); + Assert.Equal("Message from user 1", messageList1[0].Text); + Assert.Equal("Message from user 2", messageList2[0].Text); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreserveStateAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string TenantId = "tenant-serialize"; + const string UserId = "user-serialize"; + const string SessionId = "session-serialize"; + + using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId); + await originalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Test serialization message")]); + + // Act - Serialize the store state + var serializedState = originalStore.Serialize(); + + // Create a new store from the serialized state + using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey); + var serializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + using var deserializedStore = CosmosChatMessageStore.CreateFromSerializedState(cosmosClient, serializedState, s_testDatabaseId, HierarchicalTestContainerId, serializerOptions); + + // Wait a moment for eventual consistency + await Task.Delay(100); + + // Assert - The deserialized store should have the same functionality + var messages = await deserializedStore.GetMessagesAsync(); + var messageList = messages.ToList(); + + Assert.Single(messageList); + Assert.Equal("Test serialization message", messageList[0].Text); + Assert.Equal(SessionId, deserializedStore.ConversationId); + Assert.Equal(s_testDatabaseId, deserializedStore.DatabaseId); + Assert.Equal(HierarchicalTestContainerId, deserializedStore.ContainerId); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string SessionId = "coexist-session"; + + // Create simple store using simple partitioning container and hierarchical store using hierarchical container + using var simpleStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, SessionId); + using var hierarchicalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-coexist", "user-coexist", SessionId); + + // Add messages to both + await simpleStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Simple partitioning message")]); + await hierarchicalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")]); + + // Wait a moment for eventual consistency + await Task.Delay(100); + + // Act & Assert + var simpleMessages = await simpleStore.GetMessagesAsync(); + var simpleMessageList = simpleMessages.ToList(); + + var hierarchicalMessages = await hierarchicalStore.GetMessagesAsync(); + var hierarchicalMessageList = hierarchicalMessages.ToList(); + + // Each should only see its own messages since they use different containers + Assert.Single(simpleMessageList); + Assert.Single(hierarchicalMessageList); + Assert.Equal("Simple partitioning message", simpleMessageList[0].Text); + Assert.Equal("Hierarchical partitioning message", hierarchicalMessageList[0].Text); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string ConversationId = "max-messages-test"; + + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ConversationId); + + // Add 10 messages + var messages = new List(); + for (int i = 1; i <= 10; i++) + { + messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); + await Task.Delay(10); // Small delay to ensure different timestamps + } + await store.AddMessagesAsync(messages); + + // Wait for eventual consistency + await Task.Delay(100); + + // Act - Set max to 5 and retrieve + store.MaxMessagesToRetrieve = 5; + var retrievedMessages = await store.GetMessagesAsync(); + var messageList = retrievedMessages.ToList(); + + // Assert - Should get the 5 most recent messages (6-10) in ascending order + Assert.Equal(5, messageList.Count); + Assert.Equal("Message 6", messageList[0].Text); + Assert.Equal("Message 7", messageList[1].Text); + Assert.Equal("Message 8", messageList[2].Text); + Assert.Equal("Message 9", messageList[3].Text); + Assert.Equal("Message 10", messageList[4].Text); + } + + [SkippableFact] + [Trait("Category", "CosmosDB")] + public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + const string ConversationId = "max-messages-null-test"; + + using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ConversationId); + + // Add 10 messages + var messages = new List(); + for (int i = 1; i <= 10; i++) + { + messages.Add(new ChatMessage(ChatRole.User, $"Message {i}")); + } + await store.AddMessagesAsync(messages); + + // Wait for eventual consistency + await Task.Delay(100); + + // Act - No limit set (default null) + var retrievedMessages = await store.GetMessagesAsync(); + var messageList = retrievedMessages.ToList(); + + // Assert - Should get all 10 messages + Assert.Equal(10, messageList.Count); + Assert.Equal("Message 1", messageList[0].Text); + Assert.Equal("Message 10", messageList[9].Text); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs new file mode 100644 index 00000000000..dfa1f142215 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs @@ -0,0 +1,454 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Azure.Cosmos; +using Xunit; + +namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; + +/// +/// Contains tests for . +/// +/// Test Modes: +/// - Default Mode: Cleans up all test data after each test run (deletes database) +/// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer +/// +/// To enable Preserve Mode, set environment variable: COSMOS_PRESERVE_CONTAINERS=true +/// Example: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test +/// +/// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at: +/// https://localhost:8081/_explorer/index.html +/// Database: AgentFrameworkTests +/// Container: Checkpoints +/// +[Collection("CosmosDB")] +public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable +{ + // Cosmos DB Emulator connection settings + private const string EmulatorEndpoint = "https://localhost:8081"; + private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + private const string TestContainerId = "Checkpoints"; + // Use unique database ID per test class instance to avoid conflicts +#pragma warning disable CA1802 // Use literals where appropriate + private static readonly string s_testDatabaseId = $"AgentFrameworkTests-CheckpointStore-{Guid.NewGuid():N}"; +#pragma warning restore CA1802 + + private string _connectionString = string.Empty; + private CosmosClient? _cosmosClient; + private Database? _database; + private bool _emulatorAvailable; + private bool _preserveContainer; + + // JsonSerializerOptions configured for .NET 9+ compatibility + private static readonly JsonSerializerOptions s_jsonOptions = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(); +#if NET9_0_OR_GREATER + options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver(); +#endif + return options; + } + + public async Task InitializeAsync() + { + // Check environment variable to determine if we should preserve containers + // Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection + this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase); + + this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}"; + + try + { + this._cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey); + + // Test connection by attempting to create database + this._database = await this._cosmosClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId); + await this._database.CreateContainerIfNotExistsAsync( + TestContainerId, + "/runId", + throughput: 400); + + this._emulatorAvailable = true; + } + catch (Exception ex) when (!(ex is OutOfMemoryException || ex is StackOverflowException || ex is AccessViolationException)) + { + // Emulator not available, tests will be skipped + this._emulatorAvailable = false; + this._cosmosClient?.Dispose(); + this._cosmosClient = null; + } + } + + public async Task DisposeAsync() + { + if (this._cosmosClient != null && this._emulatorAvailable) + { + try + { + if (this._preserveContainer) + { + // Preserve mode: Don't delete the database/container, keep data for inspection + // This allows viewing data in the Cosmos DB Emulator Data Explorer + // No cleanup needed - data persists for debugging + } + else + { + // Clean mode: Delete the test database and all data + await this._database!.DeleteAsync(); + } + } + catch (Exception ex) + { + // Ignore cleanup errors, but log for diagnostics + Console.WriteLine($"[DisposeAsync] Cleanup error: {ex.Message}\n{ex.StackTrace}"); + } + finally + { + this._cosmosClient.Dispose(); + } + } + } + + private void SkipIfEmulatorNotAvailable() + { + // In CI: Skip if COSMOS_EMULATOR_AVAILABLE is not set to "true" + // Locally: Skip if emulator connection check failed + var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOS_EMULATOR_AVAILABLE"), "true", StringComparison.OrdinalIgnoreCase); + + Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available"); + } + + #region Constructor Tests + + [SkippableFact] + public void Constructor_WithCosmosClient_SetsProperties() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + + // Act + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + + // Assert + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(TestContainerId, store.ContainerId); + } + + [SkippableFact] + public void Constructor_WithConnectionString_SetsProperties() + { + // Arrange + this.SkipIfEmulatorNotAvailable(); + + // Act + using var store = new CosmosCheckpointStore(this._connectionString, s_testDatabaseId, TestContainerId); + + // Assert + Assert.Equal(s_testDatabaseId, store.DatabaseId); + Assert.Equal(TestContainerId, store.ContainerId); + } + + [SkippableFact] + public void Constructor_WithNullCosmosClient_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new CosmosCheckpointStore((CosmosClient)null!, s_testDatabaseId, TestContainerId)); + } + + [SkippableFact] + public void Constructor_WithNullConnectionString_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => + new CosmosCheckpointStore((string)null!, s_testDatabaseId, TestContainerId)); + } + + #endregion + + #region Checkpoint Operations Tests + + [SkippableFact] + public async Task CreateCheckpointAsync_NewCheckpoint_CreatesSuccessfullyAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test checkpoint" }, s_jsonOptions); + + // Act + var checkpointInfo = await store.CreateCheckpointAsync(runId, checkpointValue); + + // Assert + Assert.NotNull(checkpointInfo); + Assert.Equal(runId, checkpointInfo.RunId); + Assert.NotNull(checkpointInfo.CheckpointId); + Assert.NotEmpty(checkpointInfo.CheckpointId); + } + + [SkippableFact] + public async Task RetrieveCheckpointAsync_ExistingCheckpoint_ReturnsCorrectValueAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + var originalData = new { message = "Hello, World!", timestamp = DateTimeOffset.UtcNow }; + var checkpointValue = JsonSerializer.SerializeToElement(originalData, s_jsonOptions); + + // Act + var checkpointInfo = await store.CreateCheckpointAsync(runId, checkpointValue); + var retrievedValue = await store.RetrieveCheckpointAsync(runId, checkpointInfo); + + // Assert + Assert.Equal(JsonValueKind.Object, retrievedValue.ValueKind); + Assert.True(retrievedValue.TryGetProperty("message", out var messageProp)); + Assert.Equal("Hello, World!", messageProp.GetString()); + } + + [SkippableFact] + public async Task RetrieveCheckpointAsync_NonExistentCheckpoint_ThrowsInvalidOperationExceptionAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + var fakeCheckpointInfo = new CheckpointInfo(runId, "nonexistent-checkpoint"); + + // Act & Assert + await Assert.ThrowsAsync(() => + store.RetrieveCheckpointAsync(runId, fakeCheckpointInfo).AsTask()); + } + + [SkippableFact] + public async Task RetrieveIndexAsync_EmptyStore_ReturnsEmptyCollectionAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + + // Act + var index = await store.RetrieveIndexAsync(runId); + + // Assert + Assert.NotNull(index); + Assert.Empty(index); + } + + [SkippableFact] + public async Task RetrieveIndexAsync_WithCheckpoints_ReturnsAllCheckpointsAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Create multiple checkpoints + var checkpoint1 = await store.CreateCheckpointAsync(runId, checkpointValue); + var checkpoint2 = await store.CreateCheckpointAsync(runId, checkpointValue); + var checkpoint3 = await store.CreateCheckpointAsync(runId, checkpointValue); + + // Act + var index = (await store.RetrieveIndexAsync(runId)).ToList(); + + // Assert + Assert.Equal(3, index.Count); + Assert.Contains(index, c => c.CheckpointId == checkpoint1.CheckpointId); + Assert.Contains(index, c => c.CheckpointId == checkpoint2.CheckpointId); + Assert.Contains(index, c => c.CheckpointId == checkpoint3.CheckpointId); + } + + [SkippableFact] + public async Task CreateCheckpointAsync_WithParent_CreatesHierarchyAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Act + var parentCheckpoint = await store.CreateCheckpointAsync(runId, checkpointValue); + var childCheckpoint = await store.CreateCheckpointAsync(runId, checkpointValue, parentCheckpoint); + + // Assert + Assert.NotEqual(parentCheckpoint.CheckpointId, childCheckpoint.CheckpointId); + Assert.Equal(runId, parentCheckpoint.RunId); + Assert.Equal(runId, childCheckpoint.RunId); + } + + [SkippableFact] + public async Task RetrieveIndexAsync_WithParentFilter_ReturnsFilteredResultsAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Create parent and child checkpoints + var parent = await store.CreateCheckpointAsync(runId, checkpointValue); + var child1 = await store.CreateCheckpointAsync(runId, checkpointValue, parent); + var child2 = await store.CreateCheckpointAsync(runId, checkpointValue, parent); + + // Create an orphan checkpoint + var orphan = await store.CreateCheckpointAsync(runId, checkpointValue); + + // Act + var allCheckpoints = (await store.RetrieveIndexAsync(runId)).ToList(); + var childrenOfParent = (await store.RetrieveIndexAsync(runId, parent)).ToList(); + + // Assert + Assert.Equal(4, allCheckpoints.Count); // parent + 2 children + orphan + Assert.Equal(2, childrenOfParent.Count); // only children + + Assert.Contains(childrenOfParent, c => c.CheckpointId == child1.CheckpointId); + Assert.Contains(childrenOfParent, c => c.CheckpointId == child2.CheckpointId); + Assert.DoesNotContain(childrenOfParent, c => c.CheckpointId == parent.CheckpointId); + Assert.DoesNotContain(childrenOfParent, c => c.CheckpointId == orphan.CheckpointId); + } + + #endregion + + #region Run Isolation Tests + + [SkippableFact] + public async Task CheckpointOperations_DifferentRuns_IsolatesDataAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId1 = Guid.NewGuid().ToString(); + var runId2 = Guid.NewGuid().ToString(); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Act + var checkpoint1 = await store.CreateCheckpointAsync(runId1, checkpointValue); + var checkpoint2 = await store.CreateCheckpointAsync(runId2, checkpointValue); + + var index1 = (await store.RetrieveIndexAsync(runId1)).ToList(); + var index2 = (await store.RetrieveIndexAsync(runId2)).ToList(); + + // Assert + Assert.Single(index1); + Assert.Single(index2); + Assert.Equal(checkpoint1.CheckpointId, index1[0].CheckpointId); + Assert.Equal(checkpoint2.CheckpointId, index2[0].CheckpointId); + Assert.NotEqual(checkpoint1.CheckpointId, checkpoint2.CheckpointId); + } + + #endregion + + #region Error Handling Tests + + [SkippableFact] + public async Task CreateCheckpointAsync_WithNullRunId_ThrowsArgumentExceptionAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Act & Assert + await Assert.ThrowsAsync(() => + store.CreateCheckpointAsync(null!, checkpointValue).AsTask()); + } + + [SkippableFact] + public async Task CreateCheckpointAsync_WithEmptyRunId_ThrowsArgumentExceptionAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Act & Assert + await Assert.ThrowsAsync(() => + store.CreateCheckpointAsync("", checkpointValue).AsTask()); + } + + [SkippableFact] + public async Task RetrieveCheckpointAsync_WithNullCheckpointInfo_ThrowsArgumentNullExceptionAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var runId = Guid.NewGuid().ToString(); + + // Act & Assert + await Assert.ThrowsAsync(() => + store.RetrieveCheckpointAsync(runId, null!).AsTask()); + } + + #endregion + + #region Disposal Tests + + [SkippableFact] + public async Task Dispose_AfterDisposal_ThrowsObjectDisposedExceptionAsync() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions); + + // Act + store.Dispose(); + + // Assert + await Assert.ThrowsAsync(() => + store.CreateCheckpointAsync("test-run", checkpointValue).AsTask()); + } + + [SkippableFact] + public void Dispose_MultipleCalls_DoesNotThrow() + { + this.SkipIfEmulatorNotAvailable(); + + // Arrange + var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); + + // Act & Assert (should not throw) + store.Dispose(); + store.Dispose(); + store.Dispose(); + } + + #endregion + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this._cosmosClient?.Dispose(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs new file mode 100644 index 00000000000..195c433de59 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosDBCollectionFixture.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Xunit; + +namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; + +/// +/// Defines a collection fixture for Cosmos DB tests to ensure they run sequentially. +/// This prevents race conditions and resource conflicts when tests create and delete +/// databases in the Cosmos DB Emulator. +/// +[CollectionDefinition("CosmosDB", DisableParallelization = true)] +public sealed class CosmosDBCollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj new file mode 100644 index 00000000000..d60418ee2c4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net10.0;net9.0 + $(NoWarn);MEAI001 + + + + false + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs new file mode 100644 index 00000000000..31cadfb0cea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AgentBotElementYamlTests.cs @@ -0,0 +1,310 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerFx; + +namespace Microsoft.Agents.AI.Declarative.UnitTests; + +/// +/// Unit tests for +/// +public sealed class AgentBotElementYamlTests +{ + [Theory] + [InlineData(PromptAgents.AgentWithEverything)] + [InlineData(PromptAgents.AgentWithApiKeyConnection)] + [InlineData(PromptAgents.AgentWithVariableReferences)] + [InlineData(PromptAgents.AgentWithOutputSchema)] + [InlineData(PromptAgents.OpenAIChatAgent)] + [InlineData(PromptAgents.AgentWithCurrentModels)] + [InlineData(PromptAgents.AgentWithRemoteConnection)] + public void FromYaml_DoesNotThrow(string text) + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(text); + + // Assert + Assert.NotNull(agent); + } + + [Fact] + public void FromYaml_NotPromptAgent_Throws() + { + // Arrange & Act & Assert + Assert.Throws(() => AgentBotElementYaml.FromYaml(PromptAgents.Workflow)); + } + + [Fact] + public void FromYaml_Properties() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything); + + // Assert + Assert.NotNull(agent); + Assert.Equal("AgentName", agent.Name); + Assert.Equal("Agent description", agent.Description); + Assert.Equal("You are a helpful assistant.", agent.Instructions?.ToTemplateString()); + Assert.NotNull(agent.Model); + Assert.True(agent.Tools.Length > 0); + } + + [Fact] + public void FromYaml_CurrentModels() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithCurrentModels); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.Model); + Assert.Equal("gpt-4o", agent.Model.ModelNameHint); + Assert.NotNull(agent.Model.Options); + Assert.Equal(0.7f, (float?)agent.Model.Options?.Temperature?.LiteralValue); + Assert.Equal(0.9f, (float?)agent.Model.Options?.TopP?.LiteralValue); + + // Assert contents using extension methods + Assert.Equal(1024, agent.Model.Options?.MaxOutputTokens?.LiteralValue); + Assert.Equal(50, agent.Model.Options?.TopK?.LiteralValue); + Assert.Equal(0.7f, (float?)agent.Model.Options?.FrequencyPenalty?.LiteralValue); + Assert.Equal(0.7f, (float?)agent.Model.Options?.PresencePenalty?.LiteralValue); + Assert.Equal(42, agent.Model.Options?.Seed?.LiteralValue); + Assert.Equal(PromptAgents.s_stopSequences, agent.Model.Options?.StopSequences); + Assert.True(agent.Model.Options?.AllowMultipleToolCalls?.LiteralValue); + Assert.Equal(ChatToolMode.Auto, agent.Model.Options?.AsChatToolMode()); + } + + [Fact] + public void FromYaml_OutputSchema() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithOutputSchema); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.OutputType); + ChatResponseFormatJson responseFormat = (agent.OutputType.AsChatResponseFormat() as ChatResponseFormatJson)!; + Assert.NotNull(responseFormat); + Assert.NotNull(responseFormat.Schema); + } + + [Fact] + public void FromYaml_CodeInterpreter() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything); + + // Assert + Assert.NotNull(agent); + var tools = agent.Tools; + var codeInterpreterTools = tools.Where(t => t is CodeInterpreterTool).ToArray(); + Assert.Single(codeInterpreterTools); + CodeInterpreterTool codeInterpreterTool = (codeInterpreterTools[0] as CodeInterpreterTool)!; + Assert.NotNull(codeInterpreterTool); + } + + [Fact] + public void FromYaml_FunctionTool() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything); + + // Assert + Assert.NotNull(agent); + var tools = agent.Tools; + var functionTools = tools.Where(t => t is InvokeClientTaskAction).ToArray(); + Assert.Single(functionTools); + InvokeClientTaskAction functionTool = (functionTools[0] as InvokeClientTaskAction)!; + Assert.NotNull(functionTool); + Assert.Equal("GetWeather", functionTool.Name); + Assert.Equal("Get the weather for a given location.", functionTool.Description); + // TODO check schema + } + + [Fact] + public void FromYaml_MCP() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything); + + // Assert + Assert.NotNull(agent); + var tools = agent.Tools; + var mcpTools = tools.Where(t => t is McpServerTool).ToArray(); + Assert.Single(mcpTools); + McpServerTool mcpTool = (mcpTools[0] as McpServerTool)!; + Assert.NotNull(mcpTool); + Assert.Equal("PersonInfoTool", mcpTool.ServerName?.LiteralValue); + AnonymousConnection connection = (mcpTool.Connection as AnonymousConnection)!; + Assert.NotNull(connection); + Assert.Equal("https://my-mcp-endpoint.com/api", connection.Endpoint?.LiteralValue); + } + + [Fact] + public void FromYaml_WebSearchTool() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything); + + // Assert + Assert.NotNull(agent); + var tools = agent.Tools; + var webSearchTools = tools.Where(t => t is WebSearchTool).ToArray(); + Assert.Single(webSearchTools); + Assert.NotNull(webSearchTools[0] as WebSearchTool); + } + + [Fact] + public void FromYaml_FileSearchTool() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything); + + // Assert + Assert.NotNull(agent); + var tools = agent.Tools; + var fileSearchTools = tools.Where(t => t is FileSearchTool).ToArray(); + Assert.Single(fileSearchTools); + FileSearchTool fileSearchTool = (fileSearchTools[0] as FileSearchTool)!; + Assert.NotNull(fileSearchTool); + + // Verify vector store content property exists and has correct values + Assert.NotNull(fileSearchTool.VectorStoreIds); + Assert.Equal(3, fileSearchTool.VectorStoreIds.LiteralValue.Length); + Assert.Equal("1", fileSearchTool.VectorStoreIds.LiteralValue[0]); + Assert.Equal("2", fileSearchTool.VectorStoreIds.LiteralValue[1]); + Assert.Equal("3", fileSearchTool.VectorStoreIds.LiteralValue[2]); + } + + [Fact] + public void FromYaml_ApiKeyConnection() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithApiKeyConnection); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.Model); + CurrentModels model = (agent.Model as CurrentModels)!; + Assert.NotNull(model); + Assert.NotNull(model.Connection); + Assert.IsType(model.Connection); + ApiKeyConnection connection = (model.Connection as ApiKeyConnection)!; + Assert.NotNull(connection); + Assert.Equal("https://my-azure-openai-endpoint.openai.azure.com/", connection.Endpoint?.LiteralValue); + Assert.Equal("my-api-key", connection.Key?.LiteralValue); + } + + [Fact] + public void FromYaml_RemoteConnection() + { + // Arrange & Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithRemoteConnection); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.Model); + CurrentModels model = (agent.Model as CurrentModels)!; + Assert.NotNull(model); + Assert.NotNull(model.Connection); + Assert.IsType(model.Connection); + RemoteConnection connection = (model.Connection as RemoteConnection)!; + Assert.NotNull(connection); + Assert.Equal("https://my-azure-openai-endpoint.openai.azure.com/", connection.Endpoint?.LiteralValue); + } + + [Fact] + public void FromYaml_WithVariableReferences() + { + // Arrange + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OpenAIEndpoint"] = "endpoint", + ["OpenAIApiKey"] = "apiKey", + ["Temperature"] = "0.9", + ["TopP"] = "0.8" + }) + .Build(); + + // Act + var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithVariableReferences, configuration); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.Model); + CurrentModels model = (agent.Model as CurrentModels)!; + Assert.NotNull(model); + Assert.NotNull(model.Options); + Assert.Equal(0.9, Eval(model.Options?.Temperature, configuration)); + Assert.Equal(0.8, Eval(model.Options?.TopP, configuration)); + Assert.NotNull(model.Connection); + Assert.IsType(model.Connection); + ApiKeyConnection connection = (model.Connection as ApiKeyConnection)!; + Assert.NotNull(connection); + Assert.NotNull(connection.Endpoint); + Assert.NotNull(connection.Key); + Assert.Equal("endpoint", Eval(connection.Endpoint, configuration)); + Assert.Equal("apiKey", Eval(connection.Key, configuration)); + } + + /// + /// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent. + /// + [Description("Information about a person including their name, age, and occupation")] + public sealed class PersonInfo + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("age")] + public int? Age { get; set; } + + [JsonPropertyName("occupation")] + public string? Occupation { get; set; } + } + + private static string? Eval(StringExpression? expression, IConfiguration? configuration = null) + { + if (expression is null) + { + return null; + } + + RecalcEngine engine = new(); + if (configuration is not null) + { + foreach (var kvp in configuration.AsEnumerable()) + { + engine.UpdateVariable(kvp.Key, kvp.Value ?? string.Empty); + } + } + + return expression.Eval(engine); + } + + private static double? Eval(NumberExpression? expression, IConfiguration? configuration = null) + { + if (expression is null) + { + return null; + } + + RecalcEngine engine = new(); + if (configuration != null) + { + foreach (var kvp in configuration.AsEnumerable()) + { + engine.UpdateVariable(kvp.Key, kvp.Value ?? string.Empty); + } + } + + return expression.Eval(engine); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs new file mode 100644 index 00000000000..d20bd9be00c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Declarative.UnitTests; + +/// +/// Unit tests for +/// +public sealed class AggregatorPromptAgentFactoryTests +{ + [Fact] + public void AggregatorAgentFactory_ThrowsForEmptyArray() + { + // Arrange & Act & Assert + Assert.Throws(() => new AggregatorPromptAgentFactory([])); + } + + [Fact] + public async Task AggregatorAgentFactory_ReturnsNull() + { + // Arrange + var factory = new AggregatorPromptAgentFactory([new TestAgentFactory(null)]); + + // Act + var agent = await factory.TryCreateAsync(new GptComponentMetadata("test")); + + // Assert + Assert.Null(agent); + } + + [Fact] + public async Task AggregatorAgentFactory_ReturnsAgent() + { + // Arrange + var agentToReturn = new TestAgent(); + var factory = new AggregatorPromptAgentFactory([new TestAgentFactory(null), new TestAgentFactory(agentToReturn)]); + + // Act + var agent = await factory.TryCreateAsync(new GptComponentMetadata("test")); + + // Assert + Assert.Equal(agentToReturn, agent); + } + + private sealed class TestAgentFactory : PromptAgentFactory + { + private readonly AIAgent? _agentToReturn; + + public TestAgentFactory(AIAgent? agentToReturn = null) + { + this._agentToReturn = agentToReturn; + } + + public override Task TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default) + { + return Task.FromResult(this._agentToReturn); + } + } + + private sealed class TestAgent : AIAgent + { + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + throw new NotImplementedException(); + } + + public override AgentThread GetNewThread() + { + throw new NotImplementedException(); + } + + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs new file mode 100644 index 00000000000..85906620005 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/ChatClient/ChatClientAgentFactoryTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Declarative.UnitTests.ChatClient; + +/// +/// Unit tests for . +/// +public sealed class ChatClientAgentFactoryTests +{ + private readonly Mock _mockChatClient; + + public ChatClientAgentFactoryTests() + { + this._mockChatClient = new(); + } + + [Fact] + public async Task TryCreateAsync_WithChatClientInConstructor_CreatesAgentAsync() + { + // Arrange + var promptAgent = PromptAgents.CreateTestPromptAgent(); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test Description", agent.Description); + } + + [Fact] + public async Task TryCreateAsync_Creates_ChatClientAgentAsync() + { + // Arrange + var promptAgent = PromptAgents.CreateTestPromptAgent(); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClientAgent = agent as ChatClientAgent; + Assert.NotNull(chatClientAgent); + Assert.Equal("You are a helpful assistant.", chatClientAgent.Instructions); + Assert.NotNull(chatClientAgent.ChatClient); + Assert.NotNull(chatClientAgent.ChatOptions); + } + + [Fact] + public async Task TryCreateAsync_Creates_ChatOptionsAsync() + { + // Arrange + var promptAgent = PromptAgents.CreateTestPromptAgent(); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClientAgent = agent as ChatClientAgent; + Assert.NotNull(chatClientAgent?.ChatOptions); + Assert.Equal("You are a helpful assistant.", chatClientAgent?.ChatOptions?.Instructions); + Assert.Equal(0.7F, chatClientAgent?.ChatOptions?.Temperature); + Assert.Equal(0.7F, chatClientAgent?.ChatOptions?.FrequencyPenalty); + Assert.Equal(1024, chatClientAgent?.ChatOptions?.MaxOutputTokens); + Assert.Equal(0.9F, chatClientAgent?.ChatOptions?.TopP); + Assert.Equal(50, chatClientAgent?.ChatOptions?.TopK); + Assert.Equal(0.7F, chatClientAgent?.ChatOptions?.PresencePenalty); + Assert.Equal(42L, chatClientAgent?.ChatOptions?.Seed); + Assert.NotNull(chatClientAgent?.ChatOptions?.ResponseFormat); + Assert.Equal("gpt-4o", chatClientAgent?.ChatOptions?.ModelId); + Assert.Equal(["###", "END", "STOP"], chatClientAgent?.ChatOptions?.StopSequences); + Assert.True(chatClientAgent?.ChatOptions?.AllowMultipleToolCalls); + Assert.Equal(ChatToolMode.Auto, chatClientAgent?.ChatOptions?.ToolMode); + Assert.Equal("customValue", chatClientAgent?.ChatOptions?.AdditionalProperties?["customProperty"]); + } + + [Fact] + public async Task TryCreateAsync_Creates_ToolsAsync() + { + // Arrange + var promptAgent = PromptAgents.CreateTestPromptAgent(); + ChatClientPromptAgentFactory factory = new(this._mockChatClient.Object); + + // Act + AIAgent? agent = await factory.TryCreateAsync(promptAgent); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var chatClientAgent = agent as ChatClientAgent; + Assert.NotNull(chatClientAgent?.ChatOptions?.Tools); + var tools = chatClientAgent?.ChatOptions?.Tools; + Assert.Equal(5, tools?.Count); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj new file mode 100644 index 00000000000..d348a0b433b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(NoWarn);IDE1006;VSTHRD200 + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/PromptAgents.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/PromptAgents.cs new file mode 100644 index 00000000000..163e4ded18a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/PromptAgents.cs @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.AI.Declarative.UnitTests; + +internal static class PromptAgents +{ + internal const string AgentWithEverything = + """ + kind: Prompt + name: AgentName + description: Agent description + instructions: You are a helpful assistant. + model: + id: gpt-4o + options: + temperature: 0.7 + maxOutputTokens: 1024 + topP: 0.9 + topK: 50 + frequencyPenalty: 0.0 + presencePenalty: 0.0 + seed: 42 + responseFormat: text + stopSequences: + - "###" + - "END" + - "STOP" + allowMultipleToolCalls: true + tools: + - kind: codeInterpreter + inputs: + - kind: HostedFileContent + FileId: fileId123 + - kind: function + name: GetWeather + description: Get the weather for a given location. + parameters: + - name: location + type: string + description: The city and state, e.g. San Francisco, CA + required: true + - name: unit + type: string + description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'. + required: false + enum: + - celsius + - fahrenheit + - kind: mcp + serverName: PersonInfoTool + serverDescription: Get information about a person. + connection: + kind: AnonymousConnection + endpoint: https://my-mcp-endpoint.com/api + allowedTools: + - "GetPersonInfo" + - "UpdatePersonInfo" + - "DeletePersonInfo" + approvalMode: + kind: HostedMcpServerToolRequireSpecificApprovalMode + AlwaysRequireApprovalToolNames: + - "UpdatePersonInfo" + - "DeletePersonInfo" + NeverRequireApprovalToolNames: + - "GetPersonInfo" + - kind: webSearch + name: WebSearchTool + description: Search the web for information. + - kind: fileSearch + name: FileSearchTool + description: Search files for information. + ranker: default + scoreThreshold: 0.5 + maxResults: 5 + maxContentLength: 2000 + vectorStoreIds: + - 1 + - 2 + - 3 + """; + + internal const string AgentWithOutputSchema = + """ + kind: Prompt + name: Translation Assistant + description: A helpful assistant that translates text to a specified language. + model: + id: gpt-4o + options: + temperature: 0.9 + topP: 0.95 + instructions: You are a helpful assistant. You answer questions in {language}. You return your answers in a JSON format. + additionalInstructions: You must always respond in the specified language. + tools: + - kind: codeInterpreter + template: + format: PowerFx # Mustache is the other option + parser: None # Prompty and XML are the other options + inputSchema: + properties: + language: string + outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + """; + + internal const string AgentWithApiKeyConnection = + """ + kind: Prompt + name: AgentName + description: Agent description + instructions: You are a helpful assistant. + model: + id: gpt-4o + connection: + kind: ApiKey + endpoint: https://my-azure-openai-endpoint.openai.azure.com/ + key: my-api-key + """; + + internal const string AgentWithRemoteConnection = + """ + kind: Prompt + name: AgentName + description: Agent description + instructions: You are a helpful assistant. + model: + id: gpt-4o + connection: + kind: Remote + endpoint: https://my-azure-openai-endpoint.openai.azure.com/ + """; + + internal const string AgentWithVariableReferences = + """ + kind: Prompt + name: AgentName + description: Agent description + instructions: You are a helpful assistant. + model: + id: gpt-4o + options: + temperature: =Env.Temperature + topP: =Env.TopP + connection: + kind: apiKey + endpoint: =Env.OpenAIEndpoint + key: =Env.OpenAIApiKey + """; + + internal const string OpenAIChatAgent = + """ + kind: Prompt + name: Assistant + description: Helpful assistant + instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format. + model: + id: =Env.OPENAI_MODEL + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: apiKey + key: =Env.OPENAI_APIKEY + outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + """; + + internal const string AgentWithCurrentModels = + """ + kind: Prompt + name: AgentName + description: Agent description + instructions: You are a helpful assistant. + model: + id: gpt-4o + options: + temperature: 0.7 + maxOutputTokens: 1024 + topP: 0.9 + topK: 50 + frequencyPenalty: 0.7 + presencePenalty: 0.7 + seed: 42 + responseFormat: text + stopSequences: + - "###" + - "END" + - "STOP" + allowMultipleToolCalls: true + chatToolMode: auto + """; + + internal const string AgentWithCurrentModelsSnakeCase = + """ + kind: Prompt + name: AgentName + description: Agent description + instructions: You are a helpful assistant. + model: + id: gpt-4o + options: + temperature: 0.7 + max_output_tokens: 1024 + top_p: 0.9 + top_k: 50 + frequency_penalty: 0.7 + presence_penalty: 0.7 + seed: 42 + response_format: text + stop_sequences: + - "###" + - "END" + - "STOP" + allow_multiple_tool_calls: true + chat_tool_mode: auto + """; + + internal const string Workflow = + """ + kind: Workflow + trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + - kind: InvokeAzureAgent + id: question_student + conversationId: =System.ConversationId + agent: + name: StudentAgent + + - kind: InvokeAzureAgent + id: question_teacher + conversationId: =System.ConversationId + agent: + name: TeacherAgent + output: + messages: Local.TeacherResponse + + - kind: SetVariable + id: set_count_increment + variable: Local.TurnCount + value: =Local.TurnCount + 1 + + - kind: ConditionGroup + id: check_completion + conditions: + + - condition: =!IsBlank(Find("CONGRATULATIONS", Upper(MessageText(Local.TeacherResponse)))) + id: check_turn_done + actions: + + - kind: SendActivity + id: sendActivity_done + activity: GOLD STAR! + + - condition: =Local.TurnCount < 4 + id: check_turn_count + actions: + + - kind: GotoAction + id: goto_student_agent + actionId: question_student + + elseActions: + + - kind: SendActivity + id: sendActivity_tired + activity: Let's try again later... + + """; + + internal static readonly string[] s_stopSequences = ["###", "END", "STOP"]; + + internal static GptComponentMetadata CreateTestPromptAgent(string? publisher = "OpenAI", string? apiType = "Chat") + { + string agentYaml = + $""" + kind: Prompt + name: Test Agent + description: Test Description + instructions: You are a helpful assistant. + additionalInstructions: Provide detailed and accurate responses. + model: + id: gpt-4o + publisher: {publisher} + apiType: {apiType} + options: + modelId: gpt-4o + temperature: 0.7 + maxOutputTokens: 1024 + topP: 0.9 + topK: 50 + frequencyPenalty: 0.7 + presencePenalty: 0.7 + seed: 42 + responseFormat: text + stopSequences: + - "###" + - "END" + - "STOP" + allowMultipleToolCalls: true + chatToolMode: auto + customProperty: customValue + connection: + kind: apiKey + endpoint: https://my-azure-openai-endpoint.openai.azure.com/ + key: my-api-key + tools: + - kind: codeInterpreter + - kind: function + name: GetWeather + description: Get the weather for a given location. + parameters: + - name: location + type: string + description: The city and state, e.g. San Francisco, CA + required: true + - name: unit + type: string + description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'. + required: false + enum: + - celsius + - fahrenheit + - kind: mcp + serverName: PersonInfoTool + serverDescription: Get information about a person. + allowedTools: + - "GetPersonInfo" + - "UpdatePersonInfo" + - "DeletePersonInfo" + approvalMode: + kind: HostedMcpServerToolRequireSpecificApprovalMode + AlwaysRequireApprovalToolNames: + - "UpdatePersonInfo" + - "DeletePersonInfo" + NeverRequireApprovalToolNames: + - "GetPersonInfo" + connection: + kind: AnonymousConnection + endpoint: https://my-mcp-endpoint.com/api + - kind: webSearch + name: WebSearchTool + description: Search the web for information. + - kind: fileSearch + name: FileSearchTool + description: Search files for information. + vectorStoreIds: + - 1 + - 2 + - 3 + outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + """; + + return AgentBotElementYaml.FromYaml(agentYaml); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj index 9135a90e2ed..1fc964e702d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj @@ -1,15 +1,13 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) false $(NoWarn);CA1812 - - + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs index 73c230410cd..1ae3c668348 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/AgentEntityTests.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.Reflection; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Client.Entities; using Microsoft.DurableTask.Entities; @@ -67,8 +69,72 @@ await simpleAgentProxy.RunAsync( cancellationToken: this.TestTimeoutToken); // Assert: verify the agent state was stored with the correct entity name prefix - entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken); + entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken); Assert.NotNull(entity); + Assert.True(entity.IncludesState); + + DurableAgentState state = entity.State.ReadAs(); + + DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType()); + + Assert.Null(request.OrchestrationId); + } + + [Fact] + public async Task OrchestrationIdSetDuringOrchestrationAsync() + { + // Arrange + AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + name: "TestAgent", + instructions: "You are a helpful assistant that always responds with a friendly greeting." + ); + + using TestHelper testHelper = TestHelper.Start( + [simpleAgent], + this._outputHelper, + registry => registry.AddOrchestrator()); + + DurableTaskClient client = testHelper.GetClient(); + + // Act + string orchestrationId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(TestOrchestrator), "What is the capital of Maine?"); + + OrchestrationMetadata? status = await client.WaitForInstanceCompletionAsync( + orchestrationId, + true, + this.TestTimeoutToken); + + // Assert + EntityInstanceId expectedEntityId = AgentSessionId.Parse(status.ReadOutputAs()!); + + EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken); + + Assert.NotNull(entity); + Assert.True(entity.IncludesState); + + DurableAgentState state = entity.State.ReadAs(); + + DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType()); + + Assert.Equal(orchestrationId, request.OrchestrationId); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Constructed via reflection.")] + private sealed class TestOrchestrator : TaskOrchestrator + { + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + DurableAIAgent writer = context.GetAgent("TestAgent"); + AgentThread writerThread = writer.GetNewThread(); + + await writer.RunAsync( + message: context.GetInput()!, + thread: writerThread); + + AgentSessionId sessionId = writerThread.GetService(); + + return sessionId.ToString(); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj index 7150e74bd85..db6aa6d62be 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable b7762d10-e29b-4bb1-8b74-b6d69a667dd4 diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index b413733f2b8..b0cf00cae18 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable b7762d10-e29b-4bb1-8b74-b6d69a667dd4 diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateRequestTests.cs new file mode 100644 index 00000000000..acdc6021652 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateRequestTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.State; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateRequestTests +{ + [Fact] + public void RequestSerializationDeserialization() + { + // Arrange + RunRequest originalRequest = new("Hello, world!") + { + OrchestrationId = "orch-456" + }; + DurableAgentStateRequest originalDurableRequest = DurableAgentStateRequest.FromRunRequest(originalRequest); + + // Act + string jsonContent = JsonSerializer.Serialize( + originalDurableRequest, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateRequest))!); + + DurableAgentStateRequest? convertedJsonContent = (DurableAgentStateRequest?)JsonSerializer.Deserialize( + jsonContent, + DurableAgentStateJsonContext.Default.GetTypeInfo(typeof(DurableAgentStateRequest))!); + + // Assert + Assert.NotNull(convertedJsonContent); + Assert.Equal(originalRequest.CorrelationId, convertedJsonContent.CorrelationId); + Assert.Equal(originalRequest.OrchestrationId, convertedJsonContent.OrchestrationId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj index 07dde4f8027..42d86828700 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj @@ -1,16 +1,16 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) - - + + - - + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs index 923eaa77521..5bc4e8afad2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs @@ -276,18 +276,15 @@ public async ValueTask DisposeAsync() [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] internal sealed class FakeChatClientAgent : AIAgent { - private readonly string _agentId; - private readonly string _description; - public FakeChatClientAgent() { - this._agentId = "fake-agent"; - this._description = "A fake agent for testing"; + this.Id = "fake-agent"; + this.Description = "A fake agent for testing"; } - public override string Id => this._agentId; + public override string Id { get; } - public override string? Description => this._description; + public override string? Description { get; } public override AgentThread GetNewThread() { @@ -353,18 +350,15 @@ public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptio [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] internal sealed class FakeMultiMessageAgent : AIAgent { - private readonly string _agentId; - private readonly string _description; - public FakeMultiMessageAgent() { - this._agentId = "fake-multi-message-agent"; - this._description = "A fake agent that sends multiple messages for testing"; + this.Id = "fake-multi-message-agent"; + this.Description = "A fake agent that sends multiple messages for testing"; } - public override string Id => this._agentId; + public override string Id { get; } - public override string? Description => this._description; + public override string? Description { get; } public override AgentThread GetNewThread() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs new file mode 100644 index 00000000000..df8caea2149 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Http; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests; + +public sealed class ForwardedPropertiesTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task ForwardedProps_AreParsedAndPassedToAgent_WhenProvidedInRequestAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + // Create request JSON with forwardedProps (per AG-UI protocol spec) + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test forwarded props" }], + "forwardedProps": { "customProp": "customValue", "sessionId": "test-session-123" } + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object); + fakeAgent.ReceivedForwardedProperties.GetProperty("customProp").GetString().Should().Be("customValue"); + fakeAgent.ReceivedForwardedProperties.GetProperty("sessionId").GetString().Should().Be("test-session-123"); + } + + [Fact] + public async Task ForwardedProps_WithNestedObjects_AreCorrectlyParsedAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test nested props" }], + "forwardedProps": { + "user": { "id": "user-1", "name": "Test User" }, + "metadata": { "version": "1.0", "feature": "test" } + } + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object); + + JsonElement user = fakeAgent.ReceivedForwardedProperties.GetProperty("user"); + user.GetProperty("id").GetString().Should().Be("user-1"); + user.GetProperty("name").GetString().Should().Be("Test User"); + + JsonElement metadata = fakeAgent.ReceivedForwardedProperties.GetProperty("metadata"); + metadata.GetProperty("version").GetString().Should().Be("1.0"); + metadata.GetProperty("feature").GetString().Should().Be("test"); + } + + [Fact] + public async Task ForwardedProps_WithArrays_AreCorrectlyParsedAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test array props" }], + "forwardedProps": { + "tags": ["tag1", "tag2", "tag3"], + "scores": [1, 2, 3, 4, 5] + } + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object); + + JsonElement tags = fakeAgent.ReceivedForwardedProperties.GetProperty("tags"); + tags.GetArrayLength().Should().Be(3); + tags[0].GetString().Should().Be("tag1"); + + JsonElement scores = fakeAgent.ReceivedForwardedProperties.GetProperty("scores"); + scores.GetArrayLength().Should().Be(5); + scores[2].GetInt32().Should().Be(3); + } + + [Fact] + public async Task ForwardedProps_WhenEmpty_DoesNotCauseErrorsAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test empty props" }], + "forwardedProps": {} + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task ForwardedProps_WhenNotProvided_AgentStillWorksAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test no props" }] + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Undefined); + } + + [Fact] + public async Task ForwardedProps_ReturnsValidSSEResponse_WithTextDeltaEventsAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test response" }], + "forwardedProps": { "customProp": "value" } + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + response.EnsureSuccessStatusCode(); + + Stream stream = await response.Content.ReadAsStreamAsync(); + List> events = []; + await foreach (SseItem item in SseParser.Create(stream).EnumerateAsync()) + { + events.Add(item); + } + + // Assert + events.Should().NotBeEmpty(); + + // SSE events have EventType = "message" and the actual type is in the JSON data + // Should have run_started event + events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"RUN_STARTED\"")); + + // Should have text_message_start event + events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"TEXT_MESSAGE_START\"")); + + // Should have text_message_content event with the response text + events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"TEXT_MESSAGE_CONTENT\"")); + + // Should have run_finished event + events.Should().Contain(e => e.Data != null && e.Data.Contains("\"type\":\"RUN_FINISHED\"")); + } + + [Fact] + public async Task ForwardedProps_WithMixedTypes_AreCorrectlyParsedAsync() + { + // Arrange + FakeForwardedPropsAgent fakeAgent = new(); + await this.SetupTestServerAsync(fakeAgent); + + const string RequestJson = """ + { + "threadId": "thread-123", + "runId": "run-456", + "messages": [{ "id": "msg-1", "role": "user", "content": "test mixed types" }], + "forwardedProps": { + "stringProp": "text", + "numberProp": 42, + "boolProp": true, + "nullProp": null, + "arrayProp": [1, "two", false], + "objectProp": { "nested": "value" } + } + } + """; + + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + + // Act + HttpResponseMessage response = await this._client!.PostAsync(new Uri("/agent", UriKind.Relative), content); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + fakeAgent.ReceivedForwardedProperties.ValueKind.Should().Be(JsonValueKind.Object); + + fakeAgent.ReceivedForwardedProperties.GetProperty("stringProp").GetString().Should().Be("text"); + fakeAgent.ReceivedForwardedProperties.GetProperty("numberProp").GetInt32().Should().Be(42); + fakeAgent.ReceivedForwardedProperties.GetProperty("boolProp").GetBoolean().Should().BeTrue(); + fakeAgent.ReceivedForwardedProperties.GetProperty("nullProp").ValueKind.Should().Be(JsonValueKind.Null); + fakeAgent.ReceivedForwardedProperties.GetProperty("arrayProp").GetArrayLength().Should().Be(3); + fakeAgent.ReceivedForwardedProperties.GetProperty("objectProp").GetProperty("nested").GetString().Should().Be("value"); + } + + private async Task SetupTestServerAsync(FakeForwardedPropsAgent fakeAgent) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddAGUI(); + builder.WebHost.UseTestServer(); + + this._app = builder.Build(); + + this._app.MapAGUI("/agent", fakeAgent); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._client = testServer.CreateClient(); + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } +} + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated in tests")] +internal sealed class FakeForwardedPropsAgent : AIAgent +{ + public FakeForwardedPropsAgent() + { + } + + public override string? Description => "Agent for forwarded properties testing"; + + public JsonElement ReceivedForwardedProperties { get; private set; } + + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Extract forwarded properties from ChatOptions.AdditionalProperties (set by AG-UI hosting layer) + if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } && + properties.TryGetValue("ag_ui_forwarded_properties", out object? propsObj) && + propsObj is JsonElement forwardedProps) + { + this.ReceivedForwardedProperties = forwardedProps; + } + + // Always return a text response + string messageId = Guid.NewGuid().ToString("N"); + yield return new AgentRunResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new TextContent("Forwarded props processed")] + }; + + await Task.CompletedTask; + } + + public override AgentThread GetNewThread() => new FakeInMemoryAgentThread(); + + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions); + } + + private sealed class FakeInMemoryAgentThread : InMemoryAgentThread + { + public FakeInMemoryAgentThread() + : base() + { + } + + public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + : base(serializedThread, jsonSerializerOptions) + { + } + } + + public override object? GetService(Type serviceType, object? serviceKey = null) => null; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj index f87cd59c27b..6b909fd4f2b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) @@ -11,18 +10,14 @@ - - - - - - - - - + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs index 47d9e63520c..c96f2d92d00 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs @@ -62,7 +62,7 @@ public async Task StateSnapshot_IsReturnedAsDataContent_WithCorrectMediaTypeAsyn // Verify the state content string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); - JsonElement receivedState = JsonSerializer.Deserialize(receivedJson); + JsonElement receivedState = JsonElement.Parse(receivedJson); receivedState.GetProperty("counter").GetInt32().Should().Be(43, "state should be incremented"); receivedState.GetProperty("status").GetString().Should().Be("active"); } @@ -141,7 +141,7 @@ public async Task ComplexState_WithNestedObjectsAndArrays_RoundTripsCorrectlyAsy DataContent? dataContent = stateUpdate!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); - JsonElement receivedState = JsonSerializer.Deserialize(receivedJson); + JsonElement receivedState = JsonElement.Parse(receivedJson); receivedState.GetProperty("sessionId").GetString().Should().Be("test-123"); receivedState.GetProperty("nested").GetProperty("count").GetInt32().Should().Be(10); @@ -196,7 +196,7 @@ public async Task StateSnapshot_CanBeUsedInSubsequentRequest_ForStateRoundTripAs DataContent? secondStateContent = secondStateUpdate!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); string secondStateJson = System.Text.Encoding.UTF8.GetString(secondStateContent!.Data.ToArray()); - JsonElement secondState = JsonSerializer.Deserialize(secondStateJson); + JsonElement secondState = JsonElement.Parse(secondStateJson); secondState.GetProperty("counter").GetInt32().Should().Be(3, "counter should be incremented twice: 1 -> 2 -> 3"); } @@ -304,7 +304,7 @@ public async Task NonStreamingRunAsync_WithState_ReturnsStateInResponseAsync() DataContent? dataContent = stateResponseMessage!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); - JsonElement receivedState = JsonSerializer.Deserialize(receivedJson); + JsonElement receivedState = JsonElement.Parse(receivedJson); receivedState.GetProperty("counter").GetInt32().Should().Be(6); } @@ -385,7 +385,7 @@ stateObj is JsonElement state && { modifiedState[prop.Name] = prop.Value.GetString(); } - else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array) + else if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { modifiedState[prop.Name] = prop.Value; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs index c5ee3d711ba..178ed20d730 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs @@ -396,7 +396,7 @@ public async Task AGUIChatClientCombinesCustomJsonSerializerOptionsAsync() var json = JsonSerializer.Serialize(testResponse, ClientJsonContext.Default.ClientForecastResponse); // Assert - var jsonElement = JsonDocument.Parse(json).RootElement; + var jsonElement = JsonElement.Parse(json); jsonElement.GetProperty("MaxTemp").GetInt32().Should().Be(75); jsonElement.GetProperty("MinTemp").GetInt32().Should().Be(60); jsonElement.GetProperty("Outlook").GetString().Should().Be("Rainy"); @@ -652,15 +652,15 @@ public async IAsyncEnumerable GetStreamingResponseAsync( return functionName switch { "GetWeather" => new Dictionary { ["location"] = "Seattle" }, - "GetTime" => new Dictionary(), // No parameters + "GetTime" => [], // No parameters "Calculate" => new Dictionary { ["a"] = 5, ["b"] = 3 }, "FormatText" => new Dictionary { ["text"] = "hello" }, - "GetServerData" => new Dictionary(), // No parameters - "GetClientData" => new Dictionary(), // No parameters + "GetServerData" => [], // No parameters + "GetClientData" => [], // No parameters // For custom types, the parameter name is "request" and the value is an instance of the request type "GetServerForecast" => new Dictionary { ["request"] = new ServerForecastRequest("Seattle", 5) }, "GetClientForecast" => new Dictionary { ["request"] = new ClientForecastRequest("Portland", true) }, - _ => new Dictionary() // Default: no parameters + _ => [] // Default: no parameters }; } @@ -689,9 +689,9 @@ public record ClientForecastResponse(int MaxTemp, int MinTemp, string Outlook); [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(ServerForecastRequest))] [JsonSerializable(typeof(ServerForecastResponse))] -internal sealed partial class ServerJsonContext : JsonSerializerContext { } +internal sealed partial class ServerJsonContext : JsonSerializerContext; [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(ClientForecastRequest))] [JsonSerializable(typeof(ClientForecastResponse))] -internal sealed partial class ClientJsonContext : JsonSerializerContext { } +internal sealed partial class ClientJsonContext : JsonSerializerContext; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index e5fb2061479..78a30487473 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -38,7 +38,7 @@ public void MapAGUIAgent_MapsEndpoint_AtSpecifiedPattern() AIAgent agent = new TestAgent(); // Act - IEndpointConventionBuilder? result = AGUIEndpointRouteBuilderExtensions.MapAGUI(endpointsMock.Object, Pattern, agent); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(Pattern, agent); // Assert Assert.NotNull(result); @@ -305,7 +305,7 @@ public async Task MapAGUIAgent_EmitsTextMessageContent_WithCorrectDeltaAsync() public async Task MapAGUIAgent_WithCustomAgent_ProducesExpectedStreamStructureAsync() { // Arrange - AIAgent customAgentFactory(IEnumerable messages, IEnumerable tools, IEnumerable> context, JsonElement props) + static AIAgent CustomAgentFactory(IEnumerable messages, IEnumerable tools, IEnumerable> context, JsonElement props) { return new MultiResponseAgent(); } @@ -322,7 +322,7 @@ AIAgent customAgentFactory(IEnumerable messages, IEnumerable messages, IEnumerable events = ParseSseEvents(responseContent); - List contentEvents = new(); + List contentEvents = []; foreach (JsonElement evt in events) { if (evt.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj index e6d4459c6eb..57a653d9f06 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj @@ -1,17 +1,17 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) - - - + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj index ae816efb7fb..fb955c3162f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj @@ -1,15 +1,13 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable b7762d10-e29b-4bb1-8b74-b6d69a667dd4 - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index ef9807fddf6..0ba879f0244 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -111,7 +111,7 @@ await this.RunSampleTestAsync(samplePath, async (logs) => startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}"); string startResponseText = await startResponse.Content.ReadAsStringAsync(); - JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + JsonElement startResult = JsonElement.Parse(startResponseText); Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); Uri statusUri = new(statusUriElement.GetString()!); @@ -126,7 +126,7 @@ await this.RunSampleTestAsync(samplePath, async (logs) => $"Status check failed with status: {statusResponse.StatusCode}"); string statusText = await statusResponse.Content.ReadAsStringAsync(); - JsonElement statusResult = JsonSerializer.Deserialize(statusText); + JsonElement statusResult = JsonElement.Parse(statusText); Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString()); Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement)); @@ -154,7 +154,7 @@ await this.RunSampleTestAsync(samplePath, async (logs) => Assert.True(startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}"); string startResponseText = await startResponse.Content.ReadAsStringAsync(); - JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + JsonElement startResult = JsonElement.Parse(startResponseText); Assert.True(startResult.TryGetProperty("instanceId", out JsonElement instanceIdElement)); Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); @@ -169,7 +169,7 @@ await this.RunSampleTestAsync(samplePath, async (logs) => Assert.True(statusResponse.IsSuccessStatusCode, $"Status check failed with status: {statusResponse.StatusCode}"); string statusText = await statusResponse.Content.ReadAsStringAsync(); - JsonElement statusResult = JsonSerializer.Deserialize(statusText); + JsonElement statusResult = JsonElement.Parse(statusText); Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString()); Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement)); @@ -233,7 +233,7 @@ await this.RunSampleTestAsync(samplePath, async (logs) => startResponse.IsSuccessStatusCode, $"Start HITL orchestration failed with status: {startResponse.StatusCode}"); string startResponseText = await startResponse.Content.ReadAsStringAsync(); - JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + JsonElement startResult = JsonElement.Parse(startResponseText); Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); Uri statusUri = new(statusUriElement.GetString()!); @@ -250,7 +250,7 @@ await this.RunSampleTestAsync(samplePath, async (logs) => string statusText = await statusResponse.Content.ReadAsStringAsync(); this._outputHelper.WriteLine($"HITL orchestration status text: {statusText}"); - JsonElement statusResult = JsonSerializer.Deserialize(statusText); + JsonElement statusResult = JsonElement.Parse(statusText); // The orchestration should complete with a failed status due to timeout Assert.Equal("Failed", statusResult.GetProperty("runtimeStatus").GetString()); @@ -423,7 +423,7 @@ private async Task TestSpamDetectionAsync(string emailId, string emailContent, b Assert.True(startResponse.IsSuccessStatusCode, $"Start orchestration failed with status: {startResponse.StatusCode}"); string startResponseText = await startResponse.Content.ReadAsStringAsync(); - JsonElement startResult = JsonSerializer.Deserialize(startResponseText); + JsonElement startResult = JsonElement.Parse(startResponseText); Assert.True(startResult.TryGetProperty("statusQueryGetUri", out JsonElement statusUriElement)); Uri statusUri = new(statusUriElement.GetString()!); @@ -436,7 +436,7 @@ private async Task TestSpamDetectionAsync(string emailId, string emailContent, b Assert.True(statusResponse.IsSuccessStatusCode, $"Status check failed with status: {statusResponse.StatusCode}"); string statusText = await statusResponse.Content.ReadAsStringAsync(); - JsonElement statusResult = JsonSerializer.Deserialize(statusText); + JsonElement statusResult = JsonElement.Parse(statusText); Assert.Equal("Completed", statusResult.GetProperty("runtimeStatus").GetString()); Assert.True(statusResult.TryGetProperty("output", out JsonElement outputElement)); @@ -722,15 +722,12 @@ private async Task WaitForOrchestrationCompletionAsync(Uri statusUri) if (response.IsSuccessStatusCode) { string responseText = await response.Content.ReadAsStringAsync(timeoutCts.Token); - JsonElement result = JsonSerializer.Deserialize(responseText); + JsonElement result = JsonElement.Parse(responseText); - if (result.TryGetProperty("runtimeStatus", out JsonElement statusElement)) + if (result.TryGetProperty("runtimeStatus", out JsonElement statusElement) && + statusElement.GetString() is "Completed" or "Failed" or "Terminated") { - string status = statusElement.GetString()!; - if (status == "Completed" || status == "Failed" || status == "Terminated") - { - return; - } + return; } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs index c9a13d7298c..7d3a2ec13e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs @@ -134,7 +134,7 @@ static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool Assert.Contains($"agents/{agentName}/run", httpMeta.RawBindings[0]); // We expect 2 mcp tool triggers only for agentB and agentC - if (agentName == "agentB" || agentName == "agentC") + if (agentName is "agentB" or "agentC") { DefaultFunctionMetadata? mcpToolMeta = Assert.Single(metadataList, m => m.Name == $"mcptool-{agentName}") as DefaultFunctionMetadata; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj index d3842800b95..7b053abe832 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) enable diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs index 5a8f4ea442e..1be9d06ca7e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs @@ -47,7 +47,7 @@ public async Task TextReasoningContent_GeneratesReasoningItem_SuccessAsync() var firstItemAddedEvent = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added"); var firstItem = firstItemAddedEvent.GetProperty("item"); Assert.Equal("reasoning", firstItem.GetProperty("type").GetString()); - Assert.True(firstItemAddedEvent.GetProperty("output_index").GetInt32() == 0); + Assert.Equal(0, firstItemAddedEvent.GetProperty("output_index").GetInt32()); // Verify reasoning item done var firstItemDoneEvent = events.First(e => @@ -153,7 +153,7 @@ public async Task ErrorContent_GeneratesRefusalItem_SuccessAsync() // Verify item added event var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var item = itemAddedEvent.GetProperty("item"); Assert.Equal("message", item.GetProperty("type").GetString()); @@ -166,7 +166,7 @@ public async Task ErrorContent_GeneratesRefusalItem_SuccessAsync() Assert.NotEmpty(contentArray); var refusalContent = contentArray.First(c => c.GetProperty("type").GetString() == "refusal"); - Assert.True(refusalContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, refusalContent.ValueKind); Assert.Equal(ErrorMessage, refusalContent.GetProperty("refusal").GetString()); } @@ -246,12 +246,12 @@ public async Task ImageContent_UriContent_GeneratesImageItem_SuccessAsync() // Assert var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var imageContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_image"); - Assert.True(imageContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, imageContent.ValueKind); Assert.Equal(ImageUrl, imageContent.GetProperty("image_url").GetString()); } @@ -270,12 +270,12 @@ public async Task ImageContent_DataContent_GeneratesImageItem_SuccessAsync() // Assert var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var imageContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_image"); - Assert.True(imageContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, imageContent.ValueKind); Assert.Equal(DataUri, imageContent.GetProperty("image_url").GetString()); } @@ -295,12 +295,12 @@ public async Task ImageContent_WithDetailProperty_IncludesDetail_SuccessAsync() // Assert var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var imageContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_image"); - Assert.True(imageContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, imageContent.ValueKind); Assert.True(imageContent.TryGetProperty("detail", out var detailProp)); Assert.Equal(Detail, detailProp.GetString()); } @@ -345,12 +345,12 @@ public async Task AudioContent_Mp3Format_GeneratesAudioItem_SuccessAsync() // Assert var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var audioContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_audio"); - Assert.True(audioContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, audioContent.ValueKind); Assert.Equal(AudioDataUri, audioContent.GetProperty("data").GetString()); Assert.Equal("mp3", audioContent.GetProperty("format").GetString()); } @@ -421,12 +421,12 @@ public async Task HostedFileContent_GeneratesFileItem_SuccessAsync() // Assert var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var fileContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_file"); - Assert.True(fileContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, fileContent.ValueKind); Assert.Equal(FileId, fileContent.GetProperty("file_id").GetString()); } @@ -471,12 +471,12 @@ public async Task FileContent_WithDataUri_GeneratesFileItem_SuccessAsync() // Assert var itemAddedEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.output_item.added"); - Assert.True(itemAddedEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, itemAddedEvent.ValueKind); var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var fileContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_file"); - Assert.True(fileContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, fileContent.ValueKind); Assert.Equal(FileDataUri, fileContent.GetProperty("file_data").GetString()); Assert.Equal(Filename, fileContent.GetProperty("filename").GetString()); } @@ -499,7 +499,7 @@ public async Task FileContent_WithoutFilename_GeneratesFileItemWithoutFilename_S var content = itemAddedEvent.GetProperty("item").GetProperty("content"); var fileContent = content.EnumerateArray().First(c => c.GetProperty("type").GetString() == "input_file"); - Assert.True(fileContent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, fileContent.ValueKind); Assert.Equal(FileDataUri, fileContent.GetProperty("file_data").GetString()); // filename property might be null or absent } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs index c9a76e49904..296217f9313 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs @@ -95,7 +95,7 @@ public async Task FunctionApprovalRequest_WithComplexArguments_GeneratesCorrectE // Assert JsonElement approvalEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.function_approval.requested"); - Assert.True(approvalEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, approvalEvent.ValueKind); JsonElement functionCallElement = approvalEvent.GetProperty("function_call"); JsonElement argumentsElement = functionCallElement.GetProperty("arguments"); @@ -235,7 +235,7 @@ public async Task FunctionApprovalResponse_Rejected_GeneratesCorrectEvent_Succes // Assert JsonElement approvalEvent = events.FirstOrDefault(e => e.GetProperty("type").GetString() == "response.function_approval.responded"); - Assert.True(approvalEvent.ValueKind != JsonValueKind.Undefined); + Assert.NotEqual(JsonValueKind.Undefined, approvalEvent.ValueKind); Assert.Equal(RequestId, approvalEvent.GetProperty("request_id").GetString()); Assert.False(approvalEvent.GetProperty("approved").GetBoolean()); @@ -340,7 +340,7 @@ public async Task MixedContent_MultipleApprovalRequests_GeneratesMultipleEvents_ private static List ParseSseEvents(string sseContent) { - List events = new(); + List events = []; string[] lines = sseContent.Split('\n'); for (int i = 0; i < lines.Length; i++) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj index 7d64f7ae2b0..17d9742436c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj @@ -1,18 +1,19 @@  - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) false $(NoWarn);OPENAI001;CA1812 - - + - - + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs index 8a383890355..ad7e6410f83 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs @@ -138,7 +138,7 @@ public async Task BasicRequestResponseAsync() AssertJsonPropertyExists(response, "service_tier"); var serviceTier = response.GetProperty("service_tier").GetString(); Assert.NotNull(serviceTier); - Assert.True(serviceTier == "default" || serviceTier == "auto", $"service_tier should be 'default' or 'auto', got '{serviceTier}'"); + Assert.True(serviceTier is "default" or "auto", $"service_tier should be 'default' or 'auto', got '{serviceTier}'"); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsSerializationTests.cs index ecbdba4a532..7dc700abe68 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIConversationsSerializationTests.cs @@ -329,7 +329,7 @@ public void Deserialize_AllItemResponses_HaveRequiredFields() Assert.NotNull(item); Assert.NotNull(item.Id); Assert.Equal("message", item.Type); - var messageItem = Assert.IsAssignableFrom(item); + var messageItem = Assert.IsType(item, exactMatch: false); // Content is on concrete message types (ResponsesAssistantMessageItemResource, etc.) // For this test, we just verify the type is correct Assert.NotNull(messageItem); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIHttpApiIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIHttpApiIntegrationTests.cs index 1a72b252b56..a76820fff1e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIHttpApiIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIHttpApiIntegrationTests.cs @@ -209,7 +209,7 @@ public async Task CreateConversationAndResponse_NonStreaming_Background_UpdatesC // Assert - Response is in progress or queued string status = response.GetProperty("status").GetString()!; - Assert.True(status == "in_progress" || status == "queued" || status == "completed", $"Expected 'in_progress', 'queued', or 'completed', got '{status}'"); + Assert.True(status is "in_progress" or "queued" or "completed", $"Expected 'in_progress', 'queued', or 'completed', got '{status}'"); string responseId = response.GetProperty("id").GetString()!; // Wait for completion by polling diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs index 11a0c1940dc..c3054e0296d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs @@ -516,7 +516,7 @@ public ToolCallMockChatClient(string functionName, string argumentsJson) this._functionName = functionName; // Parse JSON arguments into dictionary using var doc = System.Text.Json.JsonDocument.Parse(argumentsJson); - this._arguments = new Dictionary(); + this._arguments = []; foreach (var prop in doc.RootElement.EnumerateObject()) { this._arguments[prop.Name] = prop.Value.ValueKind switch diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs index 3d96567e850..03ab65c9f2e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs @@ -58,7 +58,7 @@ public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException() public void AddAIAgentWithKey_NullInstructions_AllowsNull() { var services = new ServiceCollection(); - var result = services.AddAIAgent("agentName", null!, "key"); + var result = services.AddAIAgent("agentName", null, "key"); Assert.NotNull(result); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs index a29a6208f98..0036a60cc73 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs @@ -67,7 +67,7 @@ public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException() public void AddAIAgentWithKey_NullInstructions_AllowsNull() { var builder = new HostApplicationBuilder(); - var result = builder.AddAIAgent("agentName", null!, "key"); + var result = builder.AddAIAgent("agentName", null, "key"); Assert.NotNull(result); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs index a1b7d29f55f..d27b9a17e33 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -59,7 +59,7 @@ public void AddWorkflow_ValidParameters_ReturnsBuilder() var result = builder.AddWorkflow("workflowName", (sp, key) => CreateTestWorkflow(key)); Assert.NotNull(result); - Assert.IsAssignableFrom(result); + Assert.IsType(result, exactMatch: false); } /// @@ -234,7 +234,7 @@ public void AddAsAIAgent_ReturnsHostedAgentBuilder() var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName); Assert.NotNull(agentBuilder); - Assert.IsAssignableFrom(agentBuilder); + Assert.IsType(agentBuilder, exactMatch: false); Assert.Equal(AgentName, agentBuilder.Name); } @@ -251,7 +251,7 @@ public void AddAsAIAgent_WithoutName_ReturnsHostedAgentBuilderWithWorkflowName() var agentBuilder = workflowBuilder.AddAsAIAgent(); Assert.NotNull(agentBuilder); - Assert.IsAssignableFrom(agentBuilder); + Assert.IsType(agentBuilder, exactMatch: false); Assert.Equal(WorkflowName, agentBuilder.Name); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs index 9993007de1b..a229c7e1f83 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs @@ -118,9 +118,7 @@ private static IList ResolveAgentTools(IServiceProvider serviceProvider, /// /// Dummy AITool implementation for testing. /// - private sealed class DummyAITool : AITool - { - } + private sealed class DummyAITool : AITool; /// /// Mock chat client for testing. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj index 087c58ca92b..1279b20397d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj @@ -1,8 +1,7 @@ - $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) + $(TargetFrameworksCore) diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj index 190d38e1dd2..99b028963aa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj @@ -1,8 +1,6 @@ - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) True diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index 46a5482f155..0515c8e7aca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -91,8 +91,8 @@ public async Task InvokingAsync_PerformsSearch_AndReturnsContextMessageAsync() ThreadId = "thread", UserId = "user" }; - var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object); - var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") }); + var sut = new Mem0Provider(this._httpClient, storageScope, options: new() { EnableSensitiveTelemetryData = true }, loggerFactory: this._loggerFactoryMock.Object); + var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "What is my name?")]); // Act var aiContext = await sut.InvokingAsync(invokingContext); @@ -130,6 +130,60 @@ public async Task InvokingAsync_PerformsSearch_AndReturnsContextMessageAsync() Times.Once); } + [Theory] + [InlineData(false, false, 2)] + [InlineData(true, false, 2)] + [InlineData(false, true, 1)] + [InlineData(true, true, 1)] + public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + { + // Arrange + if (requestThrows) + { + this._handler.EnqueueEmptyInternalServerError(); + } + else + { + this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"session_id\": \"thread\" } ]"); + } + + var storageScope = new Mem0ProviderScope + { + ApplicationId = "app", + AgentId = "agent", + ThreadId = "thread", + UserId = "user" + }; + var options = new Mem0ProviderOptions { EnableSensitiveTelemetryData = enableSensitiveTelemetryData }; + + var sut = new Mem0Provider(this._httpClient, storageScope, options: options, loggerFactory: this._loggerFactoryMock.Object); + var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Who am I?") }); + + // Act + await sut.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); + foreach (var logInvocation in this._loggerMock.Invocations) + { + var state = Assert.IsAssignableFrom>>(logInvocation.Arguments[2]); + var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; + Assert.Equal(enableSensitiveTelemetryData ? "user" : "", userIdValue); + + var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value; + if (inputValue != null) + { + Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "", inputValue); + } + + var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value; + if (messageTextValue != null) + { + Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "", messageTextValue); + } + } + } + [Fact] public async Task InvokedAsync_PersistsAllowedMessagesAsync() { @@ -218,6 +272,55 @@ public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync() Times.Once); } + [Theory] + [InlineData(false, false, 0)] + [InlineData(true, false, 0)] + [InlineData(false, true, 1)] + [InlineData(true, true, 1)] + public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogCount) + { + // Arrange + if (requestThrows) + { + this._handler.EnqueueEmptyInternalServerError(); + } + else + { + this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"session_id\": \"thread\" } ]"); + } + + var storageScope = new Mem0ProviderScope + { + ApplicationId = "app", + AgentId = "agent", + ThreadId = "thread", + UserId = "user" + }; + + var options = new Mem0ProviderOptions { EnableSensitiveTelemetryData = enableSensitiveTelemetryData }; + var sut = new Mem0Provider(this._httpClient, storageScope, options: options, loggerFactory: this._loggerFactoryMock.Object); + var requestMessages = new List + { + new(ChatRole.User, "User text") + }; + var responseMessages = new List + { + new(ChatRole.Assistant, "Assistant text") + }; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); + + // Assert + Assert.Equal(expectedLogCount, this._loggerMock.Invocations.Count); + foreach (var logInvocation in this._loggerMock.Invocations) + { + var state = Assert.IsAssignableFrom>>(logInvocation.Arguments[2]); + var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; + Assert.Equal(enableSensitiveTelemetryData ? "user" : "", userIdValue); + } + } + [Fact] public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync() { @@ -316,7 +419,7 @@ public void Dispose() private sealed class RecordingHandler : HttpMessageHandler { private readonly Queue _responses = new(); - public List<(HttpRequestMessage RequestMessage, string RequestBody)> Requests { get; } = new(); + public List<(HttpRequestMessage RequestMessage, string RequestBody)> Requests { get; } = []; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj index 1836f437d51..5abb64ca222 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj @@ -1,9 +1,5 @@  - - $(ProjectsTargetFrameworks) - - false diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs index 61e3f5ef57d..26f855d59e1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs @@ -4,6 +4,7 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.IO; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -91,7 +92,7 @@ public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() { Name = "Test Agent", Description = "Test description", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; // Act @@ -222,7 +223,7 @@ public void GetAIAgent_WithClientResultAndOptions_WorksCorrectly() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -249,7 +250,7 @@ public void GetAIAgent_WithAssistantAndOptions_WorksCorrectly() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -298,7 +299,7 @@ public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -325,7 +326,7 @@ public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() { Name = "Override Name", Description = "Override Description", - Instructions = "Override Instructions" + ChatOptions = new() { Instructions = "Override Instructions" } }; // Act @@ -455,6 +456,162 @@ public async Task GetAIAgentAsync_WithEmptyAgentId_ThrowsArgumentExceptionAsync( Assert.Equal("agentId", exception.ParamName); } + /// + /// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void CreateAIAgent_WithServices_PassesServicesToAgent() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var serviceProvider = new TestServiceProvider(); + const string ModelId = "test-model"; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + instructions: "Test instructions", + name: "Test Agent", + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that CreateAIAgent with options and services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndServices_PassesServicesToAgent() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var serviceProvider = new TestServiceProvider(); + const string ModelId = "test-model"; + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + ChatOptions = new() { Instructions = "Test instructions" } + }; + + // Act + var agent = assistantClient.CreateAIAgent(ModelId, options, services: serviceProvider); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that GetAIAgent with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void GetAIAgent_WithServices_PassesServicesToAgent() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var serviceProvider = new TestServiceProvider(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent"}"""))!; + + // Act + var agent = assistantClient.GetAIAgent(assistant, services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that GetAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public async Task GetAIAgentAsync_WithServices_PassesServicesToAgentAsync() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var serviceProvider = new TestServiceProvider(); + + // Act + var agent = await assistantClient.GetAIAgentAsync("asst_abc123", services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that CreateAIAgent with both clientFactory and services works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var serviceProvider = new TestServiceProvider(); + var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model")); + const string ModelId = "test-model"; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + instructions: "Test instructions", + name: "Test Agent", + clientFactory: (innerClient) => testChatClient, + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the custom chat client was applied + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + + // Verify the IServiceProvider was passed through + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Uses reflection to access the FunctionInvocationServices property which is not public. + /// + private static IServiceProvider? GetFunctionInvocationServices(FunctionInvokingChatClient client) + { + var property = typeof(FunctionInvokingChatClient).GetProperty( + "FunctionInvocationServices", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return property?.GetValue(client) as IServiceProvider; + } + /// /// Creates a test AssistantClient implementation for testing. /// @@ -488,6 +645,11 @@ public TestChatClient(IChatClient innerClient) : base(innerClient) } } + private sealed class TestServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + private sealed class FakePipelineResponse : PipelineResponse { public override int Status => throw new NotImplementedException(); diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs index 72ea0395e9b..ef9f27b01a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs @@ -130,7 +130,7 @@ public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() { Name = "Test Agent", Description = "Test description", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }; // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs index 2612f4bfa95..f31d343157a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -167,4 +168,114 @@ public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() Assert.Equal("options", exception.ParamName); } + + /// + /// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void CreateAIAgent_WithServices_PassesServicesToAgent() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var serviceProvider = new TestServiceProvider(); + + // Act + var agent = responseClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent", + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that CreateAIAgent with options and services parameter correctly passes it through to the ChatClientAgent. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndServices_PassesServicesToAgent() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var serviceProvider = new TestServiceProvider(); + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + ChatOptions = new() { Instructions = "Test instructions" } + }; + + // Act + var agent = responseClient.CreateAIAgent(options, services: serviceProvider); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify the IServiceProvider was passed through to the FunctionInvokingChatClient + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// Verify that CreateAIAgent with both clientFactory and services works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var serviceProvider = new TestServiceProvider(); + var testChatClient = new TestChatClient(responseClient.AsIChatClient()); + + // Act + var agent = responseClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent", + clientFactory: (innerClient) => testChatClient, + services: serviceProvider); + + // Assert + Assert.NotNull(agent); + + // Verify the custom chat client was applied + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + + // Verify the IServiceProvider was passed through + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var functionInvokingClient = chatClient.GetService(); + Assert.NotNull(functionInvokingClient); + Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient)); + } + + /// + /// A simple test IServiceProvider implementation for testing. + /// + private sealed class TestServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + /// + /// Uses reflection to access the FunctionInvocationServices property which is not public. + /// + private static IServiceProvider? GetFunctionInvocationServices(FunctionInvokingChatClient client) + { + var property = typeof(FunctionInvokingChatClient).GetProperty( + "FunctionInvocationServices", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return property?.GetValue(client) as IServiceProvider; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj index 7f26fdc1326..515ca2fb8d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj @@ -1,9 +1,5 @@ - - $(ProjectsTargetFrameworks) - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj index bd07eca8ab6..0129bba5d14 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj @@ -1,9 +1,5 @@  - - $(ProjectsTargetFrameworks) - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index b2b0ac45e62..3e45d8d4bd2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -220,10 +220,10 @@ public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessRespon var request = new ProtectionScopesRequest("test-user-id", "test-tenant-id") { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new("microsoft.graph.policyLocationApplication", "app-123") - } + ] }; var expectedResponse = new ProtectionScopesResponse @@ -233,10 +233,10 @@ public async Task GetProtectionScopesAsync_WithValidRequest_ReturnsSuccessRespon new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new ("microsoft.graph.policyLocationApplication", "app-123") - } + ] } } }; @@ -502,7 +502,7 @@ private static ContentToProcess CreateValidContentToProcess() }; return new ContentToProcess( - new List { metadata }, + [metadata], activityMetadata, deviceMetadata, integratedAppMetadata, @@ -554,9 +554,10 @@ protected override async Task SendAsync(HttpRequestMessage throw new HttpRequestException("Simulated network error"); } - var response = new HttpResponseMessage(this.StatusCodeToReturn); - - response.Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json"); + var response = new HttpResponseMessage(this.StatusCodeToReturn) + { + Content = new StringContent(this.ResponseToReturn ?? string.Empty, Encoding.UTF8, "application/json") + }; if (!string.IsNullOrEmpty(this.ETagToReturn)) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index f43f086de7a..9d56e0bc507 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -61,10 +61,10 @@ public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockT new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new ("microsoft.graph.policyLocationApplication", "app-123") - }, + ], ExecutionMode = ExecutionMode.EvaluateInline } } @@ -120,10 +120,10 @@ public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldB new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new ("microsoft.graph.policyLocationApplication", "app-123") - }, + ], ExecutionMode = ExecutionMode.EvaluateInline } } @@ -179,10 +179,10 @@ public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockF new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new("microsoft.graph.policyLocationApplication", "app-123") - }, + ], ExecutionMode = ExecutionMode.EvaluateInline } } @@ -234,10 +234,10 @@ public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableA new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new ("microsoft.graph.policyLocationApplication", "app-123") - }, + ], ExecutionMode = ExecutionMode.EvaluateInline } } @@ -290,10 +290,10 @@ public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModif new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new ("microsoft.graph.policyLocationApplication", "app-123") - }, + ], ExecutionMode = ExecutionMode.EvaluateInline } } @@ -347,10 +347,10 @@ public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableSc new() { Activities = ProtectionScopeActivities.UploadText, - Locations = new List - { + Locations = + [ new ("microsoft.graph.policyLocationApplication", "app-456") - } + ] } } }; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs index ca5803bba46..7f455327dcb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs @@ -257,11 +257,10 @@ public void BuildsPipelineInOrderAdded() { // Arrange var mockInnerAgent = new Mock(); - var builder = new AIAgentBuilder(mockInnerAgent.Object); - - builder.Use(next => new InnerAgentCapturingAgent("First", next)); - builder.Use(next => new InnerAgentCapturingAgent("Second", next)); - builder.Use(next => new InnerAgentCapturingAgent("Third", next)); + var builder = new AIAgentBuilder(mockInnerAgent.Object) + .Use(next => new InnerAgentCapturingAgent("First", next)) + .Use(next => new InnerAgentCapturingAgent("Second", next)) + .Use(next => new InnerAgentCapturingAgent("Third", next)); // Act var first = (InnerAgentCapturingAgent)builder.Build(); @@ -306,7 +305,7 @@ public void UsesEmptyServiceProviderWhenNoServicesProvided() { Assert.Null(serviceProvider.GetService(typeof(object))); - var keyedServiceProvider = Assert.IsAssignableFrom(serviceProvider); + var keyedServiceProvider = Assert.IsType(serviceProvider, exactMatch: false); Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key")); Assert.Throws(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key")); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs index dc983ef2022..58cf5f718fb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs @@ -19,7 +19,6 @@ public void DefaultConstructor_InitializesWithNullValues() // Assert Assert.Null(options.Name); - Assert.Null(options.Instructions); Assert.Null(options.Description); Assert.Null(options.ChatOptions); Assert.Null(options.ChatMessageStoreFactory); @@ -27,90 +26,44 @@ public void DefaultConstructor_InitializesWithNullValues() } [Fact] - public void ParameterizedConstructor_WithNullValues_SetsPropertiesCorrectly() + public void Constructor_WithNullValues_SetsPropertiesCorrectly() { // Act - var options = new ChatClientAgentOptions( - instructions: null, - name: null, - description: null, - tools: null); + var options = new ChatClientAgentOptions() { Name = null, Description = null, ChatOptions = new() { Tools = null, Instructions = null } }; // Assert Assert.Null(options.Name); - Assert.Null(options.Instructions); Assert.Null(options.Description); - Assert.Null(options.ChatOptions); Assert.Null(options.AIContextProviderFactory); - } - - [Fact] - public void ParameterizedConstructor_WithInstructionsOnly_SetsChatOptionsWithInstructions() - { - // Arrange - const string Instructions = "Test instructions"; - - // Act - var options = new ChatClientAgentOptions( - instructions: Instructions, - name: null, - description: null, - tools: null); - - // Assert - Assert.Null(options.Name); - Assert.Equal(Instructions, options.Instructions); - Assert.Null(options.Description); - Assert.Null(options.ChatOptions); - } - - [Fact] - public void ParameterizedConstructor_WithToolsOnly_SetsChatOptionsWithTools() - { - // Arrange - var tools = new List { AIFunctionFactory.Create(() => "test") }; - - // Act - var options = new ChatClientAgentOptions( - instructions: null, - name: null, - description: null, - tools: tools); - - // Assert - Assert.Null(options.Name); - Assert.Null(options.Instructions); - Assert.Null(options.Description); + Assert.Null(options.ChatMessageStoreFactory); Assert.NotNull(options.ChatOptions); Assert.Null(options.ChatOptions.Instructions); - Assert.Same(tools, options.ChatOptions.Tools); + Assert.Null(options.ChatOptions.Tools); } [Fact] - public void ParameterizedConstructor_WithInstructionsAndTools_SetsChatOptionsWithBoth() + public void Constructor_WithToolsOnly_SetsChatOptionsWithTools() { // Arrange - const string Instructions = "Test instructions"; var tools = new List { AIFunctionFactory.Create(() => "test") }; // Act - var options = new ChatClientAgentOptions( - instructions: Instructions, - name: null, - description: null, - tools: tools); + var options = new ChatClientAgentOptions() + { + Name = null, + Description = null, + ChatOptions = new() { Tools = tools } + }; // Assert Assert.Null(options.Name); - Assert.Equal(Instructions, options.Instructions); Assert.Null(options.Description); Assert.NotNull(options.ChatOptions); - Assert.Null(options.ChatOptions.Instructions); - Assert.Same(tools, options.ChatOptions.Tools); + AssertSameTools(tools, options.ChatOptions.Tools); } [Fact] - public void ParameterizedConstructor_WithAllParameters_SetsAllPropertiesCorrectly() + public void Constructor_WithAllParameters_SetsAllPropertiesCorrectly() { // Arrange const string Instructions = "Test instructions"; @@ -119,38 +72,37 @@ public void ParameterizedConstructor_WithAllParameters_SetsAllPropertiesCorrectl var tools = new List { AIFunctionFactory.Create(() => "test") }; // Act - var options = new ChatClientAgentOptions( - instructions: Instructions, - name: Name, - description: Description, - tools: tools); + var options = new ChatClientAgentOptions() + { + Name = Name, + Description = Description, + ChatOptions = new() { Tools = tools, Instructions = Instructions } + }; // Assert Assert.Equal(Name, options.Name); - Assert.Equal(Instructions, options.Instructions); + Assert.Equal(Instructions, options.ChatOptions.Instructions); Assert.Equal(Description, options.Description); Assert.NotNull(options.ChatOptions); - Assert.Null(options.ChatOptions.Instructions); - Assert.Same(tools, options.ChatOptions.Tools); + AssertSameTools(tools, options.ChatOptions.Tools); } [Fact] - public void ParameterizedConstructor_WithNameAndDescriptionOnly_DoesNotCreateChatOptions() + public void Constructor_WithNameAndDescriptionOnly_DoesNotCreateChatOptions() { // Arrange const string Name = "Test name"; const string Description = "Test description"; // Act - var options = new ChatClientAgentOptions( - instructions: null, - name: Name, - description: Description, - tools: null); + var options = new ChatClientAgentOptions() + { + Name = Name, + Description = Description, + }; // Assert Assert.Equal(Name, options.Name); - Assert.Null(options.Instructions); Assert.Equal(Description, options.Description); Assert.Null(options.ChatOptions); } @@ -159,7 +111,6 @@ public void ParameterizedConstructor_WithNameAndDescriptionOnly_DoesNotCreateCha public void Clone_CreatesDeepCopyWithSameValues() { // Arrange - const string Instructions = "Test instructions"; const string Name = "Test name"; const string Description = "Test description"; var tools = new List { AIFunctionFactory.Create(() => "test") }; @@ -171,8 +122,11 @@ static AIContextProvider AIContextProviderFactory( ChatClientAgentOptions.AIContextProviderFactoryContext ctx) => new Mock().Object; - var original = new ChatClientAgentOptions(Instructions, Name, Description, tools) + var original = new ChatClientAgentOptions() { + Name = Name, + Description = Description, + ChatOptions = new() { Tools = tools }, Id = "test-id", ChatMessageStoreFactory = ChatMessageStoreFactory, AIContextProviderFactory = AIContextProviderFactory @@ -185,7 +139,6 @@ static AIContextProvider AIContextProviderFactory( Assert.NotSame(original, clone); Assert.Equal(original.Id, clone.Id); Assert.Equal(original.Name, clone.Name); - Assert.Equal(original.Instructions, clone.Instructions); Assert.Equal(original.Description, clone.Description); Assert.Same(original.ChatMessageStoreFactory, clone.ChatMessageStoreFactory); Assert.Same(original.AIContextProviderFactory, clone.AIContextProviderFactory); @@ -197,14 +150,13 @@ static AIContextProvider AIContextProviderFactory( } [Fact] - public void Clone_WithNullChatOptions_ClonesCorrectly() + public void Clone_WithoutProvidingChatOptions_ClonesCorrectly() { // Arrange var original = new ChatClientAgentOptions { Id = "test-id", Name = "Test name", - Instructions = "Test instructions", Description = "Test description" }; @@ -215,10 +167,19 @@ public void Clone_WithNullChatOptions_ClonesCorrectly() Assert.NotSame(original, clone); Assert.Equal(original.Id, clone.Id); Assert.Equal(original.Name, clone.Name); - Assert.Equal(original.Instructions, clone.Instructions); Assert.Equal(original.Description, clone.Description); - Assert.Null(clone.ChatOptions); + Assert.Null(original.ChatOptions); Assert.Null(clone.ChatMessageStoreFactory); Assert.Null(clone.AIContextProviderFactory); } + + private static void AssertSameTools(IList? expected, IList? actual) + { + var index = 0; + foreach (var tool in expected ?? []) + { + Assert.Same(tool, actual?[index]); + index++; + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 862b9ef3b40..6e9d952b57c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -31,7 +31,7 @@ public void VerifyChatClientAgentDefinition() Id = "test-agent-id", Name = "test name", Description = "test description", - Instructions = "test instructions", + ChatOptions = new() { Instructions = "test instructions" }, }); // Assert @@ -65,7 +65,7 @@ public async Task VerifyChatClientAgentInvocationAsync() ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions" + ChatOptions = new() { Instructions = "base instructions" }, }); // Act @@ -99,7 +99,7 @@ public async Task RunAsyncThrowsArgumentNullExceptionWhenMessagesIsNullAsync() { // Arrange var chatClient = new Mock().Object; - ChatClientAgent agent = new(chatClient, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(chatClient, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); // Act & Assert await Assert.ThrowsAsync(() => agent.RunAsync((IReadOnlyCollection)null!)); @@ -120,7 +120,7 @@ public async Task RunAsyncPassesChatOptionsWhenUsingChatClientAgentRunOptionsAsy It.Is(opts => opts.MaxOutputTokens == 100), It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); // Act await agent.RunAsync([new(ChatRole.User, "test")], options: new ChatClientAgentRunOptions(chatOptions)); @@ -181,7 +181,7 @@ public async Task RunAsyncIncludesBaseInstructionsInOptionsAsync() capturedMessages.AddRange(msgs)) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "base instructions" } }); var runOptions = new AgentRunOptions(); // Act @@ -212,7 +212,7 @@ public async Task RunAsyncSetsAuthorNameOnAllResponseMessagesAsync(string? autho It.IsAny(), It.IsAny())).ReturnsAsync(new ChatResponse(responseMessages)); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions", Name = authorName }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" }, Name = authorName }); // Act var result = await agent.RunAsync([new(ChatRole.User, "test")]); @@ -239,7 +239,7 @@ public async Task RunAsyncRetrievesMessagesFromThreadWhenThreadStoresMessagesThr capturedMessages.AddRange(msgs)) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); // Create a thread using the agent's GetNewThread method var thread = agent.GetNewThread(); @@ -270,7 +270,7 @@ public async Task RunAsyncWorksWithoutInstructionsWhenInstructionsAreNullOrEmpty capturedMessages.AddRange(msgs)) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = null }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = null } }); // Act await agent.RunAsync([new(ChatRole.User, "test message")]); @@ -300,7 +300,7 @@ public async Task RunAsyncWorksWithEmptyMessagesWhenNoMessagesProvidedAsync() capturedMessages.AddRange(msgs)) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); // Act await agent.RunAsync([]); @@ -326,7 +326,7 @@ public async Task RunAsyncDoesNotThrowWhenSpecifyingTwoSameThreadIdsAsync() It.Is(opts => opts.ConversationId == "ConvId"), It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; @@ -346,7 +346,7 @@ public async Task RunAsyncThrowsWhenSpecifyingTwoDifferentThreadIdsAsync() var chatOptions = new ChatOptions { ConversationId = "ConvId" }; Mock mockService = new(); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); ChatClientAgentThread thread = new() { ConversationId = "ThreadId" }; @@ -369,7 +369,7 @@ public async Task RunAsyncClonesChatOptionsToAddThreadIdAsync() It.Is(opts => opts.MaxOutputTokens == 100 && opts.ConversationId == "ConvId"), It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; @@ -394,7 +394,7 @@ public async Task RunAsyncThrowsForMissingConversationIdWithConversationIdThread It.IsAny(), It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; @@ -415,7 +415,7 @@ public async Task RunAsyncSetsConversationIdOnThreadWhenReturnedByChatClientAsyn It.IsAny>(), It.IsAny(), It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); ChatClientAgentThread thread = new(); // Act @@ -442,7 +442,7 @@ public async Task RunAsyncUsesChatMessageStoreWhenNoConversationIdReturnedByChat mockFactory.Setup(f => f(It.IsAny())).Returns(new InMemoryChatMessageStore()); ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", + ChatOptions = new() { Instructions = "test instructions" }, ChatMessageStoreFactory = mockFactory.Object }); @@ -459,10 +459,10 @@ public async Task RunAsyncUsesChatMessageStoreWhenNoConversationIdReturnedByChat } /// - /// Verify that RunAsync doesn't use the ChatMessageStore factory when the chat client returns a conversation id. + /// Verify that RunAsync uses the default InMemoryChatMessageStore when the chat client returns no conversation id. /// [Fact] - public async Task RunAsyncIgnoresChatMessageStoreWhenConversationIdReturnedByChatClientAsync() + public async Task RunAsyncUsesDefaultInMemoryChatMessageStoreWhenNoConversationIdReturnedByChatClientAsync() { // Arrange Mock mockService = new(); @@ -470,12 +470,45 @@ public async Task RunAsyncIgnoresChatMessageStoreWhenConversationIdReturnedByCha s => s.GetResponseAsync( It.IsAny>(), It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + }); + + // Act + ChatClientAgentThread? thread = agent.GetNewThread() as ChatClientAgentThread; + await agent.RunAsync([new(ChatRole.User, "test")], thread); + + // Assert + var messageStore = Assert.IsType(thread!.MessageStore); + Assert.Equal(2, messageStore.Count); + Assert.Equal("test", messageStore[0].Text); + Assert.Equal("response", messageStore[1].Text); + } + + /// + /// Verify that RunAsync uses the ChatMessageStore factory when the chat client returns no conversation id. + /// + [Fact] + public async Task RunAsyncUsesChatMessageStoreFactoryWhenProvidedAndNoConversationIdReturnedByChatClientAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatMessageStore = new(); + Mock> mockFactory = new(); - mockFactory.Setup(f => f(It.IsAny())).Returns(new InMemoryChatMessageStore()); + mockFactory.Setup(f => f(It.IsAny())).Returns(mockChatMessageStore.Object); + ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", + ChatOptions = new() { Instructions = "test instructions" }, ChatMessageStoreFactory = mockFactory.Object }); @@ -484,8 +517,36 @@ public async Task RunAsyncIgnoresChatMessageStoreWhenConversationIdReturnedByCha await agent.RunAsync([new(ChatRole.User, "test")], thread); // Assert - Assert.Equal("ConvId", thread!.ConversationId); - mockFactory.Verify(f => f(It.IsAny()), Times.Never); + Assert.IsType(thread!.MessageStore, exactMatch: false); + mockChatMessageStore.Verify(s => s.AddMessagesAsync(It.Is>(x => x.Count() == 2), It.IsAny()), Times.Once); + mockFactory.Verify(f => f(It.IsAny()), Times.Once); + } + + /// + /// Verify that RunAsync throws when a ChatMessageStore Factory is provided and the chat client returns a conversation id. + /// + [Fact] + public async Task RunAsyncThrowsWhenChatMessageStoreFactoryProvidedAndConversationIdReturnedByChatClientAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + Mock> mockFactory = new(); + mockFactory.Setup(f => f(It.IsAny())).Returns(new InMemoryChatMessageStore()); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatMessageStoreFactory = mockFactory.Object + }); + + // Act & Assert + ChatClientAgentThread? thread = agent.GetNewThread() as ChatClientAgentThread; + var exception = await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); + Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message); } /// @@ -531,7 +592,7 @@ public async Task RunAsyncInvokesAIContextProviderAndUsesResultAsync() .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act var thread = agent.GetNewThread() as ChatClientAgentThread; @@ -593,7 +654,7 @@ public async Task RunAsyncInvokesAIContextProviderWhenGetResponseFailsAsync() .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act await Assert.ThrowsAsync(() => agent.RunAsync(requestMessages)); @@ -639,7 +700,7 @@ public async Task RunAsyncInvokesAIContextProviderAndSucceedsWithEmptyAIContextA .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new AIContext()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act await agent.RunAsync([new(ChatRole.User, "user message")]); @@ -846,7 +907,7 @@ public void InstructionsReturnsMetadataInstructionsWhenMetadataProvided() { // Arrange var chatClient = new Mock().Object; - var metadata = new ChatClientAgentOptions { Instructions = "You are a helpful assistant" }; + var metadata = new ChatClientAgentOptions { ChatOptions = new() { Instructions = "You are a helpful assistant" } }; ChatClientAgent agent = new(chatClient, metadata); // Act & Assert @@ -875,7 +936,7 @@ public void InstructionsReturnsNullWhenMetadataInstructionsIsNull() { // Arrange var chatClient = new Mock().Object; - var metadata = new ChatClientAgentOptions { Instructions = null }; + var metadata = new ChatClientAgentOptions { ChatOptions = new() { Instructions = null } }; ChatClientAgent agent = new(chatClient, metadata); // Act & Assert @@ -906,10 +967,10 @@ public void ConstructorUsesOptionalParams() } /// - /// Verify that ChatOptions property returns null when no params are provided that require a ChatOptions instance. + /// Verify that ChatOptions is created with instructions when instructions are provided and no tools are provided. /// [Fact] - public void ChatOptionsReturnsNullWhenConstructorToolsNotProvided() + public void ChatOptionsCreatedWithInstructionsEvenWhenConstructorToolsNotProvided() { // Arrange var chatClient = new Mock().Object; @@ -919,7 +980,8 @@ public void ChatOptionsReturnsNullWhenConstructorToolsNotProvided() Assert.Equal("TestInstructions", agent.Instructions); Assert.Equal("TestName", agent.Name); Assert.Equal("TestDescription", agent.Description); - Assert.Null(agent.ChatOptions); + Assert.NotNull(agent.ChatOptions); + Assert.Equal("TestInstructions", agent.ChatOptions.Instructions); } #endregion @@ -1010,7 +1072,7 @@ public void ChatOptionsReturnsClonedCopyWhenAgentOptionsHaveChatOptions() public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync() { // Arrange - var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f }; + var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, Instructions = "test instructions" }; Mock mockService = new(); ChatOptions? capturedChatOptions = null; mockService.Setup( @@ -1024,7 +1086,6 @@ public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync() ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", ChatOptions = agentChatOptions }); var messages = new List { new(ChatRole.User, "test") }; @@ -1053,7 +1114,7 @@ public async Task ChatOptionsMergingUsesAgentOptionsConstructorWhenRequestHasNon capturedChatOptions = opts) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new("test instructions")); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); var messages = new List { new(ChatRole.User, "test") }; // Act @@ -1106,6 +1167,7 @@ public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsy // Arrange var agentChatOptions = new ChatOptions { + Instructions = "test instructions", MaxOutputTokens = 100, Temperature = 0.7f, TopP = 0.9f, @@ -1143,7 +1205,6 @@ public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsy ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", ChatOptions = agentChatOptions }); var messages = new List { new(ChatRole.User, "test") }; @@ -1202,6 +1263,7 @@ public async Task ChatOptionsMergingConcatenatesToolsFromAgentAndRequestAsync() var agentChatOptions = new ChatOptions { + Instructions = "test instructions", Tools = [agentTool] }; var requestChatOptions = new ChatOptions @@ -1222,7 +1284,6 @@ public async Task ChatOptionsMergingConcatenatesToolsFromAgentAndRequestAsync() ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", ChatOptions = agentChatOptions }); var messages = new List { new(ChatRole.User, "test") }; @@ -1251,6 +1312,7 @@ public async Task ChatOptionsMergingUsesAgentToolsWhenRequestHasNoToolsAsync() var agentChatOptions = new ChatOptions { + Instructions = "test instructions", Tools = [agentTool] }; var requestChatOptions = new ChatOptions @@ -1272,7 +1334,6 @@ public async Task ChatOptionsMergingUsesAgentToolsWhenRequestHasNoToolsAsync() ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", ChatOptions = agentChatOptions }); var messages = new List { new(ChatRole.User, "test") }; @@ -1299,6 +1360,7 @@ public async Task ChatOptionsMergingUsesRawRepresentationFactoryWithFallbackAsyn // Arrange var agentChatOptions = new ChatOptions { + Instructions = "test instructions", RawRepresentationFactory = _ => agentSetting }; var requestChatOptions = new ChatOptions @@ -1319,7 +1381,6 @@ public async Task ChatOptionsMergingUsesRawRepresentationFactoryWithFallbackAsyn ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", ChatOptions = agentChatOptions }); var messages = new List { new(ChatRole.User, "test") }; @@ -1375,7 +1436,7 @@ public async Task ChatOptionsMergingHandlesAllScalarPropertiesCorrectlyAsync() TopK = 50, PresencePenalty = 0.1f, FrequencyPenalty = 0.2f, - Instructions = "test instructions\nrequest instructions", + Instructions = "agent instructions\nrequest instructions", ModelId = "agent-model", Seed = 12345, ConversationId = "agent-conversation", @@ -1398,7 +1459,6 @@ public async Task ChatOptionsMergingHandlesAllScalarPropertiesCorrectlyAsync() ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", ChatOptions = agentChatOptions }); var messages = new List { new(ChatRole.User, "test") }; @@ -1448,7 +1508,7 @@ public void GetService_RequestingAIAgentMetadata_ReturnsMetadata() { Id = "test-agent-id", Name = "TestAgent", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1471,7 +1531,7 @@ public void GetService_RequestingIChatClient_ReturnsChatClient() var mockChatClient = new Mock(); var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1495,7 +1555,7 @@ public void GetService_RequestingChatClientAgent_ReturnsChatClientAgent() var mockChatClient = new Mock(); var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1521,7 +1581,7 @@ public void GetService_RequestingUnknownServiceType_DelegatesToChatClient() var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1545,7 +1605,7 @@ public void GetService_RequestingUnknownServiceTypeWithNullFromChatClient_Return var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1571,7 +1631,7 @@ public void GetService_WithServiceKey_DelegatesToChatClient() var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1600,7 +1660,7 @@ public void GetService_RequestingAIAgentMetadata_ReturnsMetadataWithCorrectProvi var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1633,7 +1693,7 @@ public void GetService_RequestingAIAgentMetadata_ReturnsCorrectAIAgentMetadataBa { Id = "test-agent-id", Name = "TestAgent", - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1660,7 +1720,7 @@ public void GetService_RequestingAIAgentMetadata_ReturnsConsistentMetadata() var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1695,12 +1755,12 @@ public void GetService_RequestingAIAgentMetadata_StructureIsConsistentAcrossConf var chatClientAgent1 = new ChatClientAgent(mockChatClient1.Object, new ChatClientAgentOptions { - Instructions = "Test instructions 1" + ChatOptions = new() { Instructions = "Test instructions 1" } }); var chatClientAgent2 = new ChatClientAgent(mockChatClient2.Object, new ChatClientAgentOptions { - Instructions = "Test instructions 2" + ChatOptions = new() { Instructions = "Test instructions 2" } }); // Act @@ -1735,7 +1795,7 @@ public void GetService_RequestingChatClientAgentType_ReturnsBaseImplementation() var mockChatClient = new Mock(); var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1759,7 +1819,7 @@ public void GetService_RequestingAIAgentType_ReturnsBaseImplementation() var mockChatClient = new Mock(); var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act @@ -1784,7 +1844,7 @@ public void GetService_RequestingIChatClientWithServiceKey_ReturnsOwnChatClient( var mockChatClient = new Mock(); var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act - Request IChatClient with a service key (base.GetService will return null due to serviceKey) @@ -1809,7 +1869,7 @@ public void GetService_RequestingUnknownServiceWithServiceKey_CallsUnderlyingCha mockChatClient.Setup(c => c.GetService(typeof(string), "some-key")).Returns("test-result"); var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { - Instructions = "Test instructions" + ChatOptions = new() { Instructions = "Test instructions" } }); // Act - Request string with a service key (base.GetService will return null due to serviceKey) @@ -1850,7 +1910,7 @@ public async Task VerifyChatClientAgentStreamingAsync() ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions" + ChatOptions = new() { Instructions = "test instructions" } }); // Act @@ -1897,7 +1957,7 @@ public async Task RunStreamingAsyncUsesChatMessageStoreWhenNoConversationIdRetur mockFactory.Setup(f => f(It.IsAny())).Returns(new InMemoryChatMessageStore()); ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", + ChatOptions = new() { Instructions = "test instructions" }, ChatMessageStoreFactory = mockFactory.Object }); @@ -1914,10 +1974,10 @@ public async Task RunStreamingAsyncUsesChatMessageStoreWhenNoConversationIdRetur } /// - /// Verify that RunStreamingAsync doesn't use the ChatMessageStore factory when the chat client returns a conversation id. + /// Verify that RunStreamingAsync throws when a ChatMessageStore factory is provided and the chat client returns a conversation id. /// [Fact] - public async Task RunStreamingAsyncIgnoresChatMessageStoreWhenConversationIdReturnedByChatClientAsync() + public async Task RunStreamingAsyncThrowsWhenChatMessageStoreFactoryProvidedAndConversationIdReturnedByChatClientAsync() { // Arrange Mock mockService = new(); @@ -1935,17 +1995,14 @@ public async Task RunStreamingAsyncIgnoresChatMessageStoreWhenConversationIdRetu mockFactory.Setup(f => f(It.IsAny())).Returns(new InMemoryChatMessageStore()); ChatClientAgent agent = new(mockService.Object, options: new() { - Instructions = "test instructions", + ChatOptions = new() { Instructions = "test instructions" }, ChatMessageStoreFactory = mockFactory.Object }); - // Act + // Act & Assert ChatClientAgentThread? thread = agent.GetNewThread() as ChatClientAgentThread; - await agent.RunStreamingAsync([new(ChatRole.User, "test")], thread).ToListAsync(); - - // Assert - Assert.Equal("ConvId", thread!.ConversationId); - mockFactory.Verify(f => f(It.IsAny()), Times.Never); + var exception = await Assert.ThrowsAsync(async () => await agent.RunStreamingAsync([new(ChatRole.User, "test")], thread).ToListAsync()); + Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message); } /// @@ -1991,7 +2048,7 @@ public async Task RunStreamingAsyncInvokesAIContextProviderAndUsesResultAsync() .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] }, AIContextProviderFactory = _ => mockProvider.Object }); // Act var thread = agent.GetNewThread() as ChatClientAgentThread; @@ -2054,7 +2111,7 @@ public async Task RunStreamingAsyncInvokesAIContextProviderWhenGetResponseFailsA .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "base instructions", Tools = [AIFunctionFactory.Create(() => { }, "base function")] }, AIContextProviderFactory = _ => mockProvider.Object }); // Act await Assert.ThrowsAsync(async () => @@ -2074,530 +2131,6 @@ await Assert.ThrowsAsync(async () => #endregion - #region GetNewThread Tests - - [Fact] - public void GetNewThreadUsesAIContextProviderFactoryIfProvided() - { - // Arrange - var mockChatClient = new Mock(); - var mockContextProvider = new Mock(); - var factoryCalled = false; - var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Instructions = "Test instructions", - AIContextProviderFactory = _ => - { - factoryCalled = true; - return mockContextProvider.Object; - } - }); - - // Act - var thread = agent.GetNewThread(); - - // Assert - Assert.True(factoryCalled, "AIContextProviderFactory was not called."); - Assert.IsType(thread); - var typedThread = (ChatClientAgentThread)thread; - Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider); - } - - #endregion - - #region Background Responses Tests - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task RunAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions) - { - // Arrange - object continuationToken = new(); - ChatOptions? capturedChatOptions = null; - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) - .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null }); - - AgentRunOptions agentRunOptions; - - if (providePropsViaChatOptions) - { - ChatOptions chatOptions = new() - { - AllowBackgroundResponses = true, - ContinuationToken = continuationToken - }; - - agentRunOptions = new ChatClientAgentRunOptions(chatOptions); - } - else - { - agentRunOptions = new AgentRunOptions() - { - AllowBackgroundResponses = true, - ContinuationToken = continuationToken - }; - } - - ChatClientAgent agent = new(mockChatClient.Object); - - ChatClientAgentThread thread = new(); - - // Act - await agent.RunAsync(thread, options: agentRunOptions); - - // Assert - Assert.NotNull(capturedChatOptions); - Assert.True(capturedChatOptions.AllowBackgroundResponses); - Assert.Same(continuationToken, capturedChatOptions.ContinuationToken); - } - - [Fact] - public async Task RunAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync() - { - // Arrange - object continuationToken1 = new(); - object continuationToken2 = new(); - ChatOptions? capturedChatOptions = null; - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) - .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null }); - - ChatOptions chatOptions = new() - { - AllowBackgroundResponses = true, - ContinuationToken = continuationToken1 - }; - - ChatClientAgentRunOptions agentRunOptions = new(chatOptions) - { - AllowBackgroundResponses = false, - ContinuationToken = continuationToken2 - }; - - ChatClientAgent agent = new(mockChatClient.Object); - - // Act - await agent.RunAsync(options: agentRunOptions); - - // Assert - Assert.NotNull(capturedChatOptions); - Assert.False(capturedChatOptions.AllowBackgroundResponses); - Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task RunStreamingAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions) - { - // Arrange - ChatResponseUpdate[] returnUpdates = - [ - new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"), - new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?"), - ]; - - object continuationToken = new(); - ChatOptions? capturedChatOptions = null; - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetStreamingResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) - .Returns(ToAsyncEnumerableAsync(returnUpdates)); - - AgentRunOptions agentRunOptions; - - if (providePropsViaChatOptions) - { - ChatOptions chatOptions = new() - { - AllowBackgroundResponses = true, - ContinuationToken = continuationToken - }; - - agentRunOptions = new ChatClientAgentRunOptions(chatOptions); - } - else - { - agentRunOptions = new AgentRunOptions() - { - AllowBackgroundResponses = true, - ContinuationToken = continuationToken - }; - } - - ChatClientAgent agent = new(mockChatClient.Object); - - ChatClientAgentThread thread = new(); - - // Act - await foreach (var _ in agent.RunStreamingAsync(thread, options: agentRunOptions)) - { - } - - // Assert - Assert.NotNull(capturedChatOptions); - - Assert.True(capturedChatOptions.AllowBackgroundResponses); - Assert.Same(continuationToken, capturedChatOptions.ContinuationToken); - } - - [Fact] - public async Task RunStreamingAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync() - { - // Arrange - ChatResponseUpdate[] returnUpdates = - [ - new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"), - ]; - - object continuationToken1 = new(); - object continuationToken2 = new(); - ChatOptions? capturedChatOptions = null; - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetStreamingResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) - .Returns(ToAsyncEnumerableAsync(returnUpdates)); - - ChatOptions chatOptions = new() - { - AllowBackgroundResponses = true, - ContinuationToken = continuationToken1 - }; - - ChatClientAgentRunOptions agentRunOptions = new(chatOptions) - { - AllowBackgroundResponses = false, - ContinuationToken = continuationToken2 - }; - - ChatClientAgent agent = new(mockChatClient.Object); - - // Act - await foreach (var _ in agent.RunStreamingAsync(options: agentRunOptions)) - { - } - - // Assert - Assert.NotNull(capturedChatOptions); - Assert.False(capturedChatOptions.AllowBackgroundResponses); - Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken); - } - - [Fact] - public async Task RunAsyncPropagatesContinuationTokenFromChatResponseToAgentRunResponseAsync() - { - // Arrange - object continuationToken = new(); - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "partial")]) { ContinuationToken = continuationToken }); - - ChatClientAgent agent = new(mockChatClient.Object); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true }); - - ChatClientAgentThread thread = new(); - - // Act - var response = await agent.RunAsync([new(ChatRole.User, "hi")], thread, options: runOptions); - - // Assert - Assert.Same(continuationToken, response.ContinuationToken); - } - - [Fact] - public async Task RunStreamingAsyncPropagatesContinuationTokensFromUpdatesAsync() - { - // Arrange - object token1 = new(); - ChatResponseUpdate[] expectedUpdates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "pa") { ContinuationToken = token1 }, - new ChatResponseUpdate(ChatRole.Assistant, "rt") { ContinuationToken = null } // terminal - ]; - - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetStreamingResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Returns(ToAsyncEnumerableAsync(expectedUpdates)); - - ChatClientAgent agent = new(mockChatClient.Object); - - ChatClientAgentThread thread = new(); - - // Act - var actualUpdates = new List(); - await foreach (var u in agent.RunStreamingAsync([new(ChatRole.User, "hi")], thread, options: new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true }))) - { - actualUpdates.Add(u); - } - - // Assert - Assert.Equal(2, actualUpdates.Count); - Assert.Same(token1, actualUpdates[0].ContinuationToken); - Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token - } - - [Fact] - public async Task RunAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync() - { - // Arrange - Mock mockChatClient = new(); - - ChatClientAgent agent = new(mockChatClient.Object); - - AgentRunOptions runOptions = new() { ContinuationToken = new() }; - - IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; - - // Act & Assert - await Assert.ThrowsAsync(() => agent.RunAsync(inputMessages, options: runOptions)); - - // Verify that the IChatClient was never called due to early validation - mockChatClient.Verify( - c => c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task RunStreamingAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync() - { - // Arrange - Mock mockChatClient = new(); - - ChatClientAgent agent = new(mockChatClient.Object); - - AgentRunOptions runOptions = new() { ContinuationToken = new() }; - - IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; - - // Act & Assert - await Assert.ThrowsAsync(async () => - { - await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions)) - { - // Should not reach here - } - }); - - // Verify that the IChatClient was never called due to early validation - mockChatClient.Verify( - c => c.GetStreamingResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync() - { - // Arrange - List capturedMessages = []; - - // Create a mock message store that would normally provide messages - var mockMessageStore = new Mock(); - mockMessageStore - .Setup(ms => ms.GetMessagesAsync(It.IsAny())) - .ReturnsAsync([new(ChatRole.User, "Message from message store")]); - - // Create a mock AI context provider that would normally provide context - var mockContextProvider = new Mock(); - mockContextProvider - .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AIContext - { - Messages = [new(ChatRole.System, "Message from AI context")], - Instructions = "context instructions" - }); - - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => - capturedMessages.AddRange(msgs)) - .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "continued response")])); - - ChatClientAgent agent = new(mockChatClient.Object); - - // Create a thread with both message store and AI context provider - ChatClientAgentThread thread = new() - { - MessageStore = mockMessageStore.Object, - AIContextProvider = mockContextProvider.Object - }; - - AgentRunOptions runOptions = new() { ContinuationToken = new() }; - - // Act - await agent.RunAsync([], thread, options: runOptions); - - // Assert - - // With continuation token, thread message population should be skipped - Assert.Empty(capturedMessages); - - // Verify that message store was never called due to continuation token - mockMessageStore.Verify( - ms => ms.GetMessagesAsync(It.IsAny()), - Times.Never); - - // Verify that AI context provider was never called due to continuation token - mockContextProvider.Verify( - p => p.InvokingAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync() - { - // Arrange - List capturedMessages = []; - - // Create a mock message store that would normally provide messages - var mockMessageStore = new Mock(); - mockMessageStore - .Setup(ms => ms.GetMessagesAsync(It.IsAny())) - .ReturnsAsync([new(ChatRole.User, "Message from message store")]); - - // Create a mock AI context provider that would normally provide context - var mockContextProvider = new Mock(); - mockContextProvider - .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AIContext - { - Messages = [new(ChatRole.System, "Message from AI context")], - Instructions = "context instructions" - }); - - Mock mockChatClient = new(); - mockChatClient - .Setup(c => c.GetStreamingResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => - capturedMessages.AddRange(msgs)) - .Returns(ToAsyncEnumerableAsync([new ChatResponseUpdate(role: ChatRole.Assistant, content: "continued response")])); - - ChatClientAgent agent = new(mockChatClient.Object); - - // Create a thread with both message store and AI context provider - ChatClientAgentThread thread = new() - { - MessageStore = mockMessageStore.Object, - AIContextProvider = mockContextProvider.Object - }; - - AgentRunOptions runOptions = new() { ContinuationToken = new() }; - - // Act - await agent.RunStreamingAsync([], thread, options: runOptions).ToListAsync(); - - // Assert - - // With continuation token, thread message population should be skipped - Assert.Empty(capturedMessages); - - // Verify that message store was never called due to continuation token - mockMessageStore.Verify( - ms => ms.GetMessagesAsync(It.IsAny()), - Times.Never); - - // Verify that AI context provider was never called due to continuation token - mockContextProvider.Verify( - p => p.InvokingAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task RunAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync() - { - // Arrange - Mock mockChatClient = new(); - - ChatClientAgent agent = new(mockChatClient.Object); - - AgentRunOptions runOptions = new() { AllowBackgroundResponses = true }; - - IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; - - // Act & Assert - await Assert.ThrowsAsync(() => agent.RunAsync(inputMessages, options: runOptions)); - - // Verify that the IChatClient was never called due to early validation - mockChatClient.Verify( - c => c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny()), - Times.Never); - } - - [Fact] - public async Task RunStreamingAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync() - { - // Arrange - Mock mockChatClient = new(); - - ChatClientAgent agent = new(mockChatClient.Object); - - AgentRunOptions runOptions = new() { AllowBackgroundResponses = true }; - - IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; - - // Act & Assert - await Assert.ThrowsAsync(async () => - { - await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions)) - { - // Should not reach here - } - }); - - // Verify that the IChatClient was never called due to early validation - mockChatClient.Verify( - c => c.GetStreamingResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny()), - Times.Never); - } - - #endregion - private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) { await Task.Yield(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentThreadTests.cs index 8226e697ca1..48caef1b3dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentThreadTests.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; @@ -91,50 +90,6 @@ public void SetChatMessageStoreThrowsWhenConversationIdIsSet() #endregion Constructor and Property Tests - #region OnNewMessagesAsync Tests - - [Fact] - public async Task OnNewMessagesAsyncDoesNothingWhenAgentServiceIdAsync() - { - // Arrange - var thread = new ChatClientAgentThread { ConversationId = "thread-123" }; - var messages = new List - { - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there!") - }; - var agent = new MessageSendingAgent(); - - // Act - await agent.SendMessagesAsync(thread, messages, CancellationToken.None); - Assert.Equal("thread-123", thread.ConversationId); - Assert.Null(thread.MessageStore); - } - - [Fact] - public async Task OnNewMessagesAsyncAddsMessagesToStoreAsync() - { - // Arrange - var store = new InMemoryChatMessageStore(); - var thread = new ChatClientAgentThread { MessageStore = store }; - var messages = new List - { - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there!") - }; - var agent = new MessageSendingAgent(); - - // Act - await agent.SendMessagesAsync(thread, messages, CancellationToken.None); - - // Assert - Assert.Equal(2, store.Count); - Assert.Equal("Hello", store[0].Text); - Assert.Equal("Hi there!", store[1].Text); - } - - #endregion OnNewMessagesAsync Tests - #region Deserialize Tests [Fact] @@ -372,22 +327,4 @@ public void GetService_RequestingChatMessageStore_ReturnsChatMessageStore() } #endregion - - private sealed class MessageSendingAgent : AIAgent - { - public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) - => throw new NotImplementedException(); - - public override AgentThread GetNewThread() - => throw new NotImplementedException(); - - public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task SendMessagesAsync(AgentThread thread, IEnumerable messages, CancellationToken cancellationToken = default) - => NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken); - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs new file mode 100644 index 00000000000..583a0815ca1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs @@ -0,0 +1,643 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests for ChatClientAgent background responses functionality. +/// +public class ChatClientAgent_BackgroundResponsesTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RunAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions) + { + // Arrange + var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + ChatOptions? capturedChatOptions = null; + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" }); + + AgentRunOptions agentRunOptions; + + if (providePropsViaChatOptions) + { + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken + }; + + agentRunOptions = new ChatClientAgentRunOptions(chatOptions); + } + else + { + agentRunOptions = new AgentRunOptions() + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken + }; + } + + ChatClientAgent agent = new(mockChatClient.Object); + + ChatClientAgentThread thread = new() { ConversationId = "conversation-id" }; + + // Act + await agent.RunAsync(thread, options: agentRunOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.True(capturedChatOptions.AllowBackgroundResponses); + Assert.Same(continuationToken, capturedChatOptions.ContinuationToken); + } + + [Fact] + public async Task RunAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync() + { + // Arrange + var continuationToken1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + var continuationToken2 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + ChatOptions? capturedChatOptions = null; + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" }); + + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken1 + }; + + ChatClientAgentRunOptions agentRunOptions = new(chatOptions) + { + AllowBackgroundResponses = false, + ContinuationToken = continuationToken2 + }; + + ChatClientAgentThread thread = new() { ConversationId = "conversation-id" }; + + ChatClientAgent agent = new(mockChatClient.Object); + + // Act + await agent.RunAsync(thread, options: agentRunOptions); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.False(capturedChatOptions.AllowBackgroundResponses); + Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RunStreamingAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions) + { + // Arrange + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" }, + new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?") { ConversationId = "conversation-id" }, + ]; + + var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + ChatOptions? capturedChatOptions = null; + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) + .Returns(ToAsyncEnumerableAsync(returnUpdates)); + + AgentRunOptions agentRunOptions; + + if (providePropsViaChatOptions) + { + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken + }; + + agentRunOptions = new ChatClientAgentRunOptions(chatOptions); + } + else + { + agentRunOptions = new AgentRunOptions() + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken + }; + } + + ChatClientAgent agent = new(mockChatClient.Object); + + ChatClientAgentThread thread = new() { ConversationId = "conversation-id" }; + + // Act + await foreach (var _ in agent.RunStreamingAsync(thread, options: agentRunOptions)) + { + } + + // Assert + Assert.NotNull(capturedChatOptions); + + Assert.True(capturedChatOptions.AllowBackgroundResponses); + Assert.Same(continuationToken, capturedChatOptions.ContinuationToken); + } + + [Fact] + public async Task RunStreamingAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" }, + ]; + + var continuationToken1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + var continuationToken2 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + ChatOptions? capturedChatOptions = null; + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co) + .Returns(ToAsyncEnumerableAsync(returnUpdates)); + + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken1 + }; + + ChatClientAgentRunOptions agentRunOptions = new(chatOptions) + { + AllowBackgroundResponses = false, + ContinuationToken = continuationToken2 + }; + + ChatClientAgent agent = new(mockChatClient.Object); + + var thread = new ChatClientAgentThread() { ConversationId = "conversation-id" }; + + // Act + await foreach (var _ in agent.RunStreamingAsync(thread, options: agentRunOptions)) + { + } + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.False(capturedChatOptions.AllowBackgroundResponses); + Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken); + } + + [Fact] + public async Task RunAsyncPropagatesContinuationTokenFromChatResponseToAgentRunResponseAsync() + { + // Arrange + var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "partial")]) { ContinuationToken = continuationToken }); + + ChatClientAgent agent = new(mockChatClient.Object); + var runOptions = new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true }); + + ChatClientAgentThread thread = new(); + + // Act + var response = await agent.RunAsync([new(ChatRole.User, "hi")], thread, options: runOptions); + + // Assert + Assert.Same(continuationToken, response.ContinuationToken); + } + + [Fact] + public async Task RunStreamingAsyncPropagatesContinuationTokensFromUpdatesAsync() + { + // Arrange + var token1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + ChatResponseUpdate[] expectedUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "pa") { ContinuationToken = token1 }, + new ChatResponseUpdate(ChatRole.Assistant, "rt") { ContinuationToken = null } // terminal + ]; + + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(expectedUpdates)); + + ChatClientAgent agent = new(mockChatClient.Object); + + ChatClientAgentThread thread = new(); + + // Act + var actualUpdates = new List(); + await foreach (var u in agent.RunStreamingAsync([new(ChatRole.User, "hi")], thread, options: new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true }))) + { + actualUpdates.Add(u); + } + + // Assert + Assert.Equal(2, actualUpdates.Count); + Assert.Same(token1, actualUpdates[0].ContinuationToken); + Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token + } + + [Fact] + public async Task RunAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; + + // Act & Assert + await Assert.ThrowsAsync(() => agent.RunAsync(inputMessages, options: runOptions)); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunStreamingAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions)) + { + // Should not reach here + } + }); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync() + { + // Arrange + List capturedMessages = []; + + // Create a mock message store that would normally provide messages + var mockMessageStore = new Mock(); + mockMessageStore + .Setup(ms => ms.GetMessagesAsync(It.IsAny())) + .ReturnsAsync([new(ChatRole.User, "Message from message store")]); + + // Create a mock AI context provider that would normally provide context + var mockContextProvider = new Mock(); + mockContextProvider + .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AIContext + { + Messages = [new(ChatRole.System, "Message from AI context")], + Instructions = "context instructions" + }); + + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedMessages.AddRange(msgs)) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "continued response")])); + + ChatClientAgent agent = new(mockChatClient.Object); + + // Create a thread with both message store and AI context provider + ChatClientAgentThread thread = new() + { + MessageStore = mockMessageStore.Object, + AIContextProvider = mockContextProvider.Object + }; + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + // Act + await agent.RunAsync([], thread, options: runOptions); + + // Assert + + // With continuation token, thread message population should be skipped + Assert.Empty(capturedMessages); + + // Verify that message store was never called due to continuation token + mockMessageStore.Verify( + ms => ms.GetMessagesAsync(It.IsAny()), + Times.Never); + + // Verify that AI context provider was never called due to continuation token + mockContextProvider.Verify( + p => p.InvokingAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync() + { + // Arrange + List capturedMessages = []; + + // Create a mock message store that would normally provide messages + var mockMessageStore = new Mock(); + mockMessageStore + .Setup(ms => ms.GetMessagesAsync(It.IsAny())) + .ReturnsAsync([new(ChatRole.User, "Message from message store")]); + + // Create a mock AI context provider that would normally provide context + var mockContextProvider = new Mock(); + mockContextProvider + .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AIContext + { + Messages = [new(ChatRole.System, "Message from AI context")], + Instructions = "context instructions" + }); + + Mock mockChatClient = new(); + mockChatClient + .Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedMessages.AddRange(msgs)) + .Returns(ToAsyncEnumerableAsync([new ChatResponseUpdate(role: ChatRole.Assistant, content: "continued response")])); + + ChatClientAgent agent = new(mockChatClient.Object); + + // Create a thread with both message store and AI context provider + ChatClientAgentThread thread = new() + { + MessageStore = mockMessageStore.Object, + AIContextProvider = mockContextProvider.Object + }; + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + // Act + var exception = await Assert.ThrowsAsync(async () => await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync()); + + // Assert + Assert.Equal("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.", exception.Message); + + // With continuation token, thread message population should be skipped + Assert.Empty(capturedMessages); + + // Verify that message store was never called due to continuation token + mockMessageStore.Verify( + ms => ms.GetMessagesAsync(It.IsAny()), + Times.Never); + + // Verify that AI context provider was never called due to continuation token + mockContextProvider.Verify( + p => p.InvokingAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + AgentRunOptions runOptions = new() { AllowBackgroundResponses = true }; + + IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; + + // Act & Assert + await Assert.ThrowsAsync(() => agent.RunAsync(inputMessages, options: runOptions)); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunStreamingAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + AgentRunOptions runOptions = new() { AllowBackgroundResponses = true }; + + IEnumerable inputMessages = [new ChatMessage(ChatRole.User, "test message")]; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions)) + { + // Should not reach here + } + }); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunAsyncThrowsWhenContinuationTokenProvidedForInitialRunAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + // Create a new thread with no ConversationId and no MessageStore (initial run state) + ChatClientAgentThread thread = new(); + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => agent.RunAsync(thread: thread, options: runOptions)); + Assert.Equal("Continuation tokens are not allowed to be used for initial runs.", exception.Message); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunStreamingAsyncThrowsWhenContinuationTokenProvidedForInitialRunAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + // Create a new thread with no ConversationId and no MessageStore (initial run state) + ChatClientAgentThread thread = new(); + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync()); + Assert.Equal("Continuation tokens are not allowed to be used for initial runs.", exception.Message); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunStreamingAsyncThrowsWhenContinuationTokenUsedWithClientSideManagedChatHistoryAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + // Create a thread with a MessageStore + ChatClientAgentThread thread = new() + { + MessageStore = new InMemoryChatMessageStore(), // Setting a message store to skip checking the continuation token in the initial run + ConversationId = null, // No conversation ID to simulate client-side managed chat history + }; + + // Create run options with a continuation token + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync()); + Assert.Equal("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.", exception.Message); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunStreamingAsyncThrowsWhenContinuationTokenUsedWithAIContextProviderAsync() + { + // Arrange + Mock mockChatClient = new(); + + ChatClientAgent agent = new(mockChatClient.Object); + + // Create a mock AIContextProvider + var mockContextProvider = new Mock(); + mockContextProvider + .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AIContext()); + mockContextProvider + .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask()); + + // Create a thread with an AIContextProvider and conversation ID to simulate non-initial run + ChatClientAgentThread thread = new() + { + ConversationId = "existing-conversation-id", + AIContextProvider = mockContextProvider.Object + }; + + AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync()); + + Assert.Equal("Using context provider with streaming resumption is not supported.", exception.Message); + + // Verify that the IChatClient was never called due to early validation + mockChatClient.Verify( + c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) + { + await Task.Yield(); + foreach (var update in values) + { + yield return update; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs new file mode 100644 index 00000000000..04eabf36af4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.ChatClient; + +/// +/// Contains unit tests for the ChatClientAgent.DeserializeThread methods. +/// +public class ChatClientAgent_DeserializeThreadTests +{ + [Fact] + public void DeserializeThread_UsesAIContextProviderFactory_IfProvided() + { + // Arrange + var mockChatClient = new Mock(); + var mockContextProvider = new Mock(); + var factoryCalled = false; + var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + ChatOptions = new() { Instructions = "Test instructions" }, + AIContextProviderFactory = _ => + { + factoryCalled = true; + return mockContextProvider.Object; + } + }); + + var json = JsonSerializer.Deserialize(""" + { + "aiContextProviderState": ["CP1"] + } + """, TestJsonSerializerContext.Default.JsonElement); + + // Act + var thread = agent.DeserializeThread(json); + + // Assert + Assert.True(factoryCalled, "AIContextProviderFactory was not called."); + Assert.IsType(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider); + } + + [Fact] + public void DeserializeThread_UsesChatMessageStoreFactory_IfProvided() + { + // Arrange + var mockChatClient = new Mock(); + var mockMessageStore = new Mock(); + var factoryCalled = false; + var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + ChatOptions = new() { Instructions = "Test instructions" }, + ChatMessageStoreFactory = _ => + { + factoryCalled = true; + return mockMessageStore.Object; + } + }); + + var json = JsonSerializer.Deserialize(""" + { + "storeState": { } + } + """, TestJsonSerializerContext.Default.JsonElement); + + // Act + var thread = agent.DeserializeThread(json); + + // Assert + Assert.True(factoryCalled, "ChatMessageStoreFactory was not called."); + Assert.IsType(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Same(mockMessageStore.Object, typedThread.MessageStore); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs new file mode 100644 index 00000000000..628d738e720 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.ChatClient; + +/// +/// Contains unit tests for the ChatClientAgent.GetNewThread methods. +/// +public class ChatClientAgent_GetNewThreadTests +{ + [Fact] + public void GetNewThread_UsesAIContextProviderFactory_IfProvided() + { + // Arrange + var mockChatClient = new Mock(); + var mockContextProvider = new Mock(); + var factoryCalled = false; + var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + ChatOptions = new() { Instructions = "Test instructions" }, + AIContextProviderFactory = _ => + { + factoryCalled = true; + return mockContextProvider.Object; + } + }); + + // Act + var thread = agent.GetNewThread(); + + // Assert + Assert.True(factoryCalled, "AIContextProviderFactory was not called."); + Assert.IsType(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider); + } + + [Fact] + public void GetNewThread_UsesChatMessageStoreFactory_IfProvided() + { + // Arrange + var mockChatClient = new Mock(); + var mockMessageStore = new Mock(); + var factoryCalled = false; + var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + ChatOptions = new() { Instructions = "Test instructions" }, + ChatMessageStoreFactory = _ => + { + factoryCalled = true; + return mockMessageStore.Object; + } + }); + + // Act + var thread = agent.GetNewThread(); + + // Assert + Assert.True(factoryCalled, "ChatMessageStoreFactory was not called."); + Assert.IsType(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Same(mockMessageStore.Object, typedThread.MessageStore); + } + + [Fact] + public void GetNewThread_UsesChatMessageStore_FromTypedOverload() + { + // Arrange + var mockChatClient = new Mock(); + var mockMessageStore = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object); + + // Act + var thread = agent.GetNewThread(mockMessageStore.Object); + + // Assert + Assert.IsType(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Same(mockMessageStore.Object, typedThread.MessageStore); + } + + [Fact] + public void GetNewThread_UsesConversationId_FromTypedOverload() + { + // Arrange + var mockChatClient = new Mock(); + const string TestConversationId = "test_conversation_id"; + var agent = new ChatClientAgent(mockChatClient.Object); + + // Act + var thread = agent.GetNewThread(TestConversationId); + + // Assert + Assert.IsType(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Equal(TestConversationId, typedThread.ConversationId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs index 38773586448..3407f172a22 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs @@ -90,7 +90,7 @@ public void BuildAIAgent_WithOptions_CreatesAgentWithOptions() { Name = "AgentWithOptions", Description = "Desc", - Instructions = "Instr", + ChatOptions = new() { Instructions = "Instr" }, UseProvidedChatClientAsIs = true }; @@ -115,7 +115,7 @@ public void BuildAIAgent_WithOptionsAndServices_CreatesAgentCorrectly() var options = new ChatClientAgentOptions { Name = "ServiceAgent", - Instructions = "Service instructions" + ChatOptions = new() { Instructions = "Service instructions" } }; // Act @@ -148,7 +148,7 @@ public void BuildAIAgent_WithNullBuilderAndOptions_Throws() ChatClientBuilder builder = null!; // Act & Assert - Assert.Throws(() => builder.BuildAIAgent(options: new() { Instructions = "instructions" })); + Assert.Throws(() => builder.BuildAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } })); } [Fact] @@ -166,7 +166,7 @@ public void BuildAIAgent_WithMiddleware_BuildsCorrectPipeline() var agent = builder.BuildAIAgent( new ChatClientAgentOptions { - Instructions = "Middleware test", + ChatOptions = new() { Instructions = "Middleware test" }, UseProvidedChatClientAsIs = true } ); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs index 182de0be5b8..51beb6aa2e7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientExtensionsTests.cs @@ -57,7 +57,7 @@ public void CreateAIAgent_WithOptions_CreatesAgentWithOptions() { Name = "AgentWithOptions", Description = "Desc", - Instructions = "Instr", + ChatOptions = new() { Instructions = "Instr" }, UseProvidedChatClientAsIs = true }; @@ -89,6 +89,6 @@ public void CreateAIAgent_WithNullClientAndOptions_Throws() IChatClient chatClient = null!; // Act & Assert - Assert.Throws(() => chatClient.CreateAIAgent(options: new() { Instructions = "instructions" })); + Assert.Throws(() => chatClient.CreateAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } })); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index be1e901499c..b32211e8836 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -60,11 +60,11 @@ public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overri }; var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null); - var invokingContext = new AIContextProvider.InvokingContext(new[] - { + var invokingContext = new AIContextProvider.InvokingContext( + [ new ChatMessage(ChatRole.User, "Sample user question?"), new ChatMessage(ChatRole.User, "Additional part") - }); + ]); // Act var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None); @@ -441,7 +441,7 @@ public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRoles { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, RecentMessageMemoryLimit = 4, - RecentMessageRolesIncluded = new List { ChatRole.Assistant } // Only retain assistant messages. + RecentMessageRolesIncluded = [ChatRole.Assistant] // Only retain assistant messages. }; string? capturedInput = null; Task> SearchDelegateAsync(string input, CancellationToken ct) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs index 49e5a5d29c1..860867f8a2a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -214,6 +215,56 @@ public async Task InvokedAsync_DoesNotThrow_WhenUpsertThrowsAsync() Times.Once); } + [Theory] + [InlineData(false, false, 0)] + [InlineData(true, false, 0)] + [InlineData(false, true, 1)] + [InlineData(true, true, 1)] + public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + { + // Arrange + var options = new ChatHistoryMemoryProviderOptions + { + EnableSensitiveTelemetryData = enableSensitiveTelemetryData + }; + + if (requestThrows) + { + this._vectorStoreCollectionMock + .Setup(c => c.UpsertAsync(It.IsAny>>(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Upsert failed")); + } + else + { + this._vectorStoreCollectionMock + .Setup(c => c.UpsertAsync(It.IsAny>>(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + new ChatHistoryMemoryProviderScope { UserId = "user1" }, + options: options, + loggerFactory: this._loggerFactoryMock.Object); + + var requestMsg = new ChatMessage(ChatRole.User, "request text"); + var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null); + + // Act + await provider.InvokedAsync(invokedContext, CancellationToken.None); + + // Assert + Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); + foreach (var logInvocation in this._loggerMock.Invocations) + { + var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); + var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; + Assert.Equal(enableSensitiveTelemetryData ? "user1" : "", userIdValue); + } + } + #endregion #region InvokingAsync Tests @@ -333,6 +384,82 @@ public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync() Times.Once); } + [Theory] + [InlineData(false, false, 1)] + [InlineData(true, false, 1)] + [InlineData(false, true, 1)] + [InlineData(true, true, 1)] + public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + { + // Arrange + var options = new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke, + EnableSensitiveTelemetryData = enableSensitiveTelemetryData + }; + + var scope = new ChatHistoryMemoryProviderScope + { + UserId = "user1" + }; + + if (requestThrows) + { + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Throws(new InvalidOperationException("Search failed")); + } + else + { + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(new List>>())); + } + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + storageScope: scope, + searchScope: scope, + options: options, + loggerFactory: this._loggerFactoryMock.Object); + + var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "requesting relevant history")]); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); + foreach (var logInvocation in this._loggerMock.Invocations) + { + var state = Assert.IsAssignableFrom>>(logInvocation.Arguments[2]); + var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; + Assert.Equal(enableSensitiveTelemetryData ? "user1" : "", userIdValue); + + var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value; + if (inputValue != null) + { + Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "", inputValue); + } + + var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value; + if (messageTextValue != null) + { + Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "", messageTextValue); + } + } + } + #endregion #region Serialization Tests diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj index f871781d03d..7e25c9ae0ff 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj @@ -1,9 +1,5 @@ - - $(ProjectsTargetFrameworks) - - false @@ -17,7 +13,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index b525749b6cc..cf17694ccbf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -19,9 +19,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; /// public abstract class IntegrationTest : IDisposable { - private IConfigurationRoot? _configuration; - - protected IConfigurationRoot Configuration => this._configuration ??= InitializeConfig(); + protected IConfigurationRoot Configuration => field ??= InitializeConfig(); public Uri TestEndpoint { get; } @@ -32,7 +30,7 @@ protected IntegrationTest(ITestOutputHelper output) this.Output = new TestOutputAdapter(output); this.TestEndpoint = new Uri( - this.Configuration[AgentProvider.Settings.FoundryEndpoint] ?? + this.Configuration?[AgentProvider.Settings.FoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {AgentProvider.Settings.FoundryEndpoint}")); Console.SetOut(this.Output); SetProduct(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs index 3238c59b548..63e052481a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/FunctionCallingWorkflowTest.cs @@ -7,7 +7,6 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Microsoft.Agents.AI.Workflows.Declarative.Kit; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 9e86f4250a6..985086a56ed 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -1,9 +1,5 @@  - - $(ProjectsTargetFrameworks) - - true true @@ -24,7 +20,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs index d7610c33124..54343f042a7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs @@ -107,7 +107,7 @@ public void ConvertJson() private static void VerifyConversion(object? sourceValue, VariableType targetType, object? expectedValue) { object? actualValue = sourceValue.ConvertType(targetType); - if (expectedValue is IDictionary || expectedValue is DateTime) + if (expectedValue is IDictionary or DateTime) { Assert.Equivalent(expectedValue, actualValue); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj index 491ec957782..594c0b3857a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj @@ -1,9 +1,5 @@  - - $(ProjectsTargetFrameworks) - - true true @@ -18,7 +14,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs index 8a2e76415a5..5a55dd297ab 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -16,18 +17,35 @@ internal sealed class MockAgentProvider : Mock { public IList ExistingConversationIds { get; } = []; - public ChatMessage? TestChatMessage { get; set; } + public List? TestMessages { get; set; } public MockAgentProvider() { this.Setup(provider => provider.CreateConversationAsync(It.IsAny())) .Returns(() => Task.FromResult(this.CreateConversationId())); + List testMessages = this.CreateMessages(); this.Setup(provider => provider.GetMessageAsync( It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(this.CreateChatMessage())); + .Returns(Task.FromResult(testMessages.First())); + + // Setup GetMessagesAsync to return test messages + this.Setup(provider => provider.GetMessagesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(testMessages)); + + this.Setup(provider => provider.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult(testMessages.First())); } private string CreateConversationId() @@ -38,12 +56,27 @@ private string CreateConversationId() return newConversationId; } - private ChatMessage CreateChatMessage() + private List CreateMessages() { - this.TestChatMessage = new ChatMessage(ChatRole.User, Guid.NewGuid().ToString("N")) + // Create test messages + List messages = []; + const int MessageCount = 5; + for (int i = 0; i < MessageCount; i++) { - MessageId = Guid.NewGuid().ToString("N"), - }; - return this.TestChatMessage; + messages.Add(new ChatMessage(ChatRole.User, $"Test message {i + 1}") { MessageId = Guid.NewGuid().ToString("N") }); + } + this.TestMessages = messages; + + return this.TestMessages; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable messages) + { + foreach (ChatMessage message in messages) + { + yield return message; + } + + await Task.CompletedTask; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs new file mode 100644 index 00000000000..ec0531711df --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Theory] + [InlineData(AgentMessageRole.User)] + [InlineData(AgentMessageRole.Agent)] + public async Task AddMessageSuccessfullyAsync(AgentMessageRole role) + { + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(AddMessageSuccessfullyAsync), + variableName: "TestMessage", + role: AgentMessageRoleWrapper.Get(role), + messageText: $"Hello from {role}"); + } + + private async Task ExecuteTestAsync( + string displayName, + string variableName, + AgentMessageRoleWrapper role, + string messageText) + { + // Arrange + MockAgentProvider mockAgentProvider = new(); + AddConversationMessage model = this.CreateModel( + this.FormatDisplayName(displayName), + FormatVariablePath(variableName), + "TestConversationId", + role, + messageText); + + AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + ChatMessage? testMessage = mockAgentProvider.TestMessages?.FirstOrDefault(); + Assert.NotNull(testMessage); + VerifyModel(model, action); + this.VerifyState(variableName, testMessage.ToRecord()); + } + + private AddConversationMessage CreateModel( + string displayName, + string messageVariable, + string conversationId, + AgentMessageRoleWrapper role, + string messageText) + { + AddConversationMessage.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Message = PropertyPath.Create(messageVariable), + ConversationId = StringExpression.Literal(conversationId), + Role = role, + }; + + actionBuilder.Content.Add(new AddConversationMessageContent.Builder + { + Type = AgentMessageContentType.Text, + Value = TemplateLine.Parse(messageText) + }); + + return AssignParent(actionBuilder); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessageExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessageExecutorTest.cs index 3f9fa0d6067..04fcd81a3cc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessageExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessageExecutorTest.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -41,7 +42,8 @@ private async Task ExecuteTestAsync( await this.ExecuteAsync(action); // Assert - ChatMessage testMessage = mockAgentProvider.TestChatMessage ?? new ChatMessage(); + ChatMessage? testMessage = mockAgentProvider.TestMessages?.FirstOrDefault(); + Assert.NotNull(testMessage); VerifyModel(model, action); this.VerifyState(variableName, testMessage.ToRecord()); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessagesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessagesExecutorTest.cs new file mode 100644 index 00000000000..6c287a911b4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RetrieveConversationMessagesExecutorTest.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Bot.ObjectModel; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class RetrieveConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task RetrieveAllMessagesSuccessfullyAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync( + nameof(RetrieveAllMessagesSuccessfullyAsync), + "TestMessages", + "TestConversationId"); + } + + [Fact] + public async Task RetrieveMessagesWithOptionalValuesAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync( + nameof(RetrieveMessagesWithOptionalValuesAsync), + "TestMessages", + "TestConversationId", + limit: IntExpression.Literal(2), + after: StringExpression.Literal("11/01/2025"), + before: StringExpression.Literal("12/01/2025"), + sortOrder: EnumExpression.Literal(AgentMessageSortOrderWrapper.Get(AgentMessageSortOrder.NewestFirst))); + } + + private async Task ExecuteTestAsync( + string displayName, + string variableName, + string conversationId, + IntExpression? limit = null, + StringExpression? after = null, + StringExpression? before = null, + EnumExpression? sortOrder = null) + { + // Arrange + MockAgentProvider mockAgentProvider = new(); + + RetrieveConversationMessages model = this.CreateModel( + this.FormatDisplayName(displayName), + FormatVariablePath(variableName), + conversationId, + limit, + after, + before, + sortOrder); + + RetrieveConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + var testMessages = mockAgentProvider.TestMessages; + Assert.NotNull(testMessages); + VerifyModel(model, action); + this.VerifyState(variableName, testMessages.ToTable()); + } + + private RetrieveConversationMessages CreateModel( + string displayName, + string variableName, + string conversationId, + IntExpression? limit, + StringExpression? after, + StringExpression? before, + EnumExpression? sortOrder) + { + RetrieveConversationMessages.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Messages = PropertyPath.Create(variableName), + ConversationId = StringExpression.Literal(conversationId) + }; + + if (limit is not null) + { + actionBuilder.Limit = limit; + } + + if (after is not null) + { + actionBuilder.MessageAfter = after; + } + + if (before is not null) + { + actionBuilder.MessageBefore = before; + } + + if (sortOrder is not null) + { + actionBuilder.SortOrder = sortOrder; + } + + return AssignParent(actionBuilder); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/UpdateBaseline.ps1 b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/UpdateBaseline.ps1 index 56359529aa8..6f6d461884d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/UpdateBaseline.ps1 +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/UpdateBaseline.ps1 @@ -1,7 +1,7 @@ -$generatedCodeFiles = Get-ChildItem -Name -Path .\bin\Debug\net9.0\Workflows -Filter *.g.cs +$generatedCodeFiles = Get-ChildItem -Name -Path .\bin\Debug\net10.0\Workflows -Filter *.g.cs Write-Output "x$($generatedCodeFiles.Count)" foreach ($file in $generatedCodeFiles) { $baselineFile = $file -replace '\.g\.cs$', '.cs' Write-Output $baselineFile - Copy-Item -Path ".\bin\Debug\net9.0\Workflows\$file" -Destination ".\Workflows\$baselineFile" -Force + Copy-Item -Path ".\bin\Debug\net10.0\Workflows\$file" -Destination ".\Workflows\$baselineFile" -Force } \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs index cffdb8c73c3..e134f10aa7b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs @@ -111,7 +111,7 @@ public async Task RunAsyncAndStreamAsyncShouldProduceSimilarResultsAsync() await using StreamingRun streamingRun = await InProcessExecution.StreamAsync(workflow2, new List { inputMessage }); await streamingRun.TrySendMessageAsync(new TurnToken(emitEvents: true)); - List streamingEvents = new(); + List streamingEvents = []; await foreach (WorkflowEvent evt in streamingRun.WatchStreamAsync()) { streamingEvents.Add(evt); @@ -137,14 +137,12 @@ public async Task RunAsyncAndStreamAsyncShouldProduceSimilarResultsAsync() /// private sealed class SimpleTestAgent : AIAgent { - private readonly string _name; - public SimpleTestAgent(string name) { - this._name = name; + this.Name = name; } - public override string Name => this._name; + public override string Name { get; } public override AgentThread GetNewThread() => new SimpleTestAgentThread(); @@ -176,16 +174,16 @@ public override async IAsyncEnumerable RunStreamingAsync string messageId = Guid.NewGuid().ToString("N"); // Yield role first - yield return new AgentRunResponseUpdate(ChatRole.Assistant, this._name) + yield return new AgentRunResponseUpdate(ChatRole.Assistant, this.Name) { - AuthorName = this._name, + AuthorName = this.Name, MessageId = messageId }; // Then yield content yield return new AgentRunResponseUpdate(ChatRole.Assistant, responseText) { - AuthorName = this._name, + AuthorName = this.Name, MessageId = messageId }; } @@ -194,7 +192,5 @@ public override async IAsyncEnumerable RunStreamingAsync /// /// Simple thread implementation for SimpleTestAgent. /// - private sealed class SimpleTestAgentThread : InMemoryAgentThread - { - } + private sealed class SimpleTestAgentThread : InMemoryAgentThread; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs index 014c51b3c04..0ecd6bfac1f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs @@ -145,7 +145,7 @@ public async Task InProcessRun_StateShouldPersist_CheckpointedAsync() [Fact] public async Task InProcessRun_StateShouldError_TwoExecutorsAsync() { - ForwardMessageExecutor forward = new(nameof(ForwardMessageExecutor)); + ForwardMessageExecutor forward = new(nameof(ForwardMessageExecutor<>)); using StateTestExecutor testExecutor = new( new ScopeKey("StateTestExecutor", "TestScope", "TestKey"), loop: false, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj index bd9bc579159..60dac38ecd7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj @@ -1,7 +1,6 @@  - $(ProjectsTargetFrameworks) $(NoWarn);MEAI001 @@ -13,7 +12,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 7101ad13d49..8ab6280b462 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -33,7 +33,7 @@ public ObservabilityTests() this._activityListener = new ActivityListener { ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!), - Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + Sample = (ref options) => ActivitySamplingResult.AllData, ActivityStarted = activity => this._capturedActivities.Add(activity), }; ActivitySource.AddActivityListener(this._activityListener); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs index 5027028387d..ccf3f7bc8bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -35,7 +35,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandler() : BaseTestExecutor>(nameof(TypedHandler)), IMessageHandler +public class TypedHandler() : BaseTestExecutor>(nameof(TypedHandler<>)), IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default) { @@ -50,7 +50,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandlerWithOutput() : BaseTestExecutor>(nameof(TypedHandlerWithOutput)), IMessageHandler +public class TypedHandlerWithOutput() : BaseTestExecutor>(nameof(TypedHandlerWithOutput<,>)), IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs index 58372103f49..98f46cf5517 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs @@ -81,8 +81,8 @@ private sealed class TextProcessingOrchestrator(string id) { internal sealed class State { - public List Results { get; } = new(); - public HashSet PendingTaskIds { get; } = new(); + public List Results { get; } = []; + public HashSet PendingTaskIds { get; } = []; public bool IsComplete => this.PendingTaskIds.Count == 0; @@ -102,7 +102,7 @@ private async ValueTask StartProcessingAsync(List texts, IWorkflowContex async ValueTask QueueProcessingTasksAsync(State state, IWorkflowContext context, CancellationToken cancellationToken) { - foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}"))) + foreach (TextProcessingRequest request in texts.Select((value, index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}"))) { state.PendingTaskIds.Add(request.TaskId); await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs index 9173304a576..56c7f0a1574 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs @@ -79,7 +79,7 @@ public static WorkflowBuilder AddExternalRequest(this Workf public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorBinding source, out RequestPort inputPort, string? id = null) { - id = id ?? $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]"; + id ??= $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]"; inputPort = RequestPort.Create(id); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/10_Sequential_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/10_Sequential_HostAsAgent.cs index 4670f8b9314..fc23d441550 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/10_Sequential_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/10_Sequential_HostAsAgent.cs @@ -25,7 +25,7 @@ public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvi foreach (string input in inputs) { AgentRunResponse response; - object? continuationToken = null; + ResponseContinuationToken? continuationToken = null; do { response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/11_Concurrent_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/11_Concurrent_HostAsAgent.cs index ca2ba464054..d47b90223c8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/11_Concurrent_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/11_Concurrent_HostAsAgent.cs @@ -37,7 +37,7 @@ public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvi foreach (string input in inputs) { AgentRunResponse response; - object? continuationToken = null; + ResponseContinuationToken? continuationToken = null; do { response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs index 8eb553b868e..824a75d5d01 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs @@ -73,7 +73,7 @@ public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvi foreach (string input in inputs) { AgentRunResponse response; - object? continuationToken = null; + ResponseContinuationToken? continuationToken = null; do { response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs index 13c21025fa9..2d81a2ef53a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs @@ -538,7 +538,7 @@ public async Task Test_LoadPortableValueState_AfterSerializationAsync() Dictionary exportedState = await manager.ExportStateAsync(); Dictionary serializedState = JsonSerializationTests.RunJsonRoundtrip(exportedState); - Checkpoint testCheckpoint = new(0, JsonSerializationTests.CreateTestWorkflowInfo(), new([], [], []), serializedState, new()); + Checkpoint testCheckpoint = new(0, JsonSerializationTests.CreateTestWorkflowInfo(), new([], [], []), serializedState, []); manager = new(); await manager.ImportStateAsync(testCheckpoint); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs index 369f08bd8b7..a77fc8a4958 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs @@ -18,7 +18,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) { - return JsonSerializer.Deserialize(serializedThread, jsonSerializerOptions) ?? this.GetNewThread(); + return serializedThread.Deserialize(jsonSerializerOptions) ?? this.GetNewThread(); } public override AgentThread GetNewThread() @@ -91,7 +91,5 @@ public override async IAsyncEnumerable RunStreamingAsync } } - private sealed class EchoAgentThread : InMemoryAgentThread - { - } + private sealed class EchoAgentThread : InMemoryAgentThread; } diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj index 17ca46e4afd..b7fa78d499a 100644 --- a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj @@ -1,8 +1,6 @@  - $(ProjectsTargetFrameworks) - $(ProjectsDebugTargetFrameworks) True $(NoWarn);OPENAI001; diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs index 0bd084951d4..f1373bd98b0 100644 --- a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.IO; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; @@ -37,14 +38,24 @@ public async Task CreateAIAgentAsync_WithAIFunctionTool_InvokesFunctionAsync(str { "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( model: s_config.ChatModelId!, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [weatherFunction])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [weatherFunction] + } + }), "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( model: s_config.ChatModelId!, - options: new ChatClientAgentOptions( - instructions: AgentInstructions, - tools: [weatherFunction])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = AgentInstructions, + Tools = [weatherFunction] + } + }), "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( model: s_config.ChatModelId!, instructions: AgentInstructions, @@ -94,14 +105,24 @@ public async Task CreateAIAgentAsync_WithHostedCodeInterpreter_RunsCodeAsync(str { "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( model: s_config.ChatModelId!, - options: new ChatClientAgentOptions( - instructions: Instructions, - tools: [codeInterpreterTool])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = Instructions, + Tools = [codeInterpreterTool] + } + }), "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( model: s_config.ChatModelId!, - options: new ChatClientAgentOptions( - instructions: Instructions, - tools: [codeInterpreterTool])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = Instructions, + Tools = [codeInterpreterTool] + } + }), "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( model: s_config.ChatModelId!, instructions: Instructions, @@ -153,20 +174,33 @@ You are a helpful agent that can help fetch data from files you know about. }); string vectorStoreId = vectorStoreCreate.Value.Id; + // Wait for vector store indexing to complete before using it + await WaitForVectorStoreReadyAsync(vectorStoreClient, vectorStoreId); + var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }; var agent = createMechanism switch { "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( model: s_config.ChatModelId!, - options: new ChatClientAgentOptions( - instructions: Instructions, - tools: [fileSearchTool])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = Instructions, + Tools = [fileSearchTool] + } + }), "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( model: s_config.ChatModelId!, - options: new ChatClientAgentOptions( - instructions: Instructions, - tools: [fileSearchTool])), + options: new ChatClientAgentOptions() + { + ChatOptions = new() + { + Instructions = Instructions, + Tools = [fileSearchTool] + } + }), "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( model: s_config.ChatModelId!, instructions: Instructions, @@ -189,4 +223,43 @@ You are a helpful agent that can help fetch data from files you know about. File.Delete(searchFilePath); } } + + /// + /// Waits for a vector store to complete indexing by polling its status. + /// + /// The vector store client. + /// The ID of the vector store. + /// Maximum time to wait in seconds (default: 30). + /// A task that completes when the vector store is ready or throws on timeout/failure. + private static async Task WaitForVectorStoreReadyAsync( + VectorStoreClient client, + string vectorStoreId, + int maxWaitSeconds = 30) + { + Stopwatch sw = Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < maxWaitSeconds) + { + VectorStore vectorStore = await client.GetVectorStoreAsync(vectorStoreId); + VectorStoreStatus status = vectorStore.Status; + + if (status == VectorStoreStatus.Completed) + { + if (vectorStore.FileCounts.Failed > 0) + { + throw new InvalidOperationException("Vector store indexing failed for some files"); + } + + return; + } + + if (status == VectorStoreStatus.Expired) + { + throw new InvalidOperationException("Vector store has expired"); + } + + await Task.Delay(1000); + } + + throw new TimeoutException($"Vector store did not complete indexing within {maxWaitSeconds}s"); + } } diff --git a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj index 6d86ae649e0..ff68295855a 100644 --- a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj +++ b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj @@ -1,7 +1,6 @@ - $(ProjectsTargetFrameworks) True diff --git a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs index f98540d8cce..656d310ddfa 100644 --- a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs +++ b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs @@ -47,8 +47,7 @@ public Task CreateChatClientAgentAsync( return Task.FromResult(new ChatClientAgent(chatClient, options: new() { Name = name, - Instructions = instructions, - ChatOptions = new() { Tools = aiTools } + ChatOptions = new() { Instructions = instructions, Tools = aiTools } })); } diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj index da5fae35d9f..540353d8560 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj @@ -1,7 +1,6 @@ - $(ProjectsTargetFrameworks) True $(NoWarn);OPENAI001; diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index fbb087a1530..a58583fbca4 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -73,9 +73,9 @@ public async Task CreateChatClientAgentAsync( options: new() { Name = name, - Instructions = instructions, ChatOptions = new ChatOptions { + Instructions = instructions, Tools = aiTools, RawRepresentationFactory = new Func(_ => new ResponseCreationOptions() { StoredOutputEnabled = store }) }, diff --git a/python/.cspell.json b/python/.cspell.json index da81b69a3b4..3fea304d38b 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -27,6 +27,7 @@ "aiplatform", "azuredocindex", "azuredocs", + "azurefunctions", "boto", "contentvector", "contoso", diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 6ff393bf66c..d52fa572629 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251204] - 2025-12-04 + +### Added + +- **agent-framework-core**: Add support for Pydantic `BaseModel` as function call result (#2606) +- **agent-framework-core**: Executor events now include I/O data (#2591) +- **samples**: Inline YAML declarative sample (#2582) +- **samples**: Handoff-as-agent with HITL sample (#2534) + +### Changed + +- **agent-framework-core**: [BREAKING] Support Magentic agent tool call approvals and plan stalling HITL behavior (#2569) +- **agent-framework-core**: [BREAKING] Standardize orchestration outputs as list of `ChatMessage`; allow agent as group chat manager (#2291) +- **agent-framework-core**: [BREAKING] Respond with `AgentRunResponse` including serialized structured output (#2285) +- **observability**: Use `executor_id` and `edge_group_id` as span names for clearer traces (#2538) +- **agent-framework-devui**: Add multimodal input support for workflows and refactor chat input (#2593) +- **docs**: Update Python orchestration documentation (#2087) + +### Fixed + +- **observability**: Resolve mypy error in observability module (#2641) +- **agent-framework-core**: Fix `AgentRunResponse.created_at` returning local datetime labeled as UTC (#2590) +- **agent-framework-core**: Emit `ExecutorFailedEvent` before `WorkflowFailedEvent` when executor throws (#2537) +- **agent-framework-core**: Fix MagenticAgentExecutor producing `repr` string for tool call content (#2566) +- **agent-framework-core**: Fixed empty text content Pydantic validation failure (#2539) +- **agent-framework-azure-ai**: Added support for application endpoints in Azure AI client (#2460) +- **agent-framework-azurefunctions**: Add MCP tool support (#2385) +- **agent-framework-core**: Preserve MCP array items schema in Pydantic field generation (#2382) +- **agent-framework-devui**: Make tool call view optional and fix links (#2243) +- **agent-framework-core**: Always include output in function call result messages (#2414) +- **agent-framework-redis**: Fix TypeError (#2411) + ## [1.0.0b251120] - 2025-11-20 ### Added @@ -290,7 +322,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251204...HEAD +[1.0.0b251204]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...python-1.0.0b251204 [1.0.0b251120]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251117...python-1.0.0b251120 [1.0.0b251117]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251114...python-1.0.0b251117 [1.0.0b251114]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112.post1...python-1.0.0b251114 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 75d8301e6d2..7301fec1ae6 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py index 298c0acfe94..23860150be3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py @@ -3,7 +3,7 @@ """AgentFrameworkAgent wrapper for AG-UI protocol - Clean Architecture.""" from collections.abc import AsyncGenerator -from typing import Any +from typing import Any, cast from ag_ui.core import BaseEvent from agent_framework import AgentProtocol @@ -22,21 +22,48 @@ class AgentConfig: def __init__( self, - state_schema: dict[str, Any] | None = None, + state_schema: Any | None = None, predict_state_config: dict[str, dict[str, str]] | None = None, require_confirmation: bool = True, ): """Initialize agent configuration. Args: - state_schema: Optional state schema for state management + state_schema: Optional state schema for state management; accepts dict or Pydantic model/class predict_state_config: Configuration for predictive state updates require_confirmation: Whether predictive updates require confirmation """ - self.state_schema = state_schema or {} + self.state_schema = self._normalize_state_schema(state_schema) self.predict_state_config = predict_state_config or {} self.require_confirmation = require_confirmation + @staticmethod + def _normalize_state_schema(state_schema: Any | None) -> dict[str, Any]: + """Accept dict or Pydantic model/class and return a properties dict.""" + if state_schema is None: + return {} + + if isinstance(state_schema, dict): + return cast(dict[str, Any], state_schema) + + base_model_type: type[Any] | None + try: + from pydantic import BaseModel as ImportedBaseModel + + base_model_type = ImportedBaseModel + except Exception: # pragma: no cover + base_model_type = None + + if base_model_type is not None and isinstance(state_schema, base_model_type): + schema_dict = state_schema.__class__.model_json_schema() + return schema_dict.get("properties", {}) or {} + + if base_model_type is not None and isinstance(state_schema, type) and issubclass(state_schema, base_model_type): + schema_dict = state_schema.model_json_schema() + return schema_dict.get("properties", {}) or {} + + return {} + class AgentFrameworkAgent: """Wraps Agent Framework agents for AG-UI protocol compatibility. @@ -55,7 +82,7 @@ def __init__( agent: AgentProtocol, name: str | None = None, description: str | None = None, - state_schema: dict[str, Any] | None = None, + state_schema: Any | None = None, predict_state_config: dict[str, dict[str, str]] | None = None, require_confirmation: bool = True, orchestrators: list[Orchestrator] | None = None, @@ -67,7 +94,7 @@ def __init__( agent: The Agent Framework agent to wrap name: Optional name for the agent description: Optional description - state_schema: Optional state schema for state management + state_schema: Optional state schema for state management; accepts dict or Pydantic model/class predict_state_config: Configuration for predictive state updates. Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}} require_confirmation: Whether predictive updates require confirmation. diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py b/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py index d1baad55619..eedf88db14d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py @@ -2,6 +2,7 @@ """FastAPI endpoint creation for AG-UI agents.""" +import copy import logging from typing import Any @@ -19,9 +20,10 @@ def add_agent_framework_fastapi_endpoint( app: FastAPI, agent: AgentProtocol | AgentFrameworkAgent, path: str = "/", - state_schema: dict[str, Any] | None = None, + state_schema: Any | None = None, predict_state_config: dict[str, dict[str, str]] | None = None, allow_origins: list[str] | None = None, + default_state: dict[str, Any] | None = None, ) -> None: """Add an AG-UI endpoint to a FastAPI app. @@ -29,10 +31,11 @@ def add_agent_framework_fastapi_endpoint( app: The FastAPI application agent: The agent to expose (can be raw AgentProtocol or wrapped) path: The endpoint path - state_schema: Optional state schema for shared state management + state_schema: Optional state schema for shared state management; accepts dict or Pydantic model/class predict_state_config: Optional predictive state update configuration. Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}} allow_origins: CORS origins (not yet implemented) + default_state: Optional initial state to seed when the client does not provide state keys """ if isinstance(agent, AgentProtocol): wrapped_agent = AgentFrameworkAgent( @@ -52,6 +55,11 @@ async def agent_endpoint(request: Request): # type: ignore[misc] """ try: input_data = await request.json() + if default_state: + state = input_data.setdefault("state", {}) + for key, value in default_state.items(): + if key not in state: + state[key] = copy.deepcopy(value) logger.debug( f"[{path}] Received request - Run ID: {input_data.get('run_id', 'no-run-id')}, " f"Thread ID: {input_data.get('thread_id', 'no-thread-id')}, " diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py index 8aec59d52c1..184da0239e4 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_events.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -5,6 +5,7 @@ import json import logging import re +from copy import deepcopy from typing import Any from ag_ui.core import ( @@ -104,574 +105,521 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba for idx, content in enumerate(update.contents): logger.info(f" Content {idx}: type={type(content).__name__}") if isinstance(content, TextContent): - logger.info( - f" TextContent found: text_length={len(content.text)}, text_preview='{content.text[:100]}'" - ) - logger.info( - f" Flags: skip_text_content={self.skip_text_content}, should_stop_after_confirm={self.should_stop_after_confirm}" - ) + events.extend(self._handle_text_content(content)) + elif isinstance(content, FunctionCallContent): + events.extend(self._handle_function_call_content(content)) + elif isinstance(content, FunctionResultContent): + events.extend(self._handle_function_result_content(content)) + elif isinstance(content, FunctionApprovalRequestContent): + events.extend(self._handle_function_approval_request_content(content)) - # Skip text content if using structured outputs (it's just the JSON) - if self.skip_text_content: - logger.info(" SKIPPING TextContent: skip_text_content is True") - continue + return events - # Skip text content if we're about to emit confirm_changes - # The summary should only appear after user confirms - if self.should_stop_after_confirm: - logger.info(" SKIPPING TextContent: waiting for confirm_changes response") - # Save the summary text to show after confirmation - self.suppressed_summary += content.text - logger.info(f" Suppressed summary now has {len(self.suppressed_summary)} chars") - continue + def _handle_text_content(self, content: TextContent) -> list[BaseEvent]: + events: list[BaseEvent] = [] + logger.info(f" TextContent found: length={len(content.text)}") + logger.info( + " Flags: skip_text_content=%s, should_stop_after_confirm=%s", + self.skip_text_content, + self.should_stop_after_confirm, + ) - if not self.current_message_id: - self.current_message_id = generate_event_id() - start_event = TextMessageStartEvent( - message_id=self.current_message_id, - role="assistant", - ) - logger.info(f" EMITTING TextMessageStartEvent with message_id={self.current_message_id}") - events.append(start_event) + if self.skip_text_content: + logger.info(" SKIPPING TextContent: skip_text_content is True") + return events + + if self.should_stop_after_confirm: + logger.info(" SKIPPING TextContent: waiting for confirm_changes response") + self.suppressed_summary += content.text + logger.info(f" Suppressed summary length={len(self.suppressed_summary)}") + return events + + # Skip empty text chunks to avoid emitting + # TextMessageContentEvent with an empty `delta` which fails + # Pydantic validation (AG-UI requires non-empty strings). + if not content.text: + logger.info(" SKIPPING TextContent: empty chunk") + return events + + if not self.current_message_id: + self.current_message_id = generate_event_id() + start_event = TextMessageStartEvent( + message_id=self.current_message_id, + role="assistant", + ) + logger.info(f" EMITTING TextMessageStartEvent with message_id={self.current_message_id}") + events.append(start_event) + + event = TextMessageContentEvent( + message_id=self.current_message_id, + delta=content.text, + ) + self.accumulated_text_content += content.text + logger.info(f" EMITTING TextMessageContentEvent with text_len={len(content.text)}") + events.append(event) + return events - event = TextMessageContentEvent( - message_id=self.current_message_id, - delta=content.text, - ) - # Accumulate text content for final MessagesSnapshotEvent - self.accumulated_text_content += content.text - logger.info(f" EMITTING TextMessageContentEvent with delta: '{content.text}'") - events.append(event) + def _handle_function_call_content(self, content: FunctionCallContent) -> list[BaseEvent]: + events: list[BaseEvent] = [] + if content.name: + logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})") + + if not content.name and not content.call_id and not self.current_tool_call_name: + args_length = len(str(content.arguments)) if content.arguments else 0 + logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}") + + tool_call_id = self._coalesce_tool_call_id(content) + if content.name and tool_call_id != self.current_tool_call_id: + self.streaming_tool_args = "" + self.state_delta_count = 0 + if content.name: + self.current_tool_call_id = tool_call_id + self.current_tool_call_name = content.name + + tool_start_event = ToolCallStartEvent( + tool_call_id=tool_call_id, + tool_call_name=content.name, + parent_message_id=self.current_message_id, + ) + logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'") + events.append(tool_start_event) + + self.pending_tool_calls.append( + { + "id": tool_call_id, + "type": "function", + "function": { + "name": content.name, + "arguments": "", + }, + } + ) + elif tool_call_id: + self.current_tool_call_id = tool_call_id + + if content.arguments: + delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments) + logger.info(f"Emitting ToolCallArgsEvent with delta_length={len(delta_str)}, id='{tool_call_id}'") + args_event = ToolCallArgsEvent( + tool_call_id=tool_call_id, + delta=delta_str, + ) + events.append(args_event) + + for tool_call in self.pending_tool_calls: + if tool_call["id"] == tool_call_id: + tool_call["function"]["arguments"] += delta_str + break + + events.extend(self._emit_predictive_state_deltas(delta_str)) + events.extend(self._legacy_predictive_state(content)) - elif isinstance(content, FunctionCallContent): - # Log tool calls for debugging - if content.name: - logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})") - - if not content.name and not content.call_id and not self.current_tool_call_name: - args_preview = str(content.arguments)[:50] if content.arguments else "None" - logger.warning(f"FunctionCallContent missing name and call_id. Args: {args_preview}") - - # Get or use existing tool call ID - all chunks of same tool call share the same call_id - # Important: the first chunk might have name but no call_id yet - if content.call_id: - tool_call_id = content.call_id - elif self.current_tool_call_id: - tool_call_id = self.current_tool_call_id - else: - # Generate a new ID for this tool call - tool_call_id = ( - generate_event_id() - ) # Handle streaming tool calls - name comes in first chunk, arguments in subsequent chunks - if content.name: - # This is a new tool call or the first chunk with the name - self.current_tool_call_id = tool_call_id - self.current_tool_call_name = content.name - - tool_start_event = ToolCallStartEvent( - tool_call_id=tool_call_id, - tool_call_name=content.name, - parent_message_id=self.current_message_id, - ) - logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'") - events.append(tool_start_event) - - # Track tool call for MessagesSnapshotEvent - # Initialize a new tool call entry - self.pending_tool_calls.append( - { - "id": tool_call_id, - "type": "function", - "function": { - "name": content.name, - "arguments": "", # Will accumulate as we get argument chunks - }, - } - ) - else: - # Subsequent chunk without name - update our tracked ID if needed - if tool_call_id: - self.current_tool_call_id = tool_call_id - - # Emit arguments if present - if content.arguments: - # content.arguments is already a JSON string from the LLM for streaming calls - # For non-streaming it could be a dict, so we need to handle both - if isinstance(content.arguments, str): - delta_str = content.arguments - else: - # If it's a dict, convert to JSON - delta_str = json.dumps(content.arguments) - - logger.info(f"Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'") - args_event = ToolCallArgsEvent( - tool_call_id=tool_call_id, - delta=delta_str, - ) - events.append(args_event) - - # Accumulate arguments for MessagesSnapshotEvent - if self.pending_tool_calls: - # Find the matching tool call and append the delta - for tool_call in self.pending_tool_calls: - if tool_call["id"] == tool_call_id: - tool_call["function"]["arguments"] += delta_str - break - - # Predictive state updates - accumulate streaming arguments and emit deltas - # Use current_tool_call_name since content.name is only present on first chunk - if self.current_tool_call_name and self.predict_state_config: - # Accumulate the argument string - if isinstance(content.arguments, str): - self.streaming_tool_args += content.arguments - else: - self.streaming_tool_args += json.dumps(content.arguments) - - logger.debug( - f"Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'" - ) + return events - # Try to parse accumulated arguments (may be incomplete JSON) - # We use a lenient approach: try standard parsing first, then try to extract partial values - parsed_args = None - try: - parsed_args = json.loads(self.streaming_tool_args) - except json.JSONDecodeError: - # JSON is incomplete - try to extract partial string values - # For streaming "document" field, we can extract: {"document": "text... - # Look for pattern: {"field": "value (incomplete) - for state_key, config in self.predict_state_config.items(): - if config["tool"] == self.current_tool_call_name: - tool_arg_name = config["tool_argument"] - - # Try to extract partial string value for this argument - # Pattern: "argument_name": "partial text - pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)' - match = re.search(pattern, self.streaming_tool_args) - - if match: - partial_value = match.group(1) - # Unescape common sequences - partial_value = ( - partial_value.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\") - ) - - # Emit delta if we have new content - if ( - state_key not in self.last_emitted_state - or self.last_emitted_state[state_key] != partial_value - ): - state_delta_event = StateDeltaEvent( - delta=[ - { - "op": "replace", - "path": f"/{state_key}", - "value": partial_value, - } - ], - ) - - self.state_delta_count += 1 - if self.state_delta_count % 10 == 1: - value_preview = ( - str(partial_value)[:100] + "..." - if len(str(partial_value)) > 100 - else str(partial_value) - ) - logger.info( - f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': " - f"op=replace, path=/{state_key}, value={value_preview}" - ) - elif self.state_delta_count % 100 == 0: - logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted") - - events.append(state_delta_event) - self.last_emitted_state[state_key] = partial_value - self.pending_state_updates[state_key] = partial_value - - # If we successfully parsed complete JSON, process it - if parsed_args: - # Check if this tool matches any predictive state config - for state_key, config in self.predict_state_config.items(): - if config["tool"] == self.current_tool_call_name: - tool_arg_name = config["tool_argument"] - - # Extract the state value - if tool_arg_name == "*": - state_value = parsed_args - elif tool_arg_name in parsed_args: - state_value = parsed_args[tool_arg_name] - else: - continue - - # Only emit if state has changed from last emission - if ( - state_key not in self.last_emitted_state - or self.last_emitted_state[state_key] != state_value - ): - # Emit StateDeltaEvent for real-time UI updates (JSON Patch format) - state_delta_event = StateDeltaEvent( - delta=[ - { - "op": "replace", # Use replace since field exists in schema - "path": f"/{state_key}", # JSON Pointer path with leading slash - "value": state_value, - } - ], - ) - - # Increment counter and log every 10th emission with sample data - self.state_delta_count += 1 - if self.state_delta_count % 10 == 1: # Log 1st, 11th, 21st, etc. - value_preview = ( - str(state_value)[:100] + "..." - if len(str(state_value)) > 100 - else str(state_value) - ) - logger.info( - f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': " - f"op=replace, path=/{state_key}, value={value_preview}" - ) - elif self.state_delta_count % 100 == 0: # Also log every 100th - logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted") - - events.append(state_delta_event) - - # Track what we emitted - self.last_emitted_state[state_key] = state_value - self.pending_state_updates[state_key] = state_value - - # Legacy predictive state check (for when arguments are complete) - if content.name and content.arguments: - parsed_args = content.parse_arguments() - - if parsed_args: - logger.info(f"Checking predict_state_config: {self.predict_state_config}") - for state_key, config in self.predict_state_config.items(): - logger.info(f"Checking state_key='{state_key}', config={config}") - if config["tool"] == content.name: - tool_arg_name = config["tool_argument"] - logger.info( - f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'" - ) - - # If tool_argument is "*", use all arguments as the state value - if tool_arg_name == "*": - state_value = parsed_args - logger.info(f"Using all args as state value, keys: {list(state_value.keys())}") - elif tool_arg_name in parsed_args: - state_value = parsed_args[tool_arg_name] - logger.info(f"Using specific arg '{tool_arg_name}' as state value") - else: - logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args") - continue - - # Emit predictive delta (JSON Patch format) - state_delta_event = StateDeltaEvent( - delta=[ - { - "op": "replace", # Use replace since field exists in schema - "path": f"/{state_key}", # JSON Pointer path with leading slash - "value": state_value, - } - ], - ) - logger.info( - f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}" - ) - events.append(state_delta_event) - - # Track pending update for later snapshot - self.pending_state_updates[state_key] = state_value - - # Note: ToolCallEndEvent is emitted when we receive FunctionResultContent, - # not here during streaming, since we don't know when the stream is complete + def _coalesce_tool_call_id(self, content: FunctionCallContent) -> str: + if content.call_id: + return content.call_id + if self.current_tool_call_id: + return self.current_tool_call_id + return generate_event_id() - elif isinstance(content, FunctionResultContent): - # First emit ToolCallEndEvent to close the tool call - if content.call_id: - end_event = ToolCallEndEvent( - tool_call_id=content.call_id, - ) - logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") - events.append(end_event) - self.tool_calls_ended.add(content.call_id) # Track that we emitted end event + def _emit_predictive_state_deltas(self, argument_chunk: str) -> list[BaseEvent]: + events: list[BaseEvent] = [] + if not self.current_tool_call_name or not self.predict_state_config: + return events + + self.streaming_tool_args += argument_chunk + logger.debug( + "Predictive state: accumulated %s chars for tool '%s'", + len(self.streaming_tool_args), + self.current_tool_call_name, + ) - # Log total StateDeltaEvent count for this tool call - if self.state_delta_count > 0: - logger.info( - f"Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total" + parsed_args = None + try: + parsed_args = json.loads(self.streaming_tool_args) + except json.JSONDecodeError: + for state_key, config in self.predict_state_config.items(): + if config["tool"] != self.current_tool_call_name: + continue + tool_arg_name = config["tool_argument"] + pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)' + match = re.search(pattern, self.streaming_tool_args) + + if match: + partial_value = match.group(1).replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\") + + if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != partial_value: + state_delta_event = StateDeltaEvent( + delta=[ + { + "op": "replace", + "path": f"/{state_key}", + "value": partial_value, + } + ], ) - # Reset streaming accumulator and counter for next tool call - self.streaming_tool_args = "" - self.state_delta_count = 0 + self.state_delta_count += 1 + if self.state_delta_count % 10 == 1: + logger.info( + "StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s", + self.state_delta_count, + state_key, + state_key, + len(str(partial_value)), + ) + elif self.state_delta_count % 100 == 0: + logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted") + + events.append(state_delta_event) + self.last_emitted_state[state_key] = partial_value + self.pending_state_updates[state_key] = partial_value - # Tool result - emit ToolCallResultEvent - result_message_id = generate_event_id() + if parsed_args: + for state_key, config in self.predict_state_config.items(): + if config["tool"] != self.current_tool_call_name: + continue + tool_arg_name = config["tool_argument"] - # Preserve structured data for backend tool rendering - # Serialize dicts to JSON string, otherwise convert to string - if isinstance(content.result, dict): - result_content = json.dumps(content.result) # type: ignore[arg-type] - elif content.result is not None: - result_content = str(content.result) + if tool_arg_name == "*": + state_value = parsed_args + elif tool_arg_name in parsed_args: + state_value = parsed_args[tool_arg_name] else: - result_content = "" + continue - result_event = ToolCallResultEvent( - message_id=result_message_id, - tool_call_id=content.call_id, - content=result_content, - role="tool", + if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value: + state_delta_event = StateDeltaEvent( + delta=[ + { + "op": "replace", + "path": f"/{state_key}", + "value": state_value, + } + ], + ) + + self.state_delta_count += 1 + if self.state_delta_count % 10 == 1: + logger.info( + "StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s", + self.state_delta_count, + state_key, + state_key, + len(str(state_value)), + ) + elif self.state_delta_count % 100 == 0: + logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted") + + events.append(state_delta_event) + self.last_emitted_state[state_key] = state_value + self.pending_state_updates[state_key] = state_value + return events + + def _legacy_predictive_state(self, content: FunctionCallContent) -> list[BaseEvent]: + events: list[BaseEvent] = [] + if not (content.name and content.arguments): + return events + parsed_args = content.parse_arguments() + if not parsed_args: + return events + + logger.info( + "Checking predict_state_config keys: %s", + list(self.predict_state_config.keys()) if self.predict_state_config else "None", + ) + for state_key, config in self.predict_state_config.items(): + logger.info(f"Checking state_key='{state_key}'") + if config["tool"] != content.name: + continue + tool_arg_name = config["tool_argument"] + logger.info(f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'") + + state_value: Any + if tool_arg_name == "*": + state_value = parsed_args + logger.info(f"Using all args as state value, keys: {list(state_value.keys())}") + elif tool_arg_name in parsed_args: + state_value = parsed_args[tool_arg_name] + logger.info(f"Using specific arg '{tool_arg_name}' as state value") + else: + logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args") + continue + + previous_value = self.last_emitted_state.get(state_key, object()) + if previous_value == state_value: + logger.info( + "Skipping duplicate StateDeltaEvent for key '%s' - value unchanged", + state_key, ) - events.append(result_event) + continue - # Track tool result for MessagesSnapshotEvent - # AG-UI protocol expects: { role: "tool", toolCallId: ..., content: ... } - # Use camelCase for Pydantic's alias_generator=to_camel - self.tool_results.append( + state_delta_event = StateDeltaEvent( + delta=[ { - "id": result_message_id, - "role": "tool", - "toolCallId": content.call_id, - "content": result_content, + "op": "replace", + "path": f"/{state_key}", + "value": state_value, } - ) + ], + ) + logger.info(f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}") # type: ignore + events.append(state_delta_event) + self.pending_state_updates[state_key] = state_value + self.last_emitted_state[state_key] = state_value + return events - # Emit MessagesSnapshotEvent with the complete conversation including tool calls and results - # This is required for CopilotKit's useCopilotAction to detect tool result - # HOWEVER: Skip this for predictive tools when require_confirmation=False, because - # the agent will generate a follow-up text message and we'll emit a complete snapshot at the end. - # Emitting here would create an incomplete snapshot that gets replaced, causing UI flicker. - should_emit_snapshot = self.pending_tool_calls and self.tool_results - - # Check if this is a predictive tool that will have a follow-up message - is_predictive_without_confirmation = False - if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config: - for state_key, config in self.predict_state_config.items(): - if config["tool"] == self.current_tool_call_name and not self.require_confirmation: - is_predictive_without_confirmation = True - logger.info( - f"Skipping intermediate MessagesSnapshotEvent for predictive tool '{self.current_tool_call_name}' " - "- will emit complete snapshot after follow-up message" - ) - break + def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]: + events: list[BaseEvent] = [] + if content.call_id: + end_event = ToolCallEndEvent( + tool_call_id=content.call_id, + ) + logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") + events.append(end_event) + self.tool_calls_ended.add(content.call_id) + + if self.state_delta_count > 0: + logger.info( + "Tool call '%s' complete: emitted %s StateDeltaEvents total", + content.call_id, + self.state_delta_count, + ) - if should_emit_snapshot and not is_predictive_without_confirmation: - # Import message adapter - from ._message_adapters import agent_framework_messages_to_agui + self.streaming_tool_args = "" + self.state_delta_count = 0 + + result_message_id = generate_event_id() + if isinstance(content.result, dict): + result_content = json.dumps(content.result) # type: ignore[arg-type] + elif content.result is not None: + result_content = str(content.result) + else: + result_content = "" + + result_event = ToolCallResultEvent( + message_id=result_message_id, + tool_call_id=content.call_id, + content=result_content, + role="tool", + ) + events.append(result_event) + + self.tool_results.append( + { + "id": result_message_id, + "role": "tool", + "toolCallId": content.call_id, + "content": result_content, + } + ) - # Build assistant message with tool_calls - assistant_message = { - "id": generate_event_id(), - "role": "assistant", - "tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls - } + events.extend(self._emit_snapshot_for_tool_result()) + events.extend(self._emit_state_snapshot_and_confirmation()) - # Convert Agent Framework messages to AG-UI format (adds required 'id' field) - converted_input_messages = agent_framework_messages_to_agui(self.input_messages) + return events - # Build complete messages array: input messages + assistant message + tool results - all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy() + def _emit_snapshot_for_tool_result(self) -> list[BaseEvent]: + events: list[BaseEvent] = [] + should_emit_snapshot = self.pending_tool_calls and self.tool_results - # Emit MessagesSnapshotEvent using the proper event type - # Note: messages are dict[str, Any] but Pydantic will validate them as Message types - messages_snapshot_event = MessagesSnapshotEvent( - type=EventType.MESSAGES_SNAPSHOT, - messages=all_messages, # type: ignore[arg-type] + is_predictive_without_confirmation = False + if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config: + for _, config in self.predict_state_config.items(): + if config["tool"] == self.current_tool_call_name and not self.require_confirmation: + is_predictive_without_confirmation = True + logger.info( + "Skipping intermediate MessagesSnapshotEvent for predictive tool '%s' - delaying until summary", + self.current_tool_call_name, ) - logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages") - events.append(messages_snapshot_event) - - # After tool execution, emit StateSnapshotEvent if we have pending state updates - if self.pending_state_updates: - # Update the current state with pending updates - for key, value in self.pending_state_updates.items(): - self.current_state[key] = value - - # Log the state structure for debugging - logger.info(f"Emitting StateSnapshotEvent with keys: {list(self.current_state.keys())}") - if "recipe" in self.current_state: - recipe = self.current_state["recipe"] - logger.info( - f"Recipe fields: title={recipe.get('title')}, " - f"skill_level={recipe.get('skill_level')}, " - f"ingredients_count={len(recipe.get('ingredients', []))}, " - f"instructions_count={len(recipe.get('instructions', []))}" - ) + break + + if should_emit_snapshot and not is_predictive_without_confirmation: + from ._message_adapters import agent_framework_messages_to_agui + + assistant_message = { + "id": generate_event_id(), + "role": "assistant", + "tool_calls": self.pending_tool_calls.copy(), + } + converted_input_messages = agent_framework_messages_to_agui(self.input_messages) + all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy() + + messages_snapshot_event = MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, + messages=all_messages, # type: ignore[arg-type] + ) + logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages") + events.append(messages_snapshot_event) + return events - # Emit complete state snapshot - state_snapshot_event = StateSnapshotEvent( - snapshot=self.current_state, - ) - events.append(state_snapshot_event) - - # Check if this was a predictive state update tool (e.g., write_document_local) - # If so, emit a confirm_changes tool call for the UI modal - tool_was_predictive = False - logger.debug( - f"Checking predictive state: current_tool='{self.current_tool_call_name}', " - f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}" + def _emit_state_snapshot_and_confirmation(self) -> list[BaseEvent]: + events: list[BaseEvent] = [] + if self.pending_state_updates: + for key, value in self.pending_state_updates.items(): + self.current_state[key] = value + + logger.info(f"Emitting StateSnapshotEvent with keys: {list(self.current_state.keys())}") + if "recipe" in self.current_state: + recipe = self.current_state["recipe"] + logger.info( + "Recipe fields: title=%s, skill_level=%s, ingredients_count=%s, instructions_count=%s", + recipe.get("title"), + recipe.get("skill_level"), + len(recipe.get("ingredients", [])), + len(recipe.get("instructions", [])), + ) + + state_snapshot_event = StateSnapshotEvent( + snapshot=self.current_state, + ) + events.append(state_snapshot_event) + + tool_was_predictive = False + logger.debug( + "Checking predictive state: current_tool='%s', predict_config=%s", + self.current_tool_call_name, + list(self.predict_state_config.keys()) if self.predict_state_config else "None", + ) + for state_key, config in self.predict_state_config.items(): + if self.current_tool_call_name and config["tool"] == self.current_tool_call_name: + logger.info( + "Tool '%s' matches predictive config for state key '%s'", + self.current_tool_call_name, + state_key, ) - for state_key, config in self.predict_state_config.items(): - # Check if this tool call matches a predictive config - # We need to match against self.current_tool_call_name - if self.current_tool_call_name and config["tool"] == self.current_tool_call_name: - logger.info( - f"Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'" - ) - tool_was_predictive = True - break + tool_was_predictive = True + break - if tool_was_predictive and self.require_confirmation: - # Emit confirm_changes tool call sequence - confirm_call_id = generate_event_id() + if tool_was_predictive and self.require_confirmation: + events.extend(self._emit_confirm_changes_tool_call()) + elif tool_was_predictive: + logger.info("Skipping confirm_changes - require_confirmation is False") - logger.info("Emitting confirm_changes tool call for predictive update") + self.pending_state_updates.clear() + self.last_emitted_state = deepcopy(self.current_state) + self.current_tool_call_name = None + return events - # Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED) - self.pending_tool_calls.append( - { - "id": confirm_call_id, - "type": "function", - "function": { - "name": "confirm_changes", - "arguments": "{}", - }, - } - ) + def _emit_confirm_changes_tool_call(self) -> list[BaseEvent]: + events: list[BaseEvent] = [] + confirm_call_id = generate_event_id() + logger.info("Emitting confirm_changes tool call for predictive update") + + self.pending_tool_calls.append( + { + "id": confirm_call_id, + "type": "function", + "function": { + "name": "confirm_changes", + "arguments": "{}", + }, + } + ) - # Start the confirm_changes tool call - confirm_start = ToolCallStartEvent( - tool_call_id=confirm_call_id, - tool_call_name="confirm_changes", - ) - events.append(confirm_start) + confirm_start = ToolCallStartEvent( + tool_call_id=confirm_call_id, + tool_call_name="confirm_changes", + ) + events.append(confirm_start) - # Empty args for confirm_changes - confirm_args = ToolCallArgsEvent( - tool_call_id=confirm_call_id, - delta="{}", - ) - events.append(confirm_args) + confirm_args = ToolCallArgsEvent( + tool_call_id=confirm_call_id, + delta="{}", + ) + events.append(confirm_args) - # End the confirm_changes tool call - confirm_end = ToolCallEndEvent( - tool_call_id=confirm_call_id, - ) - events.append(confirm_end) - - # Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED - # Import message adapter - from ._message_adapters import agent_framework_messages_to_agui - - # Build assistant message with pending confirm_changes tool call - assistant_message = { - "id": generate_event_id(), - "role": "assistant", - "tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes - } - - # Convert Agent Framework messages to AG-UI format (adds required 'id' field) - converted_input_messages = agent_framework_messages_to_agui(self.input_messages) - - # Build complete messages array: input messages + assistant message + any tool results - all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy() - - # Emit MessagesSnapshotEvent - # Note: messages are dict[str, Any] but Pydantic will validate them as Message types - messages_snapshot_event = MessagesSnapshotEvent( - type=EventType.MESSAGES_SNAPSHOT, - messages=all_messages, # type: ignore[arg-type] - ) - logger.info( - f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages" - ) - events.append(messages_snapshot_event) + confirm_end = ToolCallEndEvent( + tool_call_id=confirm_call_id, + ) + events.append(confirm_end) - # Set flag to stop the run after this - we're waiting for user response - self.should_stop_after_confirm = True - logger.info("Set flag to stop run after confirm_changes") - elif tool_was_predictive: - logger.info("Skipping confirm_changes - require_confirmation is False") + from ._message_adapters import agent_framework_messages_to_agui - # Clear pending updates and reset tool name tracker - self.pending_state_updates.clear() - self.last_emitted_state.clear() - self.current_tool_call_name = None # Reset for next tool call + assistant_message = { + "id": generate_event_id(), + "role": "assistant", + "tool_calls": self.pending_tool_calls.copy(), + } - elif isinstance(content, FunctionApprovalRequestContent): - # Human in the loop - function approval request - logger.info("=== FUNCTION APPROVAL REQUEST ===") - logger.info(f" Function: {content.function_call.name}") - logger.info(f" Call ID: {content.function_call.call_id}") - - # Parse the arguments to extract state for predictive UI updates - parsed_args = content.function_call.parse_arguments() - logger.info(f" Parsed args keys: {list(parsed_args.keys()) if parsed_args else 'None'}") - - # Check if this matches our predict_state_config and emit state - if parsed_args and self.predict_state_config: - logger.info(f" Checking predict_state_config: {self.predict_state_config}") - for state_key, config in self.predict_state_config.items(): - if config["tool"] == content.function_call.name: - tool_arg_name = config["tool_argument"] - logger.info( - f" MATCHED tool '{content.function_call.name}' for state key '{state_key}', arg='{tool_arg_name}'" - ) + converted_input_messages = agent_framework_messages_to_agui(self.input_messages) + all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy() - # Extract the state value - if tool_arg_name == "*": - state_value = parsed_args - elif tool_arg_name in parsed_args: - state_value = parsed_args[tool_arg_name] - else: - logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args") - continue - - # Update current state - self.current_state[state_key] = state_value - logger.info( - f"Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}" - ) + messages_snapshot_event = MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, + messages=all_messages, # type: ignore[arg-type] + ) + logger.info(f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages") + events.append(messages_snapshot_event) - # Emit state snapshot - state_snapshot = StateSnapshotEvent( - snapshot=self.current_state, - ) - events.append(state_snapshot) + self.should_stop_after_confirm = True + logger.info("Set flag to stop run after confirm_changes") + return events - # The tool call has been streamed already (Start/Args events) - # Now we need to close it with an End event before the agent waits for approval - if content.function_call.call_id: - end_event = ToolCallEndEvent( - tool_call_id=content.function_call.call_id, - ) - logger.info( - f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'" - ) - events.append(end_event) - self.tool_calls_ended.add(content.function_call.call_id) # Track that we emitted end event - - # Emit custom event for approval request - # Note: In AG-UI protocol, the frontend handles interrupts automatically - # when it sees a tool call with the configured name (via predict_state_config) - # This custom event is for additional metadata if needed - approval_event = CustomEvent( - name="function_approval_request", - value={ - "id": content.id, - "function_call": { - "call_id": content.function_call.call_id, - "name": content.function_call.name, - "arguments": content.function_call.parse_arguments(), - }, - }, + def _handle_function_approval_request_content(self, content: FunctionApprovalRequestContent) -> list[BaseEvent]: + events: list[BaseEvent] = [] + logger.info("=== FUNCTION APPROVAL REQUEST ===") + logger.info(f" Function: {content.function_call.name}") + logger.info(f" Call ID: {content.function_call.call_id}") + + parsed_args = content.function_call.parse_arguments() + parsed_arg_keys = list(parsed_args.keys()) if parsed_args else "None" + logger.info(f" Parsed args keys: {parsed_arg_keys}") + + if parsed_args and self.predict_state_config: + logger.info( + " Checking predict_state_config keys: %s", + list(self.predict_state_config.keys()) if self.predict_state_config else "None", + ) + for state_key, config in self.predict_state_config.items(): + if config["tool"] != content.function_call.name: + continue + tool_arg_name = config["tool_argument"] + logger.info( + " MATCHED tool '%s' for state key '%s', arg='%s'", + content.function_call.name, + state_key, + tool_arg_name, ) - logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'") - events.append(approval_event) + state_value: Any + if tool_arg_name == "*": + state_value = parsed_args + elif tool_arg_name in parsed_args: + state_value = parsed_args[tool_arg_name] + else: + logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args") + continue + + self.current_state[state_key] = state_value + logger.info("Emitting StateSnapshotEvent for key '%s', value type: %s", state_key, type(state_value)) # type: ignore + state_snapshot = StateSnapshotEvent( + snapshot=self.current_state, + ) + events.append(state_snapshot) + + if content.function_call.call_id: + end_event = ToolCallEndEvent( + tool_call_id=content.function_call.call_id, + ) + logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'") + events.append(end_event) + self.tool_calls_ended.add(content.function_call.call_id) + + approval_event = CustomEvent( + name="function_approval_request", + value={ + "id": content.id, + "function_call": { + "call_id": content.function_call.call_id, + "name": content.function_call.name, + "arguments": content.function_call.parse_arguments(), + }, + }, + ) + logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'") + events.append(approval_event) return events def create_run_started_event(self) -> RunStartedEvent: diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/__init__.py new file mode 100644 index 00000000000..2a50eae8941 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_message_hygiene.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_message_hygiene.py new file mode 100644 index 00000000000..97c990781b1 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_message_hygiene.py @@ -0,0 +1,176 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Message hygiene utilities for orchestrators.""" + +import json +import logging +from typing import Any + +from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent + +logger = logging.getLogger(__name__) + + +def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: + """Normalize tool ordering and inject synthetic results for AG-UI edge cases.""" + sanitized: list[ChatMessage] = [] + pending_tool_call_ids: set[str] | None = None + pending_confirm_changes_id: str | None = None + + for msg in messages: + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + if role_value == "assistant": + tool_ids = { + str(content.call_id) + for content in msg.contents or [] + if isinstance(content, FunctionCallContent) and content.call_id + } + confirm_changes_call = None + for content in msg.contents or []: + if isinstance(content, FunctionCallContent) and content.name == "confirm_changes": + confirm_changes_call = content + break + + sanitized.append(msg) + pending_tool_call_ids = tool_ids if tool_ids else None + pending_confirm_changes_id = ( + str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None + ) + continue + + if role_value == "user": + if pending_confirm_changes_id: + user_text = "" + for content in msg.contents or []: + if isinstance(content, TextContent): + user_text = content.text + break + + try: + parsed = json.loads(user_text) + if "accepted" in parsed: + logger.info( + f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}" + ) + synthetic_result = ChatMessage( + role="tool", + contents=[ + FunctionResultContent( + call_id=pending_confirm_changes_id, + result="Confirmed" if parsed.get("accepted") else "Rejected", + ) + ], + ) + sanitized.append(synthetic_result) + if pending_tool_call_ids: + pending_tool_call_ids.discard(pending_confirm_changes_id) + pending_confirm_changes_id = None + continue + except (json.JSONDecodeError, KeyError) as exc: + logger.debug("Could not parse user message as confirm_changes response: %s", type(exc).__name__) + + if pending_tool_call_ids: + logger.info( + f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results" + ) + for pending_call_id in pending_tool_call_ids: + logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}") + synthetic_result = ChatMessage( + role="tool", + contents=[ + FunctionResultContent( + call_id=pending_call_id, + result="Tool execution skipped - user provided follow-up message", + ) + ], + ) + sanitized.append(synthetic_result) + pending_tool_call_ids = None + pending_confirm_changes_id = None + + sanitized.append(msg) + pending_confirm_changes_id = None + continue + + if role_value == "tool": + if not pending_tool_call_ids: + continue + keep = False + for content in msg.contents or []: + if isinstance(content, FunctionResultContent): + call_id = str(content.call_id) + if call_id in pending_tool_call_ids: + keep = True + if call_id == pending_confirm_changes_id: + pending_confirm_changes_id = None + break + if keep: + sanitized.append(msg) + continue + + sanitized.append(msg) + pending_tool_call_ids = None + pending_confirm_changes_id = None + + return sanitized + + +def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: + """Remove duplicate messages while preserving order.""" + seen_keys: dict[Any, int] = {} + unique_messages: list[ChatMessage] = [] + + for idx, msg in enumerate(messages): + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent): + call_id = str(msg.contents[0].call_id) + key: Any = (role_value, call_id) + + if key in seen_keys: + existing_idx = seen_keys[key] + existing_msg = unique_messages[existing_idx] + + existing_result = None + if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent): + existing_result = existing_msg.contents[0].result + new_result = msg.contents[0].result + + if (not existing_result or existing_result == "") and new_result: + logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}") + unique_messages[existing_idx] = msg + else: + logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + elif ( + role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents) + ): + tool_call_ids = tuple( + sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id) + ) + key = (role_value, tool_call_ids) + + if key in seen_keys: + logger.info(f"Skipping duplicate assistant tool call at index {idx}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + else: + content_str = str([str(c) for c in msg.contents]) if msg.contents else "" + key = (role_value, hash(content_str)) + + if key in seen_keys: + logger.info(f"Skipping duplicate message at index {idx}: role={role_value}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + return unique_messages diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py new file mode 100644 index 00000000000..45c16afef48 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""State orchestration utilities.""" + +import json +from typing import Any + +from ag_ui.core import CustomEvent, EventType +from agent_framework import ChatMessage, TextContent + + +class StateManager: + """Coordinates state defaults, snapshots, and structured updates.""" + + def __init__( + self, + state_schema: dict[str, Any] | None, + predict_state_config: dict[str, dict[str, str]] | None, + require_confirmation: bool, + ) -> None: + self.state_schema = state_schema or {} + self.predict_state_config = predict_state_config or {} + self.require_confirmation = require_confirmation + self.current_state: dict[str, Any] = {} + + def initialize(self, initial_state: dict[str, Any] | None) -> dict[str, Any]: + """Initialize state with schema defaults.""" + self.current_state = (initial_state or {}).copy() + self._apply_schema_defaults() + return self.current_state + + def predict_state_event(self) -> CustomEvent | None: + """Create predict-state custom event when configured.""" + if not self.predict_state_config: + return None + + predict_state_value = [ + { + "state_key": state_key, + "tool": config["tool"], + "tool_argument": config["tool_argument"], + } + for state_key, config in self.predict_state_config.items() + ] + + return CustomEvent( + type=EventType.CUSTOM, + name="PredictState", + value=predict_state_value, + ) + + def initial_snapshot_event(self, event_bridge: Any) -> Any: + """Emit initial snapshot when schema and state present.""" + if not self.state_schema: + return None + self._apply_schema_defaults() + return event_bridge.create_state_snapshot_event(self.current_state) + + def state_context_message(self, is_new_user_turn: bool, conversation_has_tool_calls: bool) -> ChatMessage | None: + """Inject state context only when starting a new user turn.""" + if not self.current_state or not self.state_schema: + return None + if not is_new_user_turn or conversation_has_tool_calls: + return None + + state_json = json.dumps(self.current_state, indent=2) + return ChatMessage( + role="system", + contents=[ + TextContent( + text=( + "Current state of the application:\n" + f"{state_json}\n\n" + "When modifying state, you MUST include ALL existing data plus your changes.\n" + "For example, if adding one new item to a list, include ALL existing items PLUS the one new item.\n" + "Never replace existing data - always preserve and append or merge." + ) + ) + ], + ) + + def extract_state_updates(self, response_dict: dict[str, Any]) -> dict[str, Any]: + """Extract state updates from structured response payloads.""" + if self.state_schema: + return {key: response_dict[key] for key in self.state_schema.keys() if key in response_dict} + return {k: v for k, v in response_dict.items() if k != "message"} + + def apply_state_updates(self, updates: dict[str, Any]) -> None: + """Merge state updates into current state.""" + if not updates: + return + self.current_state.update(updates) + + def _apply_schema_defaults(self) -> None: + """Fill missing state fields based on schema hints.""" + for key, schema in self.state_schema.items(): + if key in self.current_state: + continue + if isinstance(schema, dict) and schema.get("type") == "array": # type: ignore + self.current_state[key] = [] + else: + self.current_state[key] = {} diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py new file mode 100644 index 00000000000..977c276627b --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tool handling helpers.""" + +import logging +from typing import Any + +from agent_framework import BaseChatClient, ChatAgent + +logger = logging.getLogger(__name__) + + +def collect_server_tools(agent: Any) -> list[Any]: + """Collect server tools from ChatAgent or duck-typed agent.""" + if isinstance(agent, ChatAgent): + tools_from_agent = agent.chat_options.tools + server_tools = list(tools_from_agent) if tools_from_agent else [] + logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools") + for tool in server_tools: + tool_name = getattr(tool, "name", "unknown") + approval_mode = getattr(tool, "approval_mode", None) + logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}") + return server_tools + + try: + chat_options_attr = getattr(agent, "chat_options", None) + if chat_options_attr is not None: + return getattr(chat_options_attr, "tools", None) or [] + except AttributeError: + return [] + return [] + + +def register_additional_client_tools(agent: Any, client_tools: list[Any] | None) -> None: + """Register client tools as additional declaration-only tools to avoid server execution.""" + if not client_tools: + return + + if isinstance(agent, ChatAgent): + chat_client = agent.chat_client + if isinstance(chat_client, BaseChatClient) and chat_client.function_invocation_configuration is not None: + chat_client.function_invocation_configuration.additional_tools = client_tools + logger.debug(f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)") + return + + try: + chat_client_attr = getattr(agent, "chat_client", None) + if chat_client_attr is not None: + fic = getattr(chat_client_attr, "function_invocation_configuration", None) + if fic is not None: + fic.additional_tools = client_tools # type: ignore[attr-defined] + logger.debug( + f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)" + ) + except AttributeError: + return + + +def merge_tools(server_tools: list[Any], client_tools: list[Any] | None) -> list[Any] | None: + """Combine server and client tools without overriding server metadata.""" + if not client_tools: + logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)") + return None + + server_tool_names = {getattr(tool, "name", None) for tool in server_tools} + unique_client_tools = [tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names] + + if not unique_client_tools: + logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter") + return None + + combined_tools: list[Any] = [] + if server_tools: + combined_tools.extend(server_tools) + combined_tools.extend(unique_client_tools) + logger.info( + f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools " + f"({len(server_tools)} server + {len(unique_client_tools)} unique client)" + ) + return combined_tools diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py index 6da46d819f4..654498e3716 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -21,7 +21,6 @@ AgentProtocol, AgentThread, ChatAgent, - ChatMessage, FunctionCallContent, FunctionResultContent, TextContent, @@ -271,144 +270,29 @@ async def run( AG-UI events """ from ._events import AgentFrameworkEventBridge + from ._message_adapters import agui_messages_to_snapshot_format + from ._orchestration._message_hygiene import deduplicate_messages, sanitize_tool_history + from ._orchestration._state_manager import StateManager + from ._orchestration._tooling import ( + collect_server_tools, + merge_tools, + register_additional_client_tools, + ) logger.info(f"Starting default agent run for thread_id={context.thread_id}, run_id={context.run_id}") - # Initialize state tracking - initial_state = context.input_data.get("state", {}) - current_state: dict[str, Any] = initial_state.copy() if initial_state else {} - - # Check if agent uses structured outputs (response_format) - # Use isinstance to narrow type for proper attribute access response_format = None if isinstance(context.agent, ChatAgent): response_format = context.agent.chat_options.response_format skip_text_content = response_format is not None - # Sanitizer: ensure tool results only follow assistant tool calls - # Also inject synthetic tool results for confirm_changes - def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: - sanitized: list[ChatMessage] = [] - pending_tool_call_ids: set[str] | None = None - pending_confirm_changes_id: str | None = None - - for msg in messages: - role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) - - if role_value == "assistant": - tool_ids = { - str(content.call_id) - for content in msg.contents or [] - if isinstance(content, FunctionCallContent) and content.call_id - } - # Check for confirm_changes tool call - confirm_changes_call = None - for content in msg.contents or []: - if isinstance(content, FunctionCallContent) and content.name == "confirm_changes": - confirm_changes_call = content - break - - sanitized.append(msg) - pending_tool_call_ids = tool_ids if tool_ids else None - pending_confirm_changes_id = ( - str(confirm_changes_call.call_id) - if confirm_changes_call and confirm_changes_call.call_id - else None - ) - continue - - if role_value == "user": - # Check if this user message is a confirm_changes response (JSON with "accepted" field) - # This must be checked BEFORE injecting synthetic results for pending tool calls - if pending_confirm_changes_id: - user_text = "" - for content in msg.contents or []: - if isinstance(content, TextContent): - user_text = content.text - break - - try: - parsed = json.loads(user_text) - if "accepted" in parsed: - # This is a confirm_changes response - inject synthetic tool result - logger.info( - f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}" - ) - synthetic_result = ChatMessage( - role="tool", - contents=[ - FunctionResultContent( - call_id=pending_confirm_changes_id, - result="Confirmed" if parsed.get("accepted") else "Rejected", - ) - ], - ) - sanitized.append(synthetic_result) - if pending_tool_call_ids: - pending_tool_call_ids.discard(pending_confirm_changes_id) - pending_confirm_changes_id = None - # Don't add the user message to sanitized - it's been converted to tool result - continue - except (json.JSONDecodeError, KeyError) as e: - # Failed to parse user message as confirm_changes response; continue normal processing - logger.debug(f"Could not parse user message as confirm_changes response: {e}") - - # Before processing user message, check if there are pending tool calls without results - # This happens when assistant made multiple tool calls but only some got results - # This is checked AFTER confirm_changes special handling above - if pending_tool_call_ids: - logger.info( - f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results" - ) - for pending_call_id in pending_tool_call_ids: - logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}") - synthetic_result = ChatMessage( - role="tool", - contents=[ - FunctionResultContent( - call_id=pending_call_id, - result="Tool execution skipped - user provided follow-up message", - ) - ], - ) - sanitized.append(synthetic_result) - pending_tool_call_ids = None - pending_confirm_changes_id = None - - # Normal user message processing - sanitized.append(msg) - pending_confirm_changes_id = None - continue - - if role_value == "tool": - if not pending_tool_call_ids: - continue - keep = False - for content in msg.contents or []: - if isinstance(content, FunctionResultContent): - call_id = str(content.call_id) - if call_id in pending_tool_call_ids: - keep = True - # Note: We do NOT remove call_id from pending here. - # This allows duplicate tool results to pass through sanitization - # so the deduplicator can choose the best one (prefer non-empty results). - # We only clear pending_tool_call_ids when a user message arrives. - if call_id == pending_confirm_changes_id: - # For confirm_changes specifically, we do want to clear it - # since we only expect one response - pending_confirm_changes_id = None - break - if keep: - sanitized.append(msg) - continue - - sanitized.append(msg) - pending_tool_call_ids = None - pending_confirm_changes_id = None - - return sanitized - - # Create event bridge + state_manager = StateManager( + state_schema=context.config.state_schema, + predict_state_config=context.config.predict_state_config, + require_confirmation=context.config.require_confirmation, + ) + current_state = state_manager.initialize(context.input_data.get("state", {})) + event_bridge = AgentFrameworkEventBridge( run_id=context.run_id, thread_id=context.thread_id, @@ -421,42 +305,19 @@ def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: yield event_bridge.create_run_started_event() - # Emit PredictState custom event if we have predictive state config - if context.config.predict_state_config: - from ag_ui.core import CustomEvent, EventType - - predict_state_value = [ - { - "state_key": state_key, - "tool": config["tool"], - "tool_argument": config["tool_argument"], - } - for state_key, config in context.config.predict_state_config.items() - ] - - yield CustomEvent( - type=EventType.CUSTOM, - name="PredictState", - value=predict_state_value, - ) + predict_event = state_manager.predict_state_event() + if predict_event: + yield predict_event - # If we have a state schema, ensure we emit initial state snapshot - if context.config.state_schema: - # Initialize missing state fields with appropriate empty values based on schema type - for key, schema in context.config.state_schema.items(): - if key not in current_state: - # Default to empty object; use empty array if schema specifies "array" type - current_state[key] = [] if isinstance(schema, dict) and schema.get("type") == "array" else {} # type: ignore - yield event_bridge.create_state_snapshot_event(current_state) + snapshot_event = state_manager.initial_snapshot_event(event_bridge) + if snapshot_event: + yield snapshot_event - # Create thread for context tracking thread = AgentThread() thread.metadata = { # type: ignore[attr-defined] "ag_ui_thread_id": context.thread_id, "ag_ui_run_id": context.run_id, } - - # Inject current state into thread metadata so agent can access it if current_state: thread.metadata["current_state"] = current_state # type: ignore[attr-defined] @@ -475,90 +336,24 @@ def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: for j, content in enumerate(msg.contents): content_type = type(content).__name__ if isinstance(content, TextContent): - logger.debug(f" Content {j}: {content_type} - {content.text}") + logger.debug(" Content %s: %s - text_length=%s", j, content_type, len(content.text)) elif isinstance(content, FunctionCallContent): - logger.debug(f" Content {j}: {content_type} - {content.name}({content.arguments})") + arg_length = len(str(content.arguments)) if content.arguments else 0 + logger.debug( + " Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length + ) elif isinstance(content, FunctionResultContent): + result_preview = type(content.result).__name__ if content.result is not None else "None" logger.debug( - f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}" + " Content %s: %s - call_id=%s, result_type=%s", + j, + content_type, + content.call_id, + result_preview, ) else: - logger.debug(f" Content {j}: {content_type} - {content}") - - # After getting sanitized_messages, deduplicate them - def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: - """Remove duplicate messages while preserving order. - - For tool results with the same call_id, prefer the one with actual data. - """ - seen_keys: dict[Any, int] = {} # key -> index in unique_messages (key can be various tuple types) - unique_messages: list[ChatMessage] = [] - - for idx, msg in enumerate(messages): - role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) - - # For tool messages, use call_id as unique key - if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent): - call_id = str(msg.contents[0].call_id) - key: Any = (role_value, call_id) - - # Check if we already have this tool result - if key in seen_keys: - existing_idx = seen_keys[key] - existing_msg = unique_messages[existing_idx] - - # Compare results - prefer non-empty over empty - existing_result = None - if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent): - existing_result = existing_msg.contents[0].result - new_result = msg.contents[0].result - - # Replace if existing is empty/None and new has data - if (not existing_result or existing_result == "") and new_result: - logger.info( - f"Replacing empty tool result at index {existing_idx} with data from index {idx}" - ) - unique_messages[existing_idx] = msg - else: - logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}") - continue - - seen_keys[key] = len(unique_messages) - unique_messages.append(msg) - - elif ( - role_value == "assistant" - and msg.contents - and any(isinstance(c, FunctionCallContent) for c in msg.contents) - ): - # For assistant messages with tool_calls, use the tool call IDs - tool_call_ids = tuple( - sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id) - ) - key = (role_value, tool_call_ids) - - if key in seen_keys: - logger.info(f"Skipping duplicate assistant tool call at index {idx}") - continue - - seen_keys[key] = len(unique_messages) - unique_messages.append(msg) - - else: - # For other messages (system, user, assistant without tools), hash the content - content_str = str([str(c) for c in msg.contents]) if msg.contents else "" - key = (role_value, hash(content_str)) - - if key in seen_keys: - logger.info(f"Skipping duplicate message at index {idx}: role={role_value}") - continue + logger.debug(f" Content {j}: {content_type}") - seen_keys[key] = len(unique_messages) - unique_messages.append(msg) - - return unique_messages - - # Then use it: sanitized_messages = sanitize_tool_history(raw_messages) provider_messages = deduplicate_messages(sanitized_messages) @@ -575,66 +370,45 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: for j, content in enumerate(msg.contents): content_type = type(content).__name__ if isinstance(content, TextContent): - logger.info(f" Content {j}: {content_type} - {content.text}") + logger.info(f" Content {j}: {content_type} - text_length={len(content.text)}") elif isinstance(content, FunctionCallContent): - logger.info(f" Content {j}: {content_type} - {content.name}({content.arguments})") + arg_length = len(str(content.arguments)) if content.arguments else 0 + logger.info(" Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length) elif isinstance(content, FunctionResultContent): + result_preview = type(content.result).__name__ if content.result is not None else "None" logger.info( - f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}" + " Content %s: %s - call_id=%s, result_type=%s", + j, + content_type, + content.call_id, + result_preview, ) else: - logger.info(f" Content {j}: {content_type} - {content}") - - # NOTE: For AG-UI, the client sends the full conversation history on each request. - # We should NOT add to thread.on_new_messages() as that would cause duplication. - # Instead, we pass messages directly to the agent via messages_to_run. + logger.info(f" Content {j}: {content_type}") - # Inject current state as system message context if we have state and this is a new user turn messages_to_run: list[Any] = [] - - # Check if the last message is from the user (new turn) vs assistant/tool (mid-execution) is_new_user_turn = False if provider_messages: last_msg = provider_messages[-1] - is_new_user_turn = last_msg.role.value == "user" + role_value = last_msg.role.value if hasattr(last_msg.role, "value") else str(last_msg.role) + is_new_user_turn = role_value == "user" - # Check if conversation has tool calls (indicates mid-execution) conversation_has_tool_calls = False for msg in provider_messages: - if msg.role.value == "assistant" and hasattr(msg, "contents") and msg.contents: + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + if role_value == "assistant" and hasattr(msg, "contents") and msg.contents: if any(isinstance(content, FunctionCallContent) for content in msg.contents): conversation_has_tool_calls = True break - # Only inject state context on new user turns AND when conversation doesn't have tool calls - # (tool calls indicate we're mid-execution, so state context was already injected) - if current_state and context.config.state_schema and is_new_user_turn and not conversation_has_tool_calls: - state_json = json.dumps(current_state, indent=2) - state_context_msg = ChatMessage( - role="system", - contents=[ - TextContent( - text=f"""Current state of the application: - {state_json} - - When modifying state, you MUST include ALL existing data plus your changes. - For example, if adding one new item to a list, include ALL existing items PLUS the one new item. - Never replace existing data - always preserve and append or merge.""" - ) - ], - ) + state_context_msg = state_manager.state_context_message( + is_new_user_turn=is_new_user_turn, conversation_has_tool_calls=conversation_has_tool_calls + ) + if state_context_msg: messages_to_run.append(state_context_msg) - # Add all provider messages to messages_to_run - # AG-UI sends full conversation history on each request, so we pass it directly to the agent messages_to_run.extend(provider_messages) - # Handle client tools for hybrid execution - # Client sends tool metadata, server merges with its own tools. - # Client tools have func=None (declaration-only), so @use_function_invocation - # will return the function call without executing (passes back to client). - from agent_framework import BaseChatClient - client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools")) logger.info(f"[TOOLS] Client sent {len(client_tools) if client_tools else 0} tools") if client_tools: @@ -643,85 +417,31 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: declaration_only = getattr(tool, "declaration_only", None) logger.info(f"[TOOLS] - Client tool: {tool_name}, declaration_only={declaration_only}") - # Extract server tools - use type narrowing when possible - server_tools: list[Any] = [] - if isinstance(context.agent, ChatAgent): - tools_from_agent = context.agent.chat_options.tools - server_tools = list(tools_from_agent) if tools_from_agent else [] - logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools") - for tool in server_tools: - tool_name = getattr(tool, "name", "unknown") - approval_mode = getattr(tool, "approval_mode", None) - logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}") - else: - # AgentProtocol allows duck-typed implementations - fallback to attribute access - # This supports test mocks and custom agent implementations - try: - chat_options_attr = getattr(context.agent, "chat_options", None) - if chat_options_attr is not None: - server_tools = getattr(chat_options_attr, "tools", None) or [] - except AttributeError: - pass - - # Register client tools as additional (declaration-only) so they are not executed on server - if client_tools: - if isinstance(context.agent, ChatAgent): - # Type-safe path for ChatAgent - chat_client = context.agent.chat_client - if ( - isinstance(chat_client, BaseChatClient) - and chat_client.function_invocation_configuration is not None - ): - chat_client.function_invocation_configuration.additional_tools = client_tools - logger.debug( - f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)" - ) - else: - # Fallback for AgentProtocol implementations (test mocks, custom agents) - try: - chat_client_attr = getattr(context.agent, "chat_client", None) - if chat_client_attr is not None: - fic = getattr(chat_client_attr, "function_invocation_configuration", None) - if fic is not None: - fic.additional_tools = client_tools # type: ignore[attr-defined] - logger.debug( - f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)" - ) - except AttributeError: - pass - - # For tools parameter: only pass if we have client tools to add - # If we pass tools=, it overrides the agent's configured tools and loses metadata like approval_mode - # So only pass tools when we need to add client tools on top of server tools - # IMPORTANT: Don't include client tools that duplicate server tools (same name) - tools_param = None - if client_tools: - # Get server tool names - server_tool_names = {getattr(tool, "name", None) for tool in server_tools} - - # Filter out client tools that duplicate server tools - unique_client_tools = [ - tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names - ] - - if unique_client_tools: - combined_tools: list[Any] = [] - if server_tools: - combined_tools.extend(server_tools) - combined_tools.extend(unique_client_tools) - tools_param = combined_tools - logger.info( - f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools ({len(server_tools)} server + {len(unique_client_tools)} unique client)" - ) - else: - logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter") - else: - logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)") + server_tools = collect_server_tools(context.agent) + register_additional_client_tools(context.agent, client_tools) + tools_param = merge_tools(server_tools, client_tools) - # Collect all updates to get the final structured output all_updates: list[Any] = [] update_count = 0 - async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=tools_param): + # Prepare metadata for chat client (Azure requires string values) + safe_metadata: dict[str, Any] = {} + thread_metadata = getattr(thread, "metadata", None) + if thread_metadata: + for key, value in thread_metadata.items(): + value_str = value if isinstance(value, str) else json.dumps(value) + if len(value_str) > 512: + value_str = value_str[:512] + safe_metadata[key] = value_str + + run_kwargs: dict[str, Any] = { + "thread": thread, + "tools": tools_param, + "metadata": safe_metadata, + } + if safe_metadata: + run_kwargs["store"] = True + + async for update in context.agent.run_stream(messages_to_run, **run_kwargs): update_count += 1 logger.info(f"[STREAM] Received update #{update_count} from agent") all_updates.append(update) @@ -733,23 +453,19 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: logger.info(f"[STREAM] Agent stream completed. Total updates: {update_count}") - # After agent completes, check if we should stop (waiting for user to confirm changes) if event_bridge.should_stop_after_confirm: logger.info("Stopping run after confirm_changes - waiting for user response") yield event_bridge.create_run_finished_event() return - # Check if there are pending tool calls (declaration-only tools that weren't executed) - # These need ToolCallEndEvent to signal the client to execute them - # Only emit for tool calls that haven't already had ToolCallEndEvent emitted - # (approval-required tools already had their end event emitted) if event_bridge.pending_tool_calls: pending_without_end = [ tc for tc in event_bridge.pending_tool_calls if tc.get("id") not in event_bridge.tool_calls_ended ] if pending_without_end: logger.info( - f"Found {len(pending_without_end)} pending tool calls without end event - emitting ToolCallEndEvent" + "Found %s pending tool calls without end event - emitting ToolCallEndEvent", + len(pending_without_end), ) for tool_call in pending_without_end: tool_call_id = tool_call.get("id") @@ -760,76 +476,47 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: logger.info(f"Emitting ToolCallEndEvent for declaration-only tool call '{tool_call_id}'") yield end_event - # After streaming completes, check if agent has response_format and extract structured output if all_updates and response_format: from agent_framework import AgentRunResponse from pydantic import BaseModel logger.info(f"Processing structured output, update count: {len(all_updates)}") - - # Convert streaming updates to final response to get the structured output final_response = AgentRunResponse.from_agent_run_response_updates( all_updates, output_format_type=response_format ) if final_response.value and isinstance(final_response.value, BaseModel): - # Convert Pydantic model to dict response_dict = final_response.value.model_dump(mode="json", exclude_none=True) - logger.info(f"Received structured output: {list(response_dict.keys())}") - - # Extract state fields based on state_schema - state_updates: dict[str, Any] = {} + logger.info(f"Received structured output keys: {list(response_dict.keys())}") - if context.config.state_schema: - # Use state_schema to determine which fields are state - for state_key in context.config.state_schema.keys(): - if state_key in response_dict: - state_updates[state_key] = response_dict[state_key] - else: - # No schema: treat all non-message fields as state - state_updates = {k: v for k, v in response_dict.items() if k != "message"} - - # Apply state updates if any found + state_updates = state_manager.extract_state_updates(response_dict) if state_updates: - current_state.update(state_updates) - - # Emit StateSnapshotEvent with the updated state + state_manager.apply_state_updates(state_updates) state_snapshot = event_bridge.create_state_snapshot_event(current_state) yield state_snapshot logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}") - # If there's a message field, emit it as chat text if "message" in response_dict and response_dict["message"]: message_id = generate_event_id() yield TextMessageStartEvent(message_id=message_id, role="assistant") yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"]) yield TextMessageEndEvent(message_id=message_id) - logger.info(f"Emitted conversational message: {response_dict['message'][:100]}...") + logger.info(f"Emitted conversational message with length={len(response_dict['message'])}") logger.info(f"[FINALIZE] Checking for unclosed message. current_message_id={event_bridge.current_message_id}") if event_bridge.current_message_id: logger.info(f"[FINALIZE] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}") yield event_bridge.create_message_end_event(event_bridge.current_message_id) - # Emit MessagesSnapshotEvent to persist the final assistant text message - from ._message_adapters import agui_messages_to_snapshot_format - - # Build the final assistant message with accumulated text content assistant_text_message = { "id": event_bridge.current_message_id, "role": "assistant", "content": event_bridge.accumulated_text_content, } - # Convert input messages to snapshot format (normalize content structure) - # event_bridge.input_messages are already in AG-UI format, just need normalization converted_input_messages = agui_messages_to_snapshot_format(event_bridge.input_messages) - - # Build complete messages array - # Include: input messages + any pending tool calls/results + final text message all_messages = converted_input_messages.copy() - # Add assistant message with tool calls if any if event_bridge.pending_tool_calls: tool_call_message = { "id": generate_event_id(), @@ -838,18 +525,16 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: } all_messages.append(tool_call_message) - # Add tool results if any all_messages.extend(event_bridge.tool_results.copy()) - - # Add final text message all_messages.append(assistant_text_message) messages_snapshot = MessagesSnapshotEvent( messages=all_messages, # type: ignore[arg-type] ) logger.info( - f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(all_messages)} messages " - f"(text content length: {len(event_bridge.accumulated_text_content)})" + "[FINALIZE] Emitting MessagesSnapshotEvent with %s messages (text content length: %s)", + len(all_messages), + len(event_bridge.accumulated_text_content), ) yield messages_snapshot else: diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 38db36ab2a7..3770dfe6c4b 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b251120" +version = "1.0.0b251204" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/packages/ag-ui/tests/__init__.py b/python/packages/ag-ui/tests/__init__.py new file mode 100644 index 00000000000..2a50eae8941 --- /dev/null +++ b/python/packages/ag-ui/tests/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/tests/test_ag_ui_client.py b/python/packages/ag-ui/tests/test_ag_ui_client.py index cfececd771f..09570c1be42 100644 --- a/python/packages/ag-ui/tests/test_ag_ui_client.py +++ b/python/packages/ag-ui/tests/test_ag_ui_client.py @@ -1,10 +1,61 @@ +# Copyright (c) Microsoft. All rights reserved. + """Tests for AGUIChatClient.""" import json - -from agent_framework import ChatMessage, ChatOptions, FunctionCallContent, Role, ai_function +from collections.abc import AsyncGenerator, AsyncIterable, MutableSequence +from typing import Any + +from agent_framework import ( + ChatMessage, + ChatOptions, + ChatResponseUpdate, + FunctionCallContent, + Role, + TextContent, + ai_function, +) +from agent_framework._types import ChatResponse +from pytest import MonkeyPatch from agent_framework_ag_ui._client import AGUIChatClient, ServerFunctionCallContent +from agent_framework_ag_ui._http_service import AGUIHttpService + + +class TestableAGUIChatClient(AGUIChatClient): + """Testable wrapper exposing protected helpers.""" + + @property + def http_service(self) -> AGUIHttpService: + """Expose http service for monkeypatching.""" + return self._http_service + + def extract_state_from_messages( + self, messages: list[ChatMessage] + ) -> tuple[list[ChatMessage], dict[str, Any] | None]: + """Expose state extraction helper.""" + return self._extract_state_from_messages(messages) + + def convert_messages_to_agui_format(self, messages: list[ChatMessage]) -> list[dict[str, Any]]: + """Expose message conversion helper.""" + return self._convert_messages_to_agui_format(messages) + + def get_thread_id(self, chat_options: ChatOptions) -> str: + """Expose thread id helper.""" + return self._get_thread_id(chat_options) + + async def inner_get_streaming_response( + self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + ) -> AsyncIterable[ChatResponseUpdate]: + """Proxy to protected streaming call.""" + async for update in self._inner_get_streaming_response(messages=messages, chat_options=chat_options): + yield update + + async def inner_get_response( + self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + ) -> ChatResponse: + """Proxy to protected response call.""" + return await self._inner_get_response(messages=messages, chat_options=chat_options) class TestAGUIChatClient: @@ -12,25 +63,25 @@ class TestAGUIChatClient: async def test_client_initialization(self) -> None: """Test client initialization.""" - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") - assert client._http_service is not None - assert client._http_service.endpoint.startswith("http://localhost:8888") + assert client.http_service is not None + assert client.http_service.endpoint.startswith("http://localhost:8888") async def test_client_context_manager(self) -> None: """Test client as async context manager.""" - async with AGUIChatClient(endpoint="http://localhost:8888/") as client: + async with TestableAGUIChatClient(endpoint="http://localhost:8888/") as client: assert client is not None async def test_extract_state_from_messages_no_state(self) -> None: """Test state extraction when no state is present.""" - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") messages = [ ChatMessage(role="user", text="Hello"), ChatMessage(role="assistant", text="Hi there"), ] - result_messages, state = client._extract_state_from_messages(messages) + result_messages, state = client.extract_state_from_messages(messages) assert result_messages == messages assert state is None @@ -39,7 +90,7 @@ async def test_extract_state_from_messages_with_state(self) -> None: """Test state extraction from last message.""" import base64 - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") state_data = {"key": "value", "count": 42} state_json = json.dumps(state_data) @@ -55,7 +106,7 @@ async def test_extract_state_from_messages_with_state(self) -> None: ), ] - result_messages, state = client._extract_state_from_messages(messages) + result_messages, state = client.extract_state_from_messages(messages) assert len(result_messages) == 1 assert result_messages[0].text == "Hello" @@ -65,7 +116,7 @@ async def test_extract_state_invalid_json(self) -> None: """Test state extraction with invalid JSON.""" import base64 - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") invalid_json = "not valid json" state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8") @@ -79,20 +130,20 @@ async def test_extract_state_invalid_json(self) -> None: ), ] - result_messages, state = client._extract_state_from_messages(messages) + result_messages, state = client.extract_state_from_messages(messages) assert result_messages == messages assert state is None async def test_convert_messages_to_agui_format(self) -> None: """Test message conversion to AG-UI format.""" - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") messages = [ ChatMessage(role=Role.USER, text="What is the weather?"), ChatMessage(role=Role.ASSISTANT, text="Let me check.", message_id="msg_123"), ] - agui_messages = client._convert_messages_to_agui_format(messages) + agui_messages = client.convert_messages_to_agui_format(messages) assert len(agui_messages) == 2 assert agui_messages[0]["role"] == "user" @@ -103,24 +154,24 @@ async def test_convert_messages_to_agui_format(self) -> None: async def test_get_thread_id_from_metadata(self) -> None: """Test thread ID extraction from metadata.""" - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") chat_options = ChatOptions(metadata={"thread_id": "existing_thread_123"}) - thread_id = client._get_thread_id(chat_options) + thread_id = client.get_thread_id(chat_options) assert thread_id == "existing_thread_123" async def test_get_thread_id_generation(self) -> None: """Test automatic thread ID generation.""" - client = AGUIChatClient(endpoint="http://localhost:8888/") + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") chat_options = ChatOptions() - thread_id = client._get_thread_id(chat_options) + thread_id = client.get_thread_id(chat_options) assert thread_id.startswith("thread_") assert len(thread_id) > 7 - async def test_get_streaming_response(self, monkeypatch) -> None: + async def test_get_streaming_response(self, monkeypatch: MonkeyPatch) -> None: """Test streaming response method.""" mock_events = [ {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, @@ -129,26 +180,32 @@ async def test_get_streaming_response(self, monkeypatch) -> None: {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, ] - async def mock_post_run(*args, **kwargs): + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: for event in mock_events: yield event - client = AGUIChatClient(endpoint="http://localhost:8888/") - monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) messages = [ChatMessage(role="user", text="Test message")] chat_options = ChatOptions() - updates = [] - async for update in client._inner_get_streaming_response(messages=messages, chat_options=chat_options): + updates: list[ChatResponseUpdate] = [] + async for update in client.inner_get_streaming_response(messages=messages, chat_options=chat_options): updates.append(update) assert len(updates) == 4 + assert updates[0].additional_properties is not None assert updates[0].additional_properties["thread_id"] == "thread_1" - assert updates[1].contents[0].text == "Hello" - assert updates[2].contents[0].text == " world" - async def test_get_response_non_streaming(self, monkeypatch) -> None: + first_content = updates[1].contents[0] + second_content = updates[2].contents[0] + assert isinstance(first_content, TextContent) + assert isinstance(second_content, TextContent) + assert first_content.text == "Hello" + assert second_content.text == " world" + + async def test_get_response_non_streaming(self, monkeypatch: MonkeyPatch) -> None: """Test non-streaming response method.""" mock_events = [ {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, @@ -156,23 +213,23 @@ async def test_get_response_non_streaming(self, monkeypatch) -> None: {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, ] - async def mock_post_run(*args, **kwargs): + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: for event in mock_events: yield event - client = AGUIChatClient(endpoint="http://localhost:8888/") - monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) messages = [ChatMessage(role="user", text="Test message")] chat_options = ChatOptions() - response = await client._inner_get_response(messages=messages, chat_options=chat_options) + response = await client.inner_get_response(messages=messages, chat_options=chat_options) assert response is not None assert len(response.messages) > 0 assert "Complete response" in response.text - async def test_tool_handling(self, monkeypatch) -> None: + async def test_tool_handling(self, monkeypatch: MonkeyPatch) -> None: """Test that client tool metadata is sent to server. Client tool metadata (name, description, schema) is sent to server for planning. @@ -191,28 +248,29 @@ def test_tool(param: str) -> str: {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, ] - async def mock_post_run(*args, **kwargs): + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: # Client tool metadata should be sent to server - tools = kwargs.get("tools") + tools: list[dict[str, Any]] | None = kwargs.get("tools") assert tools is not None assert len(tools) == 1 - assert tools[0]["name"] == "test_tool" - assert tools[0]["description"] == "Test tool." - assert "parameters" in tools[0] + tool_entry = tools[0] + assert tool_entry["name"] == "test_tool" + assert tool_entry["description"] == "Test tool." + assert "parameters" in tool_entry for event in mock_events: yield event - client = AGUIChatClient(endpoint="http://localhost:8888/") - monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) messages = [ChatMessage(role="user", text="Test with tools")] chat_options = ChatOptions(tools=[test_tool]) - response = await client._inner_get_response(messages=messages, chat_options=chat_options) + response = await client.inner_get_response(messages=messages, chat_options=chat_options) assert response is not None - async def test_server_tool_calls_unwrapped_after_invocation(self, monkeypatch) -> None: + async def test_server_tool_calls_unwrapped_after_invocation(self, monkeypatch: MonkeyPatch) -> None: """Ensure server-side tool calls are exposed as FunctionCallContent after processing.""" mock_events = [ @@ -222,17 +280,17 @@ async def test_server_tool_calls_unwrapped_after_invocation(self, monkeypatch) - {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, ] - async def mock_post_run(*args, **kwargs): + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: for event in mock_events: yield event - client = AGUIChatClient(endpoint="http://localhost:8888/") - monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) messages = [ChatMessage(role="user", text="Test server tool execution")] chat_options = ChatOptions() - updates = [] + updates: list[ChatResponseUpdate] = [] async for update in client.get_streaming_response(messages, chat_options=chat_options): updates.append(update) @@ -245,7 +303,7 @@ async def mock_post_run(*args, **kwargs): isinstance(content, ServerFunctionCallContent) for update in updates for content in update.contents ) - async def test_server_tool_calls_not_executed_locally(self, monkeypatch) -> None: + async def test_server_tool_calls_not_executed_locally(self, monkeypatch: MonkeyPatch) -> None: """Server tools should not trigger local function invocation even when client tools exist.""" @ai_function @@ -260,18 +318,18 @@ def client_tool() -> str: {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, ] - async def mock_post_run(*args, **kwargs): + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: for event in mock_events: yield event - async def fake_auto_invoke(*args, **kwargs): + async def fake_auto_invoke(*args: object, **kwargs: Any) -> None: function_call = kwargs.get("function_call_content") or args[0] raise AssertionError(f"Unexpected local execution of server tool: {getattr(function_call, 'name', '?')}") monkeypatch.setattr("agent_framework._tools._auto_invoke_function", fake_auto_invoke) - client = AGUIChatClient(endpoint="http://localhost:8888/") - monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) messages = [ChatMessage(role="user", text="Test server tool execution")] chat_options = ChatOptions(tool_choice="auto", tools=[client_tool]) @@ -279,7 +337,7 @@ async def fake_auto_invoke(*args, **kwargs): async for _ in client.get_streaming_response(messages, chat_options=chat_options): pass - async def test_state_transmission(self, monkeypatch) -> None: + async def test_state_transmission(self, monkeypatch: MonkeyPatch) -> None: """Test state is properly transmitted to server.""" import base64 @@ -302,16 +360,16 @@ async def test_state_transmission(self, monkeypatch) -> None: {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, ] - async def mock_post_run(*args, **kwargs): + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: assert kwargs.get("state") == state_data for event in mock_events: yield event - client = AGUIChatClient(endpoint="http://localhost:8888/") - monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + client = TestableAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) chat_options = ChatOptions() - response = await client._inner_get_response(messages=messages, chat_options=chat_options) + response = await client.inner_get_response(messages=messages, chat_options=chat_options) assert response is not None diff --git a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py index dbf0160ae63..beb6f8af2ce 100644 --- a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py @@ -3,21 +3,30 @@ """Comprehensive tests for AgentFrameworkAgent (_agent.py).""" import json +import sys +from collections.abc import AsyncIterator, MutableSequence +from pathlib import Path +from typing import Any import pytest -from agent_framework import ChatAgent, TextContent +from agent_framework import ChatAgent, ChatMessage, ChatOptions, TextContent from agent_framework._types import ChatResponseUpdate +from pydantic import BaseModel + +sys.path.insert(0, str(Path(__file__).parent)) +from test_helpers_ag_ui import StreamingChatClientStub async def test_agent_initialization_basic(): """Test basic agent initialization without state schema.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) assert wrapper.name == "test_agent" @@ -30,12 +39,13 @@ async def test_agent_initialization_with_state_schema(): """Test agent initialization with state_schema.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) - state_schema = {"document": {"type": "string"}} + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) + state_schema: dict[str, dict[str, Any]] = {"document": {"type": "string"}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) assert wrapper.config.state_schema == state_schema @@ -45,31 +55,56 @@ async def test_agent_initialization_with_predict_state_config(): """Test agent initialization with predict_state_config.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config) assert wrapper.config.predict_state_config == predict_config +async def test_agent_initialization_with_pydantic_state_schema(): + """Test agent initialization when state_schema is provided as Pydantic model/class.""" + from agent_framework.ag_ui import AgentFrameworkAgent + + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + class MyState(BaseModel): + document: str + tags: list[str] = [] + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) + + wrapper_class_schema = AgentFrameworkAgent(agent=agent, state_schema=MyState) + wrapper_instance_schema = AgentFrameworkAgent(agent=agent, state_schema=MyState(document="hi")) + + expected_properties = MyState.model_json_schema().get("properties", {}) + assert wrapper_class_schema.config.state_schema == expected_properties + assert wrapper_instance_schema.config.state_schema == expected_properties + + async def test_run_started_event_emission(): """Test RunStartedEvent is emitted at start of run.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -83,11 +118,12 @@ async def test_predict_state_custom_event_emission(): """Test PredictState CustomEvent is emitted when predict_state_config is present.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) predict_config = { "document": {"tool": "write_doc", "tool_argument": "content"}, "summary": {"tool": "summarize", "tool_argument": "text"}, @@ -96,7 +132,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): input_data = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -114,11 +150,12 @@ async def test_initial_state_snapshot_with_schema(): """Test initial StateSnapshotEvent emission when state_schema present.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) state_schema = {"document": {"type": "string"}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) @@ -127,7 +164,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): "state": {"document": "Initial content"}, } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -143,17 +180,18 @@ async def test_state_initialization_object_type(): """Test state initialization with object type in schema.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) - state_schema = {"recipe": {"type": "object", "properties": {}}} + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) + state_schema: dict[str, dict[str, Any]] = {"recipe": {"type": "object", "properties": {}}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) input_data = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -169,17 +207,18 @@ async def test_state_initialization_array_type(): """Test state initialization with array type in schema.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) - state_schema = {"steps": {"type": "array", "items": {}}} + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) + state_schema: dict[str, dict[str, Any]] = {"steps": {"type": "array", "items": {}}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) input_data = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -195,16 +234,17 @@ async def test_run_finished_event_emission(): """Test RunFinishedEvent is emitted at end of run.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -216,11 +256,12 @@ async def test_tool_result_confirm_changes_accepted(): """Test confirm_changes tool result handling when accepted.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Document updated")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Document updated")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent( agent=agent, state_schema={"document": {"type": "string"}}, @@ -228,8 +269,8 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): ) # Simulate tool result message with acceptance - tool_result = {"accepted": True, "steps": []} - input_data = { + tool_result: dict[str, Any] = {"accepted": True, "steps": []} + input_data: dict[str, Any] = { "messages": [ { "role": "tool", # Tool result from UI @@ -240,7 +281,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): "state": {"document": "Updated content"}, } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -262,16 +303,17 @@ async def test_tool_result_confirm_changes_rejected(): """Test confirm_changes tool result handling when rejected.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Simulate tool result message with rejection - tool_result = {"accepted": False, "steps": []} - input_data = { + tool_result: dict[str, Any] = {"accepted": False, "steps": []} + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -281,7 +323,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): ], } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -295,22 +337,23 @@ async def test_tool_result_function_approval_accepted(): """Test function approval tool result when steps are accepted.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Simulate tool result with multiple steps - tool_result = { + tool_result: dict[str, Any] = { "accepted": True, "steps": [ {"id": "step1", "description": "Send email", "status": "enabled"}, {"id": "step2", "description": "Create calendar event", "status": "enabled"}, ], } - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -320,7 +363,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): ], } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -340,19 +383,20 @@ async def test_tool_result_function_approval_rejected(): """Test function approval tool result when rejected.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Simulate tool result rejection with steps - tool_result = { + tool_result: dict[str, Any] = { "accepted": False, "steps": [{"id": "step1", "description": "Send email", "status": "disabled"}], } - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -362,7 +406,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): ], } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -376,17 +420,16 @@ async def test_thread_metadata_tracking(): """Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id.""" from agent_framework.ag_ui import AgentFrameworkAgent - thread_metadata = {} + thread_metadata: dict[str, Any] = {} - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - # Capture thread metadata from kwargs - nonlocal thread_metadata - if "thread" in kwargs: - thread_metadata = kwargs["thread"].metadata - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if chat_options.metadata: + thread_metadata.update(chat_options.metadata) + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data = { @@ -395,28 +438,28 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): "run_id": "test_run_456", } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) - # Check thread metadata was set - # Note: This test may need adjustment based on actual thread passing mechanism + assert thread_metadata.get("ag_ui_thread_id") == "test_thread_123" + assert thread_metadata.get("ag_ui_run_id") == "test_run_456" async def test_state_context_injection(): """Test that current state is injected into thread metadata.""" from agent_framework.ag_ui import AgentFrameworkAgent - thread_metadata = {} + thread_metadata: dict[str, Any] = {} - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - # Track if state context message was added - nonlocal thread_metadata - # In actual implementation, thread is passed and state is in metadata - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if chat_options.metadata: + thread_metadata.update(chat_options.metadata) + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent( agent=agent, state_schema={"document": {"type": "string"}}, @@ -427,27 +470,31 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): "state": {"document": "Test content"}, } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) - # State should be injected - this is validated by agent execution flow + current_state = thread_metadata.get("current_state") + if isinstance(current_state, str): + current_state = json.loads(current_state) + assert current_state == {"document": "Test content"} async def test_no_messages_provided(): """Test handling when no messages are provided.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) - input_data = {"messages": []} + input_data: dict[str, Any] = {"messages": []} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -461,16 +508,17 @@ async def test_message_end_event_emission(): """Test TextMessageEndEvent is emitted for assistant messages.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello world")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Hello world")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) - input_data = {"messages": [{"role": "user", "content": "Hi"}]} + input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -488,19 +536,20 @@ async def test_error_handling_with_exception(): """Test that exceptions during agent execution are re-raised.""" from agent_framework.ag_ui import AgentFrameworkAgent - class FailingChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - if False: - yield - raise RuntimeError("Simulated failure") + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if False: + yield ChatResponseUpdate(contents=[]) + raise RuntimeError("Simulated failure") - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=FailingChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) - input_data = {"messages": [{"role": "user", "content": "Hi"}]} + input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}]} with pytest.raises(RuntimeError, match="Simulated failure"): - async for event in wrapper.run_agent(input_data): + async for _ in wrapper.run_agent(input_data): pass @@ -508,18 +557,18 @@ async def test_json_decode_error_in_tool_result(): """Test handling of orphaned tool result - should be sanitized out.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - # Should not be called since orphaned tool result is dropped - if False: - yield - raise AssertionError("ChatClient should not be called with orphaned tool result") + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if False: + yield ChatResponseUpdate(contents=[]) + raise AssertionError("ChatClient should not be called with orphaned tool result") - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Send invalid JSON as tool result without preceding tool call - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -529,7 +578,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): ], } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -545,11 +594,12 @@ async def test_suppressed_summary_with_document_state(): """Test suppressed summary uses document state for confirmation message.""" from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Response")]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text="Response")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) wrapper = AgentFrameworkAgent( agent=agent, state_schema={"document": {"type": "string"}}, @@ -558,8 +608,8 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): ) # Simulate confirmation with document state - tool_result = {"accepted": True, "steps": []} - input_data = { + tool_result: dict[str, Any] = {"accepted": True, "steps": []} + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -570,7 +620,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): "state": {"document": "This is the beginning of a document. It contains important information."}, } - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) diff --git a/python/packages/ag-ui/tests/test_backend_tool_rendering.py b/python/packages/ag-ui/tests/test_backend_tool_rendering.py index fbd27ee8bb5..6fefc146659 100644 --- a/python/packages/ag-ui/tests/test_backend_tool_rendering.py +++ b/python/packages/ag-ui/tests/test_backend_tool_rendering.py @@ -2,6 +2,8 @@ """Tests for backend tool rendering.""" +from typing import cast + from ag_ui.core import ( TextMessageContentEvent, TextMessageStartEvent, @@ -119,6 +121,9 @@ async def test_multiple_tool_results(): assert isinstance(events[end_idx], ToolCallEndEvent) assert isinstance(events[result_idx], ToolCallResultEvent) - assert events[end_idx].tool_call_id == f"tool-{i + 1}" - assert events[result_idx].tool_call_id == f"tool-{i + 1}" - assert f"Result {i + 1}" in events[result_idx].content + end_event = cast(ToolCallEndEvent, events[end_idx]) + result_event = cast(ToolCallResultEvent, events[result_idx]) + + assert end_event.tool_call_id == f"tool-{i + 1}" + assert result_event.tool_call_id == f"tool-{i + 1}" + assert f"Result {i + 1}" in result_event.content diff --git a/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py b/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py index 205182d58df..ab355d8995b 100644 --- a/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py +++ b/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py @@ -14,7 +14,7 @@ @pytest.fixture -def sample_steps(): +def sample_steps() -> list[dict[str, str]]: """Sample steps for testing approval messages.""" return [ {"description": "Step 1: Do something", "status": "enabled"}, @@ -24,7 +24,7 @@ def sample_steps(): @pytest.fixture -def all_enabled_steps(): +def all_enabled_steps() -> list[dict[str, str]]: """All steps enabled.""" return [ {"description": "Task A", "status": "enabled"}, @@ -34,7 +34,7 @@ def all_enabled_steps(): @pytest.fixture -def empty_steps(): +def empty_steps() -> list[dict[str, str]]: """Empty steps list.""" return [] @@ -42,7 +42,7 @@ def empty_steps(): class TestDefaultConfirmationStrategy: """Tests for DefaultConfirmationStrategy.""" - def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None: strategy = DefaultConfirmationStrategy() message = strategy.on_approval_accepted(sample_steps) @@ -52,7 +52,7 @@ def test_on_approval_accepted_with_enabled_steps(self, sample_steps): assert "Step 3" not in message # Disabled step shouldn't appear assert "All steps completed successfully!" in message - def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None: strategy = DefaultConfirmationStrategy() message = strategy.on_approval_accepted(all_enabled_steps) @@ -61,28 +61,28 @@ def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): assert "Task B" in message assert "Task C" in message - def test_on_approval_accepted_with_empty_steps(self, empty_steps): + def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None: strategy = DefaultConfirmationStrategy() message = strategy.on_approval_accepted(empty_steps) assert "Executing 0 approved steps" in message assert "All steps completed successfully!" in message - def test_on_approval_rejected(self, sample_steps): + def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None: strategy = DefaultConfirmationStrategy() message = strategy.on_approval_rejected(sample_steps) assert "No problem!" in message assert "What would you like me to change" in message - def test_on_state_confirmed(self): + def test_on_state_confirmed(self) -> None: strategy = DefaultConfirmationStrategy() message = strategy.on_state_confirmed() assert "Changes confirmed" in message assert "successfully" in message - def test_on_state_rejected(self): + def test_on_state_rejected(self) -> None: strategy = DefaultConfirmationStrategy() message = strategy.on_state_rejected() @@ -93,7 +93,7 @@ def test_on_state_rejected(self): class TestTaskPlannerConfirmationStrategy: """Tests for TaskPlannerConfirmationStrategy.""" - def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None: strategy = TaskPlannerConfirmationStrategy() message = strategy.on_approval_accepted(sample_steps) @@ -103,7 +103,7 @@ def test_on_approval_accepted_with_enabled_steps(self, sample_steps): assert "Step 3" not in message assert "All tasks completed successfully!" in message - def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None: strategy = TaskPlannerConfirmationStrategy() message = strategy.on_approval_accepted(all_enabled_steps) @@ -112,28 +112,28 @@ def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): assert "2. Task B" in message assert "3. Task C" in message - def test_on_approval_accepted_with_empty_steps(self, empty_steps): + def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None: strategy = TaskPlannerConfirmationStrategy() message = strategy.on_approval_accepted(empty_steps) assert "Executing your requested tasks" in message assert "All tasks completed successfully!" in message - def test_on_approval_rejected(self, sample_steps): + def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None: strategy = TaskPlannerConfirmationStrategy() message = strategy.on_approval_rejected(sample_steps) assert "No problem!" in message assert "revise the plan" in message - def test_on_state_confirmed(self): + def test_on_state_confirmed(self) -> None: strategy = TaskPlannerConfirmationStrategy() message = strategy.on_state_confirmed() assert "Tasks confirmed" in message assert "ready to execute" in message - def test_on_state_rejected(self): + def test_on_state_rejected(self) -> None: strategy = TaskPlannerConfirmationStrategy() message = strategy.on_state_rejected() @@ -144,7 +144,7 @@ def test_on_state_rejected(self): class TestRecipeConfirmationStrategy: """Tests for RecipeConfirmationStrategy.""" - def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None: strategy = RecipeConfirmationStrategy() message = strategy.on_approval_accepted(sample_steps) @@ -154,7 +154,7 @@ def test_on_approval_accepted_with_enabled_steps(self, sample_steps): assert "Step 3" not in message assert "Recipe updated successfully!" in message - def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None: strategy = RecipeConfirmationStrategy() message = strategy.on_approval_accepted(all_enabled_steps) @@ -163,28 +163,28 @@ def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): assert "2. Task B" in message assert "3. Task C" in message - def test_on_approval_accepted_with_empty_steps(self, empty_steps): + def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None: strategy = RecipeConfirmationStrategy() message = strategy.on_approval_accepted(empty_steps) assert "Updating your recipe" in message assert "Recipe updated successfully!" in message - def test_on_approval_rejected(self, sample_steps): + def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None: strategy = RecipeConfirmationStrategy() message = strategy.on_approval_rejected(sample_steps) assert "No problem!" in message assert "ingredients or steps" in message - def test_on_state_confirmed(self): + def test_on_state_confirmed(self) -> None: strategy = RecipeConfirmationStrategy() message = strategy.on_state_confirmed() assert "Recipe changes applied" in message assert "successfully" in message - def test_on_state_rejected(self): + def test_on_state_rejected(self) -> None: strategy = RecipeConfirmationStrategy() message = strategy.on_state_rejected() @@ -195,7 +195,7 @@ def test_on_state_rejected(self): class TestDocumentWriterConfirmationStrategy: """Tests for DocumentWriterConfirmationStrategy.""" - def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None: strategy = DocumentWriterConfirmationStrategy() message = strategy.on_approval_accepted(sample_steps) @@ -205,7 +205,7 @@ def test_on_approval_accepted_with_enabled_steps(self, sample_steps): assert "Step 3" not in message assert "Document updated successfully!" in message - def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None: strategy = DocumentWriterConfirmationStrategy() message = strategy.on_approval_accepted(all_enabled_steps) @@ -214,27 +214,27 @@ def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): assert "2. Task B" in message assert "3. Task C" in message - def test_on_approval_accepted_with_empty_steps(self, empty_steps): + def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None: strategy = DocumentWriterConfirmationStrategy() message = strategy.on_approval_accepted(empty_steps) assert "Applying your edits" in message assert "Document updated successfully!" in message - def test_on_approval_rejected(self, sample_steps): + def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None: strategy = DocumentWriterConfirmationStrategy() message = strategy.on_approval_rejected(sample_steps) assert "No problem!" in message assert "keep or modify" in message - def test_on_state_confirmed(self): + def test_on_state_confirmed(self) -> None: strategy = DocumentWriterConfirmationStrategy() message = strategy.on_state_confirmed() assert "Document edits applied!" in message - def test_on_state_rejected(self): + def test_on_state_rejected(self) -> None: strategy = DocumentWriterConfirmationStrategy() message = strategy.on_state_rejected() diff --git a/python/packages/ag-ui/tests/test_document_writer_flow.py b/python/packages/ag-ui/tests/test_document_writer_flow.py index d46b9bf7a08..1ea164beef8 100644 --- a/python/packages/ag-ui/tests/test_document_writer_flow.py +++ b/python/packages/ag-ui/tests/test_document_writer_flow.py @@ -2,7 +2,7 @@ """Tests for document writer predictive state flow with confirm_changes.""" -from ag_ui.core import EventType +from ag_ui.core import EventType, StateDeltaEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent from agent_framework import FunctionCallContent, FunctionResultContent, TextContent from agent_framework._types import AgentRunResponseUpdate @@ -35,16 +35,12 @@ async def test_streaming_document_with_state_deltas(): assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1) # Second chunk - incomplete JSON, should try partial extraction - tool_call_chunk2 = FunctionCallContent( - call_id="call_123", - name=None, # Name only in first chunk - arguments=" upon a time", - ) + tool_call_chunk2 = FunctionCallContent(call_id="call_123", name="write_document_local", arguments=" upon a time") update2 = AgentRunResponseUpdate(contents=[tool_call_chunk2]) events2 = await bridge.from_agent_run_update(update2) # Should emit StateDeltaEvent with partial document - state_deltas = [e for e in events2 if e.type == EventType.STATE_DELTA] + state_deltas = [e for e in events2 if isinstance(e, StateDeltaEvent)] assert len(state_deltas) >= 1 # Check JSON Patch format @@ -62,7 +58,7 @@ async def test_confirm_changes_emission(): "document": {"tool": "write_document_local", "tool_argument": "document"}, } - current_state = {} + current_state: dict[str, str] = {} bridge = AgentFrameworkEventBridge( run_id="test_run", @@ -90,15 +86,13 @@ async def test_confirm_changes_emission(): assert any(e.type == EventType.STATE_SNAPSHOT for e in events) # Check for confirm_changes tool call - confirm_starts = [ - e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes" - ] + confirm_starts = [e for e in events if isinstance(e, ToolCallStartEvent) and e.tool_call_name == "confirm_changes"] assert len(confirm_starts) == 1 - confirm_args = [e for e in events if e.type == EventType.TOOL_CALL_ARGS and e.delta == "{}"] + confirm_args = [e for e in events if isinstance(e, ToolCallArgsEvent) and e.delta == "{}"] assert len(confirm_args) >= 1 - confirm_ends = [e for e in events if e.type == EventType.TOOL_CALL_END] + confirm_ends = [e for e in events if isinstance(e, ToolCallEndEvent)] # At least 2: one for write_document_local, one for confirm_changes assert len(confirm_ends) >= 2 @@ -141,7 +135,7 @@ async def test_no_confirm_for_non_predictive_tools(): "document": {"tool": "write_document_local", "tool_argument": "document"}, } - current_state = {} + current_state: dict[str, str] = {} bridge = AgentFrameworkEventBridge( run_id="test_run", @@ -162,9 +156,7 @@ async def test_no_confirm_for_non_predictive_tools(): events = await bridge.from_agent_run_update(update) # Should NOT have confirm_changes - confirm_starts = [ - e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes" - ] + confirm_starts = [e for e in events if isinstance(e, ToolCallStartEvent) and e.tool_call_name == "confirm_changes"] assert len(confirm_starts) == 0 # Stop flag should NOT be set @@ -193,14 +185,14 @@ async def test_state_delta_deduplication(): events1 = await bridge.from_agent_run_update(update1) # Count state deltas - state_deltas_1 = [e for e in events1 if e.type == EventType.STATE_DELTA] + state_deltas_1 = [e for e in events1 if isinstance(e, StateDeltaEvent)] assert len(state_deltas_1) >= 1 # Second tool call with SAME document (shouldn't emit new delta) bridge.current_tool_call_name = "write_document_local" tool_call2 = FunctionCallContent( call_id="call_2", - name=None, + name="write_document_local", arguments='{"document":"Same text"}', # Identical content ) update2 = AgentRunResponseUpdate(contents=[tool_call2]) @@ -234,7 +226,7 @@ async def test_predict_state_config_multiple_fields(): events = await bridge.from_agent_run_update(update) # Should emit StateDeltaEvent for both fields - state_deltas = [e for e in events if e.type == EventType.STATE_DELTA] + state_deltas = [e for e in events if isinstance(e, StateDeltaEvent)] assert len(state_deltas) >= 2 # Check both fields are present diff --git a/python/packages/ag-ui/tests/test_endpoint.py b/python/packages/ag-ui/tests/test_endpoint.py index 1ae364f8186..36c9e3bc324 100644 --- a/python/packages/ag-ui/tests/test_endpoint.py +++ b/python/packages/ag-ui/tests/test_endpoint.py @@ -3,7 +3,8 @@ """Tests for FastAPI endpoint creation (_endpoint.py).""" import json -from typing import Any +import sys +from pathlib import Path from agent_framework import ChatAgent, TextContent from agent_framework._types import ChatResponseUpdate @@ -13,22 +14,20 @@ from agent_framework_ag_ui._agent import AgentFrameworkAgent from agent_framework_ag_ui._endpoint import add_agent_framework_fastapi_endpoint +sys.path.insert(0, str(Path(__file__).parent)) +from test_helpers_ag_ui import StreamingChatClientStub, stream_from_updates -class MockChatClient: - """Mock chat client for testing.""" - def __init__(self, response_text: str = "Test response"): - self.response_text = response_text - - async def get_streaming_response(self, messages: list[Any], chat_options: Any, **kwargs: Any): - """Mock streaming response.""" - yield ChatResponseUpdate(contents=[TextContent(text=self.response_text)]) +def build_chat_client(response_text: str = "Test response") -> StreamingChatClientStub: + """Create a typed chat client stub for endpoint tests.""" + updates = [ChatResponseUpdate(contents=[TextContent(text=response_text)])] + return StreamingChatClientStub(stream_from_updates(updates)) async def test_add_endpoint_with_agent_protocol(): """Test adding endpoint with raw AgentProtocol.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/test-agent") @@ -42,7 +41,7 @@ async def test_add_endpoint_with_agent_protocol(): async def test_add_endpoint_with_wrapped_agent(): """Test adding endpoint with pre-wrapped AgentFrameworkAgent.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) wrapped_agent = AgentFrameworkAgent(agent=agent, name="wrapped") add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/wrapped-agent") @@ -57,7 +56,7 @@ async def test_add_endpoint_with_wrapped_agent(): async def test_endpoint_with_state_schema(): """Test endpoint with state_schema parameter.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) state_schema = {"document": {"type": "string"}} add_agent_framework_fastapi_endpoint(app, agent, path="/stateful", state_schema=state_schema) @@ -70,10 +69,37 @@ async def test_endpoint_with_state_schema(): assert response.status_code == 200 +async def test_endpoint_with_default_state_seed(): + """Test endpoint seeds default state when client omits it.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + state_schema = {"proverbs": {"type": "array"}} + default_state = {"proverbs": ["Keep the original."]} + + add_agent_framework_fastapi_endpoint( + app, + agent, + path="/default-state", + state_schema=state_schema, + default_state=default_state, + ) + + client = TestClient(app) + response = client.post("/default-state", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + content = response.content.decode("utf-8") + lines = [line for line in content.split("\n") if line.startswith("data: ")] + snapshots = [json.loads(line[6:]) for line in lines if json.loads(line[6:]).get("type") == "STATE_SNAPSHOT"] + assert snapshots, "Expected a STATE_SNAPSHOT event" + assert snapshots[0]["snapshot"]["proverbs"] == default_state["proverbs"] + + async def test_endpoint_with_predict_state_config(): """Test endpoint with predict_state_config parameter.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} add_agent_framework_fastapi_endpoint(app, agent, path="/predictive", predict_state_config=predict_config) @@ -87,7 +113,7 @@ async def test_endpoint_with_predict_state_config(): async def test_endpoint_request_logging(): """Test that endpoint logs request details.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/logged") @@ -107,7 +133,7 @@ async def test_endpoint_request_logging(): async def test_endpoint_event_streaming(): """Test that endpoint streams events correctly.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient("Streamed response")) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client("Streamed response")) add_agent_framework_fastapi_endpoint(app, agent, path="/stream") @@ -141,14 +167,14 @@ async def test_endpoint_event_streaming(): async def test_endpoint_error_handling(): """Test endpoint error handling during request parsing.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/failing") client = TestClient(app) # Send invalid JSON to trigger parsing error before streaming - response = client.post("/failing", data="invalid json", headers={"content-type": "application/json"}) + response = client.post("/failing", data=b"invalid json", headers={"content-type": "application/json"}) # type: ignore # The exception handler catches it and returns JSON error assert response.status_code == 200 @@ -160,8 +186,8 @@ async def test_endpoint_error_handling(): async def test_endpoint_multiple_paths(): """Test adding multiple endpoints with different paths.""" app = FastAPI() - agent1 = ChatAgent(name="agent1", instructions="First agent", chat_client=MockChatClient("Response 1")) - agent2 = ChatAgent(name="agent2", instructions="Second agent", chat_client=MockChatClient("Response 2")) + agent1 = ChatAgent(name="agent1", instructions="First agent", chat_client=build_chat_client("Response 1")) + agent2 = ChatAgent(name="agent2", instructions="Second agent", chat_client=build_chat_client("Response 2")) add_agent_framework_fastapi_endpoint(app, agent1, path="/agent1") add_agent_framework_fastapi_endpoint(app, agent2, path="/agent2") @@ -178,7 +204,7 @@ async def test_endpoint_multiple_paths(): async def test_endpoint_default_path(): """Test endpoint with default path.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent) @@ -191,7 +217,7 @@ async def test_endpoint_default_path(): async def test_endpoint_response_headers(): """Test that endpoint sets correct response headers.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/headers") @@ -207,7 +233,7 @@ async def test_endpoint_response_headers(): async def test_endpoint_empty_messages(): """Test endpoint with empty messages list.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/empty") @@ -220,7 +246,7 @@ async def test_endpoint_empty_messages(): async def test_endpoint_complex_input(): """Test endpoint with complex input data.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/complex") diff --git a/python/packages/ag-ui/tests/test_event_converters.py b/python/packages/ag-ui/tests/test_event_converters.py index d05b1fe7203..ff4d2ddc91a 100644 --- a/python/packages/ag-ui/tests/test_event_converters.py +++ b/python/packages/ag-ui/tests/test_event_converters.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + """Tests for AG-UI event converter.""" from agent_framework import FinishReason, Role diff --git a/python/packages/ag-ui/tests/test_events_comprehensive.py b/python/packages/ag-ui/tests/test_events_comprehensive.py index cd2663bd3c7..a51d1f382a3 100644 --- a/python/packages/ag-ui/tests/test_events_comprehensive.py +++ b/python/packages/ag-ui/tests/test_events_comprehensive.py @@ -68,6 +68,37 @@ async def test_skip_text_content_for_structured_outputs(): assert len(events) == 0 +async def test_skip_text_content_for_empty_text(): + """Test streaming TextContent with empty chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")]) + update2 = AgentRunResponseUpdate(contents=[TextContent(text="")]) # Empty chunk + update3 = AgentRunResponseUpdate(contents=[TextContent(text="world")]) + + events1 = await bridge.from_agent_run_update(update1) + events2 = await bridge.from_agent_run_update(update2) + events3 = await bridge.from_agent_run_update(update3) + + # First update: START + CONTENT + assert len(events1) == 2 + assert events1[0].type == "TEXT_MESSAGE_START" + assert events1[1].delta == "Hello " + + # Second update: should skip empty chunk, no events + assert len(events2) == 0 + + # Third update: just CONTENT (same message) + assert len(events3) == 1 + assert events3[0].type == "TEXT_MESSAGE_CONTENT" + assert events3[0].delta == "world" + + # Both content events should have same message_id + assert events1[1].message_id == events3[0].message_id + + async def test_tool_call_with_name(): """Test FunctionCallContent with name emits ToolCallStartEvent.""" from agent_framework_ag_ui._events import AgentFrameworkEventBridge diff --git a/python/packages/ag-ui/tests/test_helpers_ag_ui.py b/python/packages/ag-ui/tests/test_helpers_ag_ui.py new file mode 100644 index 00000000000..bfb528511e7 --- /dev/null +++ b/python/packages/ag-ui/tests/test_helpers_ag_ui.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared test stubs for AG-UI tests.""" + +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, MutableSequence +from types import SimpleNamespace +from typing import Any + +from agent_framework import ( + AgentProtocol, + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + ChatMessage, + ChatOptions, + TextContent, +) +from agent_framework._clients import BaseChatClient +from agent_framework._types import ChatResponse, ChatResponseUpdate + +from agent_framework_ag_ui._orchestrators import ExecutionContext + +StreamFn = Callable[..., AsyncIterator[ChatResponseUpdate]] +ResponseFn = Callable[..., Awaitable[ChatResponse]] + + +class StreamingChatClientStub(BaseChatClient): + """Typed streaming stub that satisfies ChatClientProtocol.""" + + def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None: + super().__init__() + self._stream_fn = stream_fn + self._response_fn = response_fn + + async def _inner_get_streaming_response( + self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + async for update in self._stream_fn(messages, chat_options, **kwargs): + yield update + + async def _inner_get_response( + self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> ChatResponse: + if self._response_fn is not None: + return await self._response_fn(messages, chat_options, **kwargs) + + contents: list[Any] = [] + async for update in self._stream_fn(messages, chat_options, **kwargs): + contents.extend(update.contents) + + return ChatResponse( + messages=[ChatMessage(role="assistant", contents=contents)], + response_id="stub-response", + ) + + +def stream_from_updates(updates: list[ChatResponseUpdate]) -> StreamFn: + """Create a stream function that yields from a static list of updates.""" + + async def _stream( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + for update in updates: + yield update + + return _stream + + +class StubAgent(AgentProtocol): + """Minimal AgentProtocol stub for orchestrator tests.""" + + def __init__( + self, + updates: list[AgentRunResponseUpdate] | None = None, + *, + agent_id: str = "stub-agent", + agent_name: str | None = "stub-agent", + chat_options: Any | None = None, + chat_client: Any | None = None, + ) -> None: + self._id = agent_id + self._name = agent_name + self._description = "stub agent" + self.updates = updates or [AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")] + self.chat_options = chat_options or SimpleNamespace(tools=None, response_format=None) + self.chat_client = chat_client or SimpleNamespace(function_invocation_configuration=None) + self.messages_received: list[Any] = [] + self.tools_received: list[Any] | None = None + + @property + def id(self) -> str: + return self._id + + @property + def name(self) -> str | None: + return self._name + + @property + def display_name(self) -> str: + return self._name or self._id + + @property + def description(self) -> str | None: + return self._description + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + return AgentRunResponse(messages=[], response_id="stub-response") + + def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + async def _stream() -> AsyncIterator[AgentRunResponseUpdate]: + self.messages_received = [] if messages is None else list(messages) # type: ignore[arg-type] + self.tools_received = kwargs.get("tools") + for update in self.updates: + yield update + + return _stream() + + def get_new_thread(self, **kwargs: Any) -> AgentThread: + return AgentThread() + + +class TestExecutionContext(ExecutionContext): + """ExecutionContext helper that allows setting messages for tests.""" + + def set_messages(self, messages: list[ChatMessage]) -> None: + self._messages = messages diff --git a/python/packages/ag-ui/tests/test_message_hygiene.py b/python/packages/ag-ui/tests/test_message_hygiene.py new file mode 100644 index 00000000000..ba775fa7d95 --- /dev/null +++ b/python/packages/ag-ui/tests/test_message_hygiene.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent + +from agent_framework_ag_ui._orchestration._message_hygiene import ( + deduplicate_messages, + sanitize_tool_history, +) + + +def test_sanitize_tool_history_injects_confirm_changes_result() -> None: + messages = [ + ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + name="confirm_changes", + call_id="call_confirm_123", + arguments='{"changes": "test"}', + ) + ], + ), + ChatMessage( + role="user", + contents=[TextContent(text='{"accepted": true}')], + ), + ] + + sanitized = sanitize_tool_history(messages) + + tool_messages = [ + msg for msg in sanitized if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert str(tool_messages[0].contents[0].call_id) == "call_confirm_123" + assert tool_messages[0].contents[0].result == "Confirmed" + + +def test_deduplicate_messages_prefers_non_empty_tool_results() -> None: + messages = [ + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call1", result="")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call1", result="result data")], + ), + ] + + deduped = deduplicate_messages(messages) + assert len(deduped) == 1 + assert deduped[0].contents[0].result == "result data" diff --git a/python/packages/ag-ui/tests/test_orchestrators.py b/python/packages/ag-ui/tests/test_orchestrators.py index a400e784587..10843a259cd 100644 --- a/python/packages/ag-ui/tests/test_orchestrators.py +++ b/python/packages/ag-ui/tests/test_orchestrators.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + """Tests for AG-UI orchestrators.""" from collections.abc import AsyncGenerator @@ -34,6 +36,7 @@ async def run_stream( *, thread: Any, tools: list[Any] | None = None, + **kwargs: Any, ) -> AsyncGenerator[AgentRunResponseUpdate, None]: self.seen_tools = tools yield AgentRunResponseUpdate(contents=[TextContent(text="ok")], role="assistant") diff --git a/python/packages/ag-ui/tests/test_orchestrators_coverage.py b/python/packages/ag-ui/tests/test_orchestrators_coverage.py index 81e41dee5f6..1da11bffbc1 100644 --- a/python/packages/ag-ui/tests/test_orchestrators_coverage.py +++ b/python/packages/ag-ui/tests/test_orchestrators_coverage.py @@ -2,7 +2,9 @@ """Comprehensive tests for orchestrator coverage.""" +import sys from collections.abc import AsyncGenerator +from pathlib import Path from types import SimpleNamespace from typing import Any @@ -15,11 +17,10 @@ from pydantic import BaseModel from agent_framework_ag_ui._agent import AgentConfig -from agent_framework_ag_ui._orchestrators import ( - DefaultOrchestrator, - ExecutionContext, - HumanInTheLoopOrchestrator, -) +from agent_framework_ag_ui._orchestrators import DefaultOrchestrator, HumanInTheLoopOrchestrator + +sys.path.insert(0, str(Path(__file__).parent)) +from test_helpers_ag_ui import StubAgent, TestExecutionContext @ai_function(approval_mode="always_require") @@ -28,34 +29,14 @@ def approval_tool(param: str) -> str: return f"executed: {param}" -class MockAgent: - """Mock agent for testing.""" - - def __init__(self, updates: list[AgentRunResponseUpdate] | None = None) -> None: - self.updates = updates or [AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")] - self.chat_options = SimpleNamespace(tools=[approval_tool], response_format=None) - self.chat_client = SimpleNamespace(function_invocation_configuration=None) - self.messages_received: list[Any] = [] - self.tools_received: list[Any] | None = None - - async def run_stream( - self, - messages: list[Any], - *, - thread: Any = None, - tools: list[Any] | None = None, - ) -> AsyncGenerator[AgentRunResponseUpdate, None]: - self.messages_received = messages - self.tools_received = tools - for update in self.updates: - yield update +DEFAULT_CHAT_OPTIONS = SimpleNamespace(tools=[approval_tool], response_format=None) async def test_human_in_the_loop_json_decode_error() -> None: """Test HumanInTheLoopOrchestrator handles invalid JSON in tool result.""" orchestrator = HumanInTheLoopOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -72,21 +53,25 @@ async def test_human_in_the_loop_json_decode_error() -> None: ) ] - context = ExecutionContext( + agent = StubAgent( + chat_options=SimpleNamespace(tools=[approval_tool], response_format=None), + updates=[AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")], + ) + context = TestExecutionContext( input_data=input_data, - agent=MockAgent(), + agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) assert orchestrator.can_handle(context) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) # Should emit RunErrorEvent for invalid JSON - error_events = [e for e in events if e.type == "RUN_ERROR"] + error_events: list[Any] = [e for e in events if e.type == "RUN_ERROR"] assert len(error_events) == 1 assert "Invalid tool result format" in error_events[0].message @@ -118,18 +103,20 @@ async def test_sanitize_tool_history_confirm_changes() -> None: orchestrator = DefaultOrchestrator() # Use pre-constructed ChatMessage objects to bypass message adapter - input_data = {"messages": []} + input_data: dict[str, Any] = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) # Override the messages property to use our pre-constructed messages - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -162,16 +149,18 @@ async def test_sanitize_tool_history_orphaned_tool_result() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": []} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -188,7 +177,7 @@ async def test_orphaned_tool_result_sanitization() -> None: """Test that orphaned tool results are filtered out.""" orchestrator = DefaultOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -201,14 +190,16 @@ async def test_orphaned_tool_result_sanitization() -> None: ], } - agent = MockAgent() - context = ExecutionContext( + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -241,16 +232,18 @@ async def test_deduplicate_messages_empty_tool_results() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": []} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -284,16 +277,18 @@ async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": []} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -326,16 +321,18 @@ async def test_deduplicate_messages_duplicate_system_messages() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": []} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -354,7 +351,7 @@ async def test_state_context_injection() -> None: """Test state context message injection for first request.""" orchestrator = DefaultOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "user", @@ -364,14 +361,16 @@ async def test_state_context_injection() -> None: "state": {"items": ["apple", "banana"]}, } - agent = MockAgent() - context = ExecutionContext( + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(state_schema={"items": {"type": "array"}}), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -406,16 +405,18 @@ async def test_no_state_context_injection_with_tool_calls() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": [], "state": {"weather": "sunny"}} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": [], "state": {"weather": "sunny"}} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(state_schema={"weather": {"type": "string"}}), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -437,7 +438,7 @@ class RecipeState(BaseModel): orchestrator = DefaultOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "user", @@ -447,32 +448,33 @@ class RecipeState(BaseModel): } # Agent with structured output - agent = MockAgent( + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, updates=[ AgentRunResponseUpdate( contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')], role="assistant", ) - ] + ], ) agent.chat_options.response_format = RecipeState - context = ExecutionContext( + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(state_schema={"ingredients": {"type": "array"}}), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) # Should emit StateSnapshotEvent with ingredients - state_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + state_events: list[Any] = [e for e in events if e.type == "STATE_SNAPSHOT"] assert len(state_events) >= 1 # Should emit TextMessage with message field - text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + text_content_events: list[Any] = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] assert len(text_content_events) >= 1 assert any("Added tomato" in e.delta for e in text_content_events) @@ -487,7 +489,7 @@ def get_weather(location: str) -> str: orchestrator = DefaultOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "user", @@ -507,16 +509,18 @@ def get_weather(location: str) -> str: ], } - agent = MockAgent() + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) agent.chat_options.tools = [get_weather] - context = ExecutionContext( + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -534,7 +538,7 @@ def server_tool() -> str: orchestrator = DefaultOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "user", @@ -554,16 +558,18 @@ def server_tool() -> str: ], } - agent = MockAgent() + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) agent.chat_options.tools = [server_tool] - context = ExecutionContext( + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -578,16 +584,18 @@ async def test_empty_messages_handling() -> None: """Test orchestrator handles empty message list gracefully.""" orchestrator = DefaultOrchestrator() - input_data = {"messages": []} + input_data: dict[str, Any] = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -603,7 +611,7 @@ async def test_all_messages_filtered_handling() -> None: """Test orchestrator handles case where all messages are filtered out.""" orchestrator = DefaultOrchestrator() - input_data = { + input_data: dict[str, Any] = { "messages": [ { "role": "tool", @@ -612,14 +620,16 @@ async def test_all_messages_filtered_handling() -> None: ] } - agent = MockAgent() - context = ExecutionContext( + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -651,16 +661,18 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": []} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -689,16 +701,18 @@ async def test_tool_result_kept_when_call_id_matches() -> None: ] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": []} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -729,6 +743,7 @@ async def run_stream( *, thread: Any = None, tools: list[Any] | None = None, + **kwargs: Any, ) -> AsyncGenerator[AgentRunResponseUpdate, None]: self.messages_received = messages yield AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant") @@ -738,16 +753,16 @@ async def run_stream( messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} + input_data: dict[str, Any] = {"messages": []} agent = CustomAgent() - context = ExecutionContext( + context = TestExecutionContext( input_data=input_data, agent=agent, # type: ignore config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) @@ -762,21 +777,23 @@ async def test_initial_state_snapshot_with_array_schema() -> None: messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] orchestrator = DefaultOrchestrator() - input_data = {"messages": [], "state": {}} - agent = MockAgent() - context = ExecutionContext( + input_data: dict[str, Any] = {"messages": [], "state": {}} + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(state_schema={"items": {"type": "array"}}), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) # Should emit state snapshot with empty array for items - state_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + state_events: list[Any] = [e for e in events if e.type == "STATE_SNAPSHOT"] assert len(state_events) >= 1 @@ -791,19 +808,21 @@ class OutputModel(BaseModel): messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] orchestrator = DefaultOrchestrator() - input_data = {"messages": []} + input_data: dict[str, Any] = {"messages": []} - agent = MockAgent() + agent = StubAgent( + chat_options=DEFAULT_CHAT_OPTIONS, + ) agent.chat_options.response_format = OutputModel - context = ExecutionContext( + context = TestExecutionContext( input_data=input_data, agent=agent, config=AgentConfig(), ) - context._messages = messages + context.set_messages(messages) - events = [] + events: list[Any] = [] async for event in orchestrator.run(context): events.append(event) diff --git a/python/packages/ag-ui/tests/test_shared_state.py b/python/packages/ag-ui/tests/test_shared_state.py index 578d48ecd0d..36f80b9d474 100644 --- a/python/packages/ag-ui/tests/test_shared_state.py +++ b/python/packages/ag-ui/tests/test_shared_state.py @@ -2,6 +2,10 @@ """Tests for shared state management.""" +import sys +from pathlib import Path +from typing import Any + import pytest from ag_ui.core import StateSnapshotEvent from agent_framework import ChatAgent, TextContent @@ -10,20 +14,16 @@ from agent_framework_ag_ui._agent import AgentFrameworkAgent from agent_framework_ag_ui._events import AgentFrameworkEventBridge +sys.path.insert(0, str(Path(__file__).parent)) +from test_helpers_ag_ui import StreamingChatClientStub, stream_from_updates + @pytest.fixture -def mock_agent(): +def mock_agent() -> ChatAgent: """Create a mock agent for testing.""" - - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Hello!")]) - - return ChatAgent( - name="test_agent", - instructions="Test agent", - chat_client=MockChatClient(), - ) + updates = [ChatResponseUpdate(contents=[TextContent(text="Hello!")])] + chat_client = StreamingChatClientStub(stream_from_updates(updates)) + return ChatAgent(name="test_agent", instructions="Test agent", chat_client=chat_client) def test_state_snapshot_event(): @@ -65,9 +65,9 @@ def test_state_delta_event(): assert event.delta[1]["op"] == "replace" -async def test_agent_with_initial_state(mock_agent): +async def test_agent_with_initial_state(mock_agent: ChatAgent) -> None: """Test agent emits state snapshot when initial state provided.""" - state_schema = {"recipe": {"type": "object", "properties": {"name": {"type": "string"}}}} + state_schema: dict[str, Any] = {"recipe": {"type": "object", "properties": {"name": {"type": "string"}}}} agent = AgentFrameworkAgent( agent=mock_agent, @@ -76,12 +76,12 @@ async def test_agent_with_initial_state(mock_agent): initial_state = {"recipe": {"name": "Test Recipe"}} - input_data = { + input_data: dict[str, Any] = { "messages": [{"role": "user", "content": "Hello"}], "state": initial_state, } - events = [] + events: list[Any] = [] async for event in agent.run_agent(input_data): events.append(event) @@ -91,16 +91,16 @@ async def test_agent_with_initial_state(mock_agent): assert snapshot_events[0].snapshot == initial_state -async def test_agent_without_state_schema(mock_agent): +async def test_agent_without_state_schema(mock_agent: ChatAgent) -> None: """Test agent doesn't emit state events without state schema.""" agent = AgentFrameworkAgent(agent=mock_agent) - input_data = { + input_data: dict[str, Any] = { "messages": [{"role": "user", "content": "Hello"}], "state": {"some": "state"}, } - events = [] + events: list[Any] = [] async for event in agent.run_agent(input_data): events.append(event) diff --git a/python/packages/ag-ui/tests/test_state_manager.py b/python/packages/ag-ui/tests/test_state_manager.py new file mode 100644 index 00000000000..bc0a7b6a192 --- /dev/null +++ b/python/packages/ag-ui/tests/test_state_manager.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft. All rights reserved. + +from ag_ui.core import CustomEvent, EventType +from agent_framework import ChatMessage, TextContent + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge +from agent_framework_ag_ui._orchestration._state_manager import StateManager + + +def test_state_manager_initializes_defaults_and_snapshot() -> None: + state_manager = StateManager( + state_schema={"items": {"type": "array"}, "metadata": {"type": "object"}}, + predict_state_config=None, + require_confirmation=True, + ) + current_state = state_manager.initialize({"metadata": {"a": 1}}) + bridge = AgentFrameworkEventBridge(run_id="run", thread_id="thread", current_state=current_state) + + snapshot_event = state_manager.initial_snapshot_event(bridge) + assert snapshot_event is not None + assert snapshot_event.snapshot["items"] == [] + assert snapshot_event.snapshot["metadata"] == {"a": 1} + + +def test_state_manager_predict_state_event_shape() -> None: + state_manager = StateManager( + state_schema=None, + predict_state_config={"doc": {"tool": "write_document_local", "tool_argument": "document"}}, + require_confirmation=True, + ) + predict_event = state_manager.predict_state_event() + assert isinstance(predict_event, CustomEvent) + assert predict_event.type == EventType.CUSTOM + assert predict_event.name == "PredictState" + assert predict_event.value[0]["state_key"] == "doc" + + +def test_state_context_only_when_new_user_turn() -> None: + state_manager = StateManager( + state_schema={"items": {"type": "array"}}, + predict_state_config=None, + require_confirmation=True, + ) + state_manager.initialize({"items": [1]}) + + assert state_manager.state_context_message(is_new_user_turn=False, conversation_has_tool_calls=False) is None + + message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False) + assert isinstance(message, ChatMessage) + assert isinstance(message.contents[0], TextContent) + assert "Current state of the application" in message.contents[0].text diff --git a/python/packages/ag-ui/tests/test_structured_output.py b/python/packages/ag-ui/tests/test_structured_output.py index 10307356a5b..c5f9719938c 100644 --- a/python/packages/ag-ui/tests/test_structured_output.py +++ b/python/packages/ag-ui/tests/test_structured_output.py @@ -3,12 +3,18 @@ """Tests for structured output handling in _agent.py.""" import json +import sys +from collections.abc import AsyncIterator, MutableSequence +from pathlib import Path from typing import Any -from agent_framework import ChatAgent, ChatOptions, TextContent +from agent_framework import ChatAgent, ChatMessage, ChatOptions, TextContent from agent_framework._types import ChatResponseUpdate from pydantic import BaseModel +sys.path.insert(0, str(Path(__file__).parent)) +from test_helpers_ag_ui import StreamingChatClientStub, stream_from_updates + class RecipeOutput(BaseModel): """Test Pydantic model for recipe output.""" @@ -34,14 +40,14 @@ async def test_structured_output_with_recipe(): """Test structured output processing with recipe state.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - # Simulate structured output - yield ChatResponseUpdate( - contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')] - ) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate( + contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')] + ) - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) agent.chat_options = ChatOptions(response_format=RecipeOutput) wrapper = AgentFrameworkAgent( @@ -51,7 +57,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): input_data = {"messages": [{"role": "user", "content": "Make pasta"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -72,17 +78,18 @@ async def test_structured_output_with_steps(): """Test structured output processing with steps state.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - steps_data = { - "steps": [ - {"id": "1", "description": "Step 1", "status": "pending"}, - {"id": "2", "description": "Step 2", "status": "pending"}, - ] - } - yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))]) - - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + steps_data = { + "steps": [ + {"id": "1", "description": "Step 1", "status": "pending"}, + {"id": "2", "description": "Step 2", "status": "pending"}, + ] + } + yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) agent.chat_options = ChatOptions(response_format=StepsOutput) wrapper = AgentFrameworkAgent( @@ -92,7 +99,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): input_data = {"messages": [{"role": "user", "content": "Do steps"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -111,12 +118,13 @@ async def test_structured_output_with_no_schema_match(): """Test structured output when response fields don't match state_schema keys.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - # Response has "data" field but schema expects "result" field - yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}}')]) + updates = [ + ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}}')]), + ] - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent( + name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_from_updates(updates)) + ) agent.chat_options = ChatOptions(response_format=GenericOutput) wrapper = AgentFrameworkAgent( @@ -126,7 +134,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): input_data = {"messages": [{"role": "user", "content": "Generate data"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -146,11 +154,12 @@ class DataOutput(BaseModel): data: dict[str, Any] info: str - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')]) - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) agent.chat_options = ChatOptions(response_format=DataOutput) wrapper = AgentFrameworkAgent( @@ -160,7 +169,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): input_data = {"messages": [{"role": "user", "content": "Generate data"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -177,18 +186,20 @@ async def test_no_structured_output_when_no_response_format(): """Test that structured output path is skipped when no response_format.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - yield ChatResponseUpdate(contents=[TextContent(text="Regular text")]) + updates = [ChatResponseUpdate(contents=[TextContent(text="Regular text")])] - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent( + name="test", + instructions="Test", + chat_client=StreamingChatClientStub(stream_from_updates(updates)), + ) # No response_format set wrapper = AgentFrameworkAgent(agent=agent) input_data = {"messages": [{"role": "user", "content": "Hi"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -202,12 +213,13 @@ async def test_structured_output_with_message_field(): """Test structured output that includes a message field.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"} - yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))]) + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"} + yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))]) - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) agent.chat_options = ChatOptions(response_format=RecipeOutput) wrapper = AgentFrameworkAgent( @@ -217,7 +229,7 @@ async def get_streaming_response(self, messages, chat_options, **kwargs): input_data = {"messages": [{"role": "user", "content": "Make salad"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) @@ -236,20 +248,20 @@ async def test_empty_updates_no_structured_processing(): """Test that empty updates don't trigger structured output processing.""" from agent_framework.ag_ui import AgentFrameworkAgent - class MockChatClient: - async def get_streaming_response(self, messages, chat_options, **kwargs): - # Return nothing - if False: - yield + async def stream_fn( + messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if False: + yield ChatResponseUpdate(contents=[]) - agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn)) agent.chat_options = ChatOptions(response_format=RecipeOutput) wrapper = AgentFrameworkAgent(agent=agent) input_data = {"messages": [{"role": "user", "content": "Test"}]} - events = [] + events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) diff --git a/python/packages/ag-ui/tests/test_tooling.py b/python/packages/ag-ui/tests/test_tooling.py new file mode 100644 index 00000000000..b802d654c62 --- /dev/null +++ b/python/packages/ag-ui/tests/test_tooling.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft. All rights reserved. + +from types import SimpleNamespace + +from agent_framework_ag_ui._orchestration._tooling import merge_tools, register_additional_client_tools + + +class DummyTool: + def __init__(self, name: str) -> None: + self.name = name + self.declaration_only = True + + +def test_merge_tools_filters_duplicates() -> None: + server = [DummyTool("a"), DummyTool("b")] + client = [DummyTool("b"), DummyTool("c")] + + merged = merge_tools(server, client) + + assert merged is not None + names = [getattr(t, "name", None) for t in merged] + assert names == ["a", "b", "c"] + + +def test_register_additional_client_tools_assigns_when_configured() -> None: + class Fic: + def __init__(self) -> None: + self.additional_tools = None + + holder = SimpleNamespace(function_invocation_configuration=Fic()) + agent = SimpleNamespace(chat_client=holder) + + tools = [DummyTool("x")] + register_additional_client_tools(agent, tools) + + assert holder.function_invocation_configuration.additional_tools == tools diff --git a/python/packages/ag-ui/tests/test_utils.py b/python/packages/ag-ui/tests/test_utils.py index e4324ab187d..4a6d0360bdb 100644 --- a/python/packages/ag-ui/tests/test_utils.py +++ b/python/packages/ag-ui/tests/test_utils.py @@ -20,8 +20,8 @@ def test_generate_event_id(): def test_merge_state(): """Test state merging.""" - current = {"a": 1, "b": 2} - update = {"b": 3, "c": 4} + current: dict[str, int] = {"a": 1, "b": 2} + update: dict[str, int] = {"b": 3, "c": 4} result = merge_state(current, update) @@ -32,8 +32,8 @@ def test_merge_state(): def test_merge_state_empty_update(): """Test merging with empty update.""" - current = {"x": 10, "y": 20} - update = {} + current: dict[str, int] = {"x": 10, "y": 20} + update: dict[str, int] = {} result = merge_state(current, update) @@ -43,8 +43,8 @@ def test_merge_state_empty_update(): def test_merge_state_empty_current(): """Test merging with empty current state.""" - current = {} - update = {"a": 1, "b": 2} + current: dict[str, int] = {} + update: dict[str, int] = {"a": 1, "b": 2} result = merge_state(current, update) @@ -53,8 +53,8 @@ def test_merge_state_empty_current(): def test_merge_state_deep_copy(): """Test that merge_state creates a deep copy preventing mutation of original.""" - current = {"recipe": {"name": "Cake", "ingredients": ["flour", "sugar"]}} - update = {"other": "value"} + current: dict[str, dict[str, object]] = {"recipe": {"name": "Cake", "ingredients": ["flour", "sugar"]}} + update: dict[str, str] = {"other": "value"} result = merge_state(current, update) diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 4911c08b2c1..a433b3e845e 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/aisearch/LICENSE b/python/packages/azure-ai-search/LICENSE similarity index 100% rename from python/packages/aisearch/LICENSE rename to python/packages/azure-ai-search/LICENSE diff --git a/python/packages/aisearch/README.md b/python/packages/azure-ai-search/README.md similarity index 94% rename from python/packages/aisearch/README.md rename to python/packages/azure-ai-search/README.md index 6631a2c863d..06853ae09ec 100644 --- a/python/packages/aisearch/README.md +++ b/python/packages/azure-ai-search/README.md @@ -3,7 +3,7 @@ Please install this package via pip: ```bash -pip install agent-framework-aisearch --pre +pip install agent-framework-azure-ai-search --pre ``` ## Azure AI Search Integration diff --git a/python/packages/aisearch/agent_framework_aisearch/__init__.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py similarity index 100% rename from python/packages/aisearch/agent_framework_aisearch/__init__.py rename to python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py diff --git a/python/packages/aisearch/agent_framework_aisearch/_search_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py similarity index 85% rename from python/packages/aisearch/agent_framework_aisearch/_search_provider.py rename to python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py index 23c8c3f309c..a63ad1deb2e 100644 --- a/python/packages/aisearch/agent_framework_aisearch/_search_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py @@ -1,21 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Azure AI Search Context Provider for Agent Framework. - -This module provides context providers for Azure AI Search integration with two modes: -- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and - multi-hop reasoning. Slightly slower with more token consumption, but more accurate. -- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple - queries where speed is critical. - -See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720 -""" import sys from collections.abc import Awaitable, Callable, MutableSequence from typing import TYPE_CHECKING, Any, ClassVar, Literal -from agent_framework import ChatMessage, Context, ContextProvider, Role +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMessage, Context, ContextProvider, Role from agent_framework._logging import get_logger from agent_framework._pydantic import AFBaseSettings from agent_framework.exceptions import ServiceInitializationError @@ -111,6 +101,18 @@ else: from typing_extensions import override # type: ignore[import] # pragma: no cover +"""Azure AI Search Context Provider for Agent Framework. + +This module provides context providers for Azure AI Search integration with two modes: +- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and + multi-hop reasoning. Slightly slower with more token consumption, but more accurate. +- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple + queries where speed is critical. + +See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720 +""" + + # Module-level constants logger = get_logger("agent_framework.azure") _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 @@ -127,6 +129,8 @@ class AzureAISearchSettings(AFBaseSettings): Can be set via environment variable AZURE_SEARCH_ENDPOINT. index_name: Name of the search index. Can be set via environment variable AZURE_SEARCH_INDEX_NAME. + knowledge_base_name: Name of an existing Knowledge Base (for agentic mode). + Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME. api_key: API key for authentication (optional, use managed identity if not provided). Can be set via environment variable AZURE_SEARCH_API_KEY. env_file_path: If provided, the .env settings are read from this file path location. @@ -156,6 +160,7 @@ class AzureAISearchSettings(AFBaseSettings): endpoint: str | None = None index_name: str | None = None + knowledge_base_name: str | None = None api_key: SecretStr | None = None @@ -237,7 +242,6 @@ def __init__( embedding_function: Callable[[str], Awaitable[list[float]]] | None = None, context_prompt: str | None = None, # Agentic mode parameters (Knowledge Base) - azure_ai_project_endpoint: str | None = None, azure_openai_resource_url: str | None = None, model_deployment_name: str | None = None, model_name: str | None = None, @@ -275,22 +279,18 @@ def __init__( Required if vector_field_name is specified and no server-side vectorization. context_prompt: Custom prompt to prepend to retrieved context. Default: "Use the following context to answer the question:" - azure_ai_project_endpoint: Azure AI Foundry project endpoint URL. - This is NOT the same as azure_openai_resource_url - the project endpoint is used - for Azure AI Foundry services, while the OpenAI endpoint is used by the Knowledge - Base to call the model for query planning. Required for agentic mode. - Example: "https://myproject.services.ai.azure.com/api/projects/myproject" azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls. - This is the OpenAI endpoint used by the Knowledge Base to call the LLM for - query planning and reasoning. This is separate from the project endpoint because - the Knowledge Base directly calls Azure OpenAI for its internal operations. - Required for agentic mode. Example: "https://myresource.openai.azure.com" + Required when using agentic mode with index_name (to auto-create Knowledge Base). + Not required when using an existing knowledge_base_name. + Example: "https://myresource.openai.azure.com" model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base. - This is the deployment name the Knowledge Base uses to call the LLM. - Required for agentic mode. + Required when using agentic mode with index_name (to auto-create Knowledge Base). + Not required when using an existing knowledge_base_name. model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini"). If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration. - knowledge_base_name: Name for the Knowledge Base. Required for agentic mode. + knowledge_base_name: Name of an existing Knowledge Base to use. + Required for agentic mode if not providing index_name. + Supports KBs with any source type (web, blob, index, etc.). retrieval_instructions: Custom instructions for the Knowledge Base's retrieval planning. Only used in agentic mode. azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model. @@ -338,6 +338,7 @@ def __init__( settings = AzureAISearchSettings( endpoint=endpoint, index_name=index_name, + knowledge_base_name=knowledge_base_name, api_key=api_key if isinstance(api_key, str) else None, env_file_path=env_file_path, env_file_encoding=env_file_encoding, @@ -351,11 +352,36 @@ def __init__( "Azure AI Search endpoint is required. Set via 'endpoint' parameter " "or 'AZURE_SEARCH_ENDPOINT' environment variable." ) - if not settings.index_name: - raise ServiceInitializationError( - "Azure AI Search index name is required. Set via 'index_name' parameter " - "or 'AZURE_SEARCH_INDEX_NAME' environment variable." - ) + + # Validate index_name and knowledge_base_name based on mode + # Note: settings.* contains the resolved value (explicit param OR env var) + if mode == "semantic": + # Semantic mode: always requires index_name + if not settings.index_name: + raise ServiceInitializationError( + "Azure AI Search index name is required for semantic mode. " + "Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable." + ) + elif mode == "agentic": + # Agentic mode: requires exactly ONE of index_name or knowledge_base_name + if settings.index_name and settings.knowledge_base_name: + raise ServiceInitializationError( + "For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both. " + "Use 'index_name' to auto-create a Knowledge Base, or 'knowledge_base_name' to use an existing one." + ) + if not settings.index_name and not settings.knowledge_base_name: + raise ServiceInitializationError( + "For agentic mode, provide either 'index_name' (to auto-create Knowledge Base) " + "or 'knowledge_base_name' (to use existing Knowledge Base). " + "Set via parameters or environment variables " + "AZURE_SEARCH_INDEX_NAME / AZURE_SEARCH_KNOWLEDGE_BASE_NAME." + ) + # If using index_name to create KB, model config is required + if settings.index_name and not model_deployment_name: + raise ServiceInitializationError( + "model_deployment_name is required for agentic mode when creating Knowledge Base from index. " + "This is the Azure OpenAI deployment used by the Knowledge Base for query planning." + ) # Determine the credential to use resolved_credential: AzureKeyCredential | AsyncTokenCredential @@ -387,14 +413,27 @@ def __init__( self.azure_openai_deployment_name = model_deployment_name # If model_name not provided, default to deployment name self.model_name = model_name or model_deployment_name - self.knowledge_base_name = knowledge_base_name + # Use resolved KB name (from explicit param or env var) + self.knowledge_base_name = settings.knowledge_base_name self.retrieval_instructions = retrieval_instructions self.azure_openai_api_key = azure_openai_api_key - self.azure_ai_project_endpoint = azure_ai_project_endpoint self.knowledge_base_output_mode = knowledge_base_output_mode self.retrieval_reasoning_effort = retrieval_reasoning_effort self.agentic_message_history_count = agentic_message_history_count + # Determine if using existing Knowledge Base or auto-creating from index + # Since validation ensures exactly one of index_name/knowledge_base_name for agentic mode: + # - knowledge_base_name provided: use existing KB + # - index_name provided: auto-create KB from index + self._use_existing_knowledge_base = False + if mode == "agentic": + if settings.knowledge_base_name: + # Use existing KB directly (supports any source type: web, blob, index, etc.) + self._use_existing_knowledge_base = True + else: + # Auto-generate KB name from index name + self.knowledge_base_name = f"{settings.index_name}-kb" + # Auto-discover vector field if not specified self._auto_discovered_vector_field = False self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected @@ -413,22 +452,24 @@ def __init__( "Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support. " "Please upgrade: pip install azure-search-documents>=11.7.0b1" ) - if not self.azure_openai_resource_url: + # Only require OpenAI resource URL if NOT using existing KB + # (existing KB already has its model configuration) + # Note: model_deployment_name is already validated at initialization + if not self._use_existing_knowledge_base and not self.azure_openai_resource_url: raise ValueError( - "azure_openai_resource_url is required for agentic mode. " + "azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index. " "This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')" ) - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required for agentic mode") - if not knowledge_base_name: - raise ValueError("knowledge_base_name is required for agentic mode") - - # Create search client for semantic mode - self._search_client = SearchClient( - endpoint=self.endpoint, - index_name=self.index_name, - credential=self.credential, - ) + + # Create search client for semantic mode (only if index_name is available) + self._search_client: SearchClient | None = None + if self.index_name: + self._search_client = SearchClient( + endpoint=self.endpoint, + index_name=self.index_name, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) # Create index client and retrieval client for agentic mode (Knowledge Base) self._index_client: SearchIndexClient | None = None @@ -437,6 +478,7 @@ def __init__( self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, ) # Retrieval client will be created after Knowledge Base initialization @@ -572,10 +614,19 @@ async def _auto_discover_vector_field(self) -> None: try: # Use existing index client or create temporary one if not self._index_client: - self._index_client = SearchIndexClient(endpoint=self.endpoint, credential=self.credential) + self._index_client = SearchIndexClient( + endpoint=self.endpoint, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) index_client = self._index_client - # Get index schema + # Get index schema (index_name is guaranteed to be set for semantic mode) + if not self.index_name: + logger.warning("Cannot auto-discover vector field: index_name is not set.") + self._auto_discovered_vector_field = True + return + index = await index_client.get_index(self.index_name) # Step 1: Find all vector fields @@ -692,7 +743,10 @@ async def _semantic_search(self, query: str) -> list[str]: search_params["semantic_configuration_name"] = self.semantic_configuration_name search_params["query_caption"] = QueryCaptionType.EXTRACTIVE - # Execute search + # Execute search (search client is guaranteed to exist for semantic mode) + if not self._search_client: + raise RuntimeError("Search client is not initialized. This should not happen in semantic mode.") + results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType] # Format results with citations @@ -709,27 +763,48 @@ async def _semantic_search(self, query: str) -> list[str]: return formatted_results async def _ensure_knowledge_base(self) -> None: - """Ensure Knowledge Base and knowledge source are created. + """Ensure Knowledge Base and knowledge source are created or use existing KB. This method is idempotent - it will only create resources if they don't exist. Note: Azure SDK uses KnowledgeAgent classes internally, but the feature is marketed as "Knowledge Bases" in Azure AI Search. """ - if self._knowledge_base_initialized or not self._index_client: + if self._knowledge_base_initialized: return - # Runtime validation for agentic mode parameters + # Runtime validation if not self.knowledge_base_name: raise ValueError("knowledge_base_name is required for agentic mode") - if not self.azure_openai_resource_url: - raise ValueError("azure_openai_resource_url is required for agentic mode") - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required for agentic mode") knowledge_base_name = self.knowledge_base_name - # Step 1: Create or get knowledge source + # Path 1: Use existing Knowledge Base directly (no index needed) + # This supports KB with any source type (web, blob, index, etc.) + if self._use_existing_knowledge_base: + # Just create the retrieval client - KB already exists with its own sources + if _agentic_retrieval_available and self._retrieval_client is None: + self._retrieval_client = KnowledgeBaseRetrievalClient( + endpoint=self.endpoint, + knowledge_base_name=knowledge_base_name, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + self._knowledge_base_initialized = True + return + + # Path 2: Auto-create Knowledge Base from search index + # Requires index_client and OpenAI configuration + if not self._index_client: + raise ValueError("Index client is required when creating Knowledge Base from index") + if not self.azure_openai_resource_url: + raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") + if not self.azure_openai_deployment_name: + raise ValueError("model_deployment_name is required when creating Knowledge Base from index") + if not self.index_name: + raise ValueError("index_name is required when creating Knowledge Base from index") + + # Step 1: Create or get knowledge source from index knowledge_source_name = f"{self.index_name}-source" try: @@ -792,6 +867,7 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, ) async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]: diff --git a/python/packages/aisearch/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml similarity index 90% rename from python/packages/aisearch/pyproject.toml rename to python/packages/azure-ai-search/pyproject.toml index 5d64b398aa5..d5569eb49ce 100644 --- a/python/packages/aisearch/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -1,10 +1,10 @@ [project] -name = "agent-framework-aisearch" +name = "agent-framework-azure-ai-search" description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251118" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -76,15 +76,15 @@ disallow_incomplete_defs = true disallow_untyped_decorators = true [tool.bandit] -targets = ["agent_framework_aisearch"] +targets = ["agent_framework_azure_ai_search"] exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" [tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_aisearch" -test = "pytest --cov=agent_framework_aisearch --cov-report=term-missing:skip-covered tests" +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search" +test = "pytest --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests" [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/aisearch/tests/test_search_provider.py b/python/packages/azure-ai-search/tests/test_search_provider.py similarity index 71% rename from python/packages/aisearch/tests/test_search_provider.py rename to python/packages/azure-ai-search/tests/test_search_provider.py index 6813c3d16a8..66ead79a6b5 100644 --- a/python/packages/aisearch/tests/test_search_provider.py +++ b/python/packages/azure-ai-search/tests/test_search_provider.py @@ -6,13 +6,11 @@ import pytest from agent_framework import ChatMessage, Context, Role -from agent_framework.azure import AzureAISearchContextProvider +from agent_framework.azure import AzureAISearchContextProvider, AzureAISearchSettings from agent_framework.exceptions import ServiceInitializationError from azure.core.credentials import AzureKeyCredential from azure.core.exceptions import ResourceNotFoundError -from agent_framework_aisearch import AzureAISearchSettings - @pytest.fixture def mock_search_client() -> AsyncMock: @@ -150,74 +148,105 @@ def test_init_semantic_mode_with_vector_field_requires_embedding_function(self) vector_field_name="embedding", ) - def test_init_agentic_mode_requires_azure_openai_resource_url(self) -> None: - """Test that agentic mode requires azure_openai_resource_url.""" - with pytest.raises(ValueError, match="azure_openai_resource_url"): + def test_init_agentic_mode_with_kb_only(self) -> None: + """Test agentic mode with existing knowledge_base_name (simplest path).""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) + assert provider.mode == "agentic" + assert provider.knowledge_base_name == "test-kb" + assert provider._use_existing_knowledge_base is True + + def test_init_agentic_mode_with_index_requires_model(self) -> None: + """Test that agentic mode with index_name requires model_deployment_name.""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with ( + patch.dict(os.environ, clean_env, clear=True), + pytest.raises(ServiceInitializationError, match="model_deployment_name"), + ): AzureAISearchContextProvider( endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", mode="agentic", + env_file_path="", # Disable .env file loading ) - def test_init_agentic_mode_requires_model_deployment_name(self) -> None: - """Test that agentic mode requires model_deployment_name.""" - with pytest.raises(ValueError, match="model_deployment_name"): - AzureAISearchContextProvider( + def test_init_agentic_mode_with_index_and_model(self) -> None: + """Test agentic mode with index_name (auto-create KB path).""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + provider = AzureAISearchContextProvider( endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", + model_deployment_name="gpt-4o", azure_openai_resource_url="https://test.openai.azure.com", + env_file_path="", # Disable .env file loading ) - - def test_init_agentic_mode_requires_knowledge_base_name(self) -> None: - """Test that agentic mode requires knowledge_base_name.""" - with pytest.raises(ValueError, match="knowledge_base_name"): + assert provider.mode == "agentic" + assert provider.index_name == "test-index" + assert provider.knowledge_base_name == "test-index-kb" # Auto-generated + assert provider._use_existing_knowledge_base is False + + def test_init_agentic_mode_rejects_both_index_and_kb(self) -> None: + """Test that agentic mode rejects both index_name AND knowledge_base_name.""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with ( + patch.dict(os.environ, clean_env, clear=True), + pytest.raises(ServiceInitializationError, match="either 'index_name' OR 'knowledge_base_name', not both"), + ): AzureAISearchContextProvider( endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", + knowledge_base_name="test-kb", model_deployment_name="gpt-4o", azure_openai_resource_url="https://test.openai.azure.com", + env_file_path="", # Disable .env file loading ) - def test_init_agentic_mode_with_all_params(self) -> None: - """Test initialization with all agentic mode parameters.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="my-gpt-4o-deployment", - model_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) - assert provider.mode == "agentic" - assert provider.azure_ai_project_endpoint == "https://test.services.ai.azure.com" - assert provider.azure_openai_resource_url == "https://test.openai.azure.com" - assert provider.azure_openai_deployment_name == "my-gpt-4o-deployment" - assert provider.model_name == "gpt-4o" - assert provider.knowledge_base_name == "test-kb" + def test_init_agentic_mode_requires_index_or_kb(self) -> None: + """Test that agentic mode requires either index_name or knowledge_base_name.""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with ( + patch.dict(os.environ, clean_env, clear=True), + pytest.raises(ServiceInitializationError, match="provide either 'index_name'.*or 'knowledge_base_name'"), + ): + AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + env_file_path="", # Disable .env file loading + ) def test_init_model_name_defaults_to_deployment_name(self) -> None: """Test that model_name defaults to deployment_name if not provided.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) - assert provider.model_name == "gpt-4o" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + model_deployment_name="gpt-4o", + env_file_path="", # Disable .env file loading + ) + assert provider.model_name == "gpt-4o" def test_init_with_custom_context_prompt(self) -> None: """Test initialization with custom context prompt.""" @@ -246,7 +275,7 @@ class TestSemanticSearch: """Test semantic search functionality.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_semantic_search_basic( self, mock_search_class: MagicMock, sample_messages: list[ChatMessage] ) -> None: @@ -275,7 +304,7 @@ async def test_semantic_search_basic( assert "Test document content" in context.messages[1].text @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_semantic_search_empty_query(self, mock_search_class: MagicMock) -> None: """Test that empty queries return empty context.""" mock_search_client = AsyncMock() @@ -295,7 +324,7 @@ async def test_semantic_search_empty_query(self, mock_search_class: MagicMock) - assert len(context.messages) == 0 @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_semantic_search_with_vector_query( self, mock_search_class: MagicMock, sample_messages: list[ChatMessage] ) -> None: @@ -332,12 +361,12 @@ class TestKnowledgeBaseSetup: """Test Knowledge Base setup for agentic mode.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_ensure_knowledge_base_creates_when_not_exists( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: - """Test that Knowledge Base is created when it doesn't exist.""" + """Test that Knowledge Base is created when it doesn't exist (index_name path).""" # Setup mocks mock_index_client = AsyncMock() mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") @@ -349,64 +378,65 @@ async def test_ensure_knowledge_base_creates_when_not_exists( mock_search_client = AsyncMock() mock_search_class.return_value = mock_search_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - model_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use index_name path (auto-create KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + index_name="test-index", + api_key="test-key", + mode="agentic", + model_deployment_name="gpt-4o", + azure_openai_resource_url="https://test.openai.azure.com", + env_file_path="", # Disable .env file loading + ) - await provider._ensure_knowledge_base() + await provider._ensure_knowledge_base() - # Verify knowledge source was created - mock_index_client.create_knowledge_source.assert_called_once() - # Verify Knowledge Base was created - mock_index_client.create_or_update_knowledge_base.assert_called_once() + # Verify knowledge source was created + mock_index_client.create_knowledge_source.assert_called_once() + # Verify Knowledge Base was created + mock_index_client.create_or_update_knowledge_base.assert_called_once() @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") - async def test_ensure_knowledge_base_skips_when_exists( + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") + async def test_ensure_knowledge_base_skips_when_using_existing_kb( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: - """Test that Knowledge Base setup is skipped when already exists.""" + """Test that KB setup is skipped when using existing knowledge_base_name.""" # Setup mocks mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.return_value = MagicMock() # Exists - mock_index_client.get_knowledge_base.return_value = MagicMock() # Exists mock_index_class.return_value = mock_index_client mock_search_client = AsyncMock() mock_search_class.return_value = mock_search_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) - await provider._ensure_knowledge_base() + await provider._ensure_knowledge_base() - # Verify nothing was created - mock_index_client.create_knowledge_source.assert_not_called() - mock_index_client.create_agent.assert_not_called() + # Verify nothing was created (using existing KB) + mock_index_client.create_knowledge_source.assert_not_called() + mock_index_client.create_or_update_knowledge_base.assert_not_called() class TestContextProviderLifecycle: """Test context provider lifecycle methods.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_context_manager(self, mock_search_class: MagicMock) -> None: """Test that provider can be used as async context manager.""" mock_search_client = AsyncMock() @@ -422,9 +452,9 @@ async def test_context_manager(self, mock_search_class: MagicMock) -> None: assert isinstance(provider, AzureAISearchContextProvider) @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_context_manager_agentic_cleanup( self, mock_search_class: MagicMock, mock_index_class: MagicMock, mock_retrieval_class: MagicMock ) -> None: @@ -439,21 +469,22 @@ async def test_context_manager_agentic_cleanup( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - async with AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) as provider: - # Simulate retrieval client being created - provider._retrieval_client = mock_retrieval_client + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + async with AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) as provider: + # Simulate retrieval client being created + provider._retrieval_client = mock_retrieval_client - # Verify cleanup was called - mock_retrieval_client.close.assert_called_once() + # Verify cleanup was called + mock_retrieval_client.close.assert_called_once() def test_string_api_key_conversion(self) -> None: """Test that string api_key is converted to AzureKeyCredential.""" @@ -470,7 +501,7 @@ class TestMessageFiltering: """Test message filtering functionality.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_filters_non_user_assistant_messages(self, mock_search_class: MagicMock) -> None: """Test that only USER and ASSISTANT messages are processed.""" # Setup mock @@ -502,7 +533,7 @@ async def test_filters_non_user_assistant_messages(self, mock_search_class: Magi mock_search_client.search.assert_called_once() @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_filters_empty_messages(self, mock_search_class: MagicMock) -> None: """Test that empty/whitespace messages are filtered out.""" mock_search_client = AsyncMock() @@ -532,7 +563,7 @@ class TestCitations: """Test citation functionality.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_citations_included_in_semantic_search(self, mock_search_class: MagicMock) -> None: """Test that citations are included in semantic search results.""" # Setup mock with document ID @@ -564,9 +595,9 @@ class TestAgenticSearch: """Test agentic search functionality.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_agentic_search_basic( self, mock_search_class: MagicMock, @@ -581,9 +612,6 @@ async def test_agentic_search_basic( # Setup index client mock mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.create_or_update_knowledge_base = AsyncMock() mock_index_class.return_value = mock_index_client # Setup retrieval client mock with response @@ -593,7 +621,7 @@ async def test_agentic_search_basic( mock_content = MagicMock() mock_content.text = "Agentic search result" # Make it pass isinstance check - from agent_framework_aisearch._search_provider import _agentic_retrieval_available + from agent_framework_azure_ai_search._search_provider import _agentic_retrieval_available if _agentic_retrieval_available: from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent @@ -605,27 +633,28 @@ async def test_agentic_search_basic( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) - context = await provider.invoking(sample_messages) + context = await provider.invoking(sample_messages) - assert isinstance(context, Context) - # Should have at least the prompt message - assert len(context.messages) >= 1 + assert isinstance(context, Context) + # Should have at least the prompt message + assert len(context.messages) >= 1 @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_agentic_search_no_results( self, mock_search_class: MagicMock, @@ -639,9 +668,6 @@ async def test_agentic_search_no_results( mock_search_class.return_value = mock_search_client mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.create_or_update_knowledge_base = AsyncMock() mock_index_class.return_value = mock_index_client # Empty response @@ -652,27 +678,28 @@ async def test_agentic_search_no_results( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) - context = await provider.invoking(sample_messages) + context = await provider.invoking(sample_messages) - assert isinstance(context, Context) - # Should have fallback message - assert len(context.messages) >= 1 + assert isinstance(context, Context) + # Should have fallback message + assert len(context.messages) >= 1 @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_agentic_search_with_medium_reasoning( self, mock_search_class: MagicMock, @@ -686,9 +713,6 @@ async def test_agentic_search_with_medium_reasoning( mock_search_class.return_value = mock_search_client mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.create_or_update_knowledge_base = AsyncMock() mock_index_class.return_value = mock_index_client mock_retrieval_client = AsyncMock() @@ -696,7 +720,7 @@ async def test_agentic_search_with_medium_reasoning( mock_message = MagicMock() mock_content = MagicMock() mock_content.text = "Medium reasoning result" - from agent_framework_aisearch._search_provider import _agentic_retrieval_available + from agent_framework_azure_ai_search._search_provider import _agentic_retrieval_available if _agentic_retrieval_available: from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent @@ -708,30 +732,31 @@ async def test_agentic_search_with_medium_reasoning( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - retrieval_reasoning_effort="medium", # Test medium reasoning - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + retrieval_reasoning_effort="medium", # Test medium reasoning + env_file_path="", # Disable .env file loading + ) - context = await provider.invoking(sample_messages) + context = await provider.invoking(sample_messages) - assert isinstance(context, Context) - assert len(context.messages) >= 1 + assert isinstance(context, Context) + assert len(context.messages) >= 1 class TestVectorFieldAutoDiscovery: """Test vector field auto-discovery functionality.""" @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_auto_discovers_single_vector_field( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: @@ -795,8 +820,8 @@ async def test_vector_detection_accuracy(self) -> None: assert is_vector_3 is False @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_no_false_positives_on_string_fields( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: @@ -839,8 +864,8 @@ async def test_no_false_positives_on_string_fields( assert provider._auto_discovered_vector_field is True @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_multiple_vector_fields_without_vectorizer( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: @@ -884,8 +909,8 @@ async def test_multiple_vector_fields_without_vectorizer( assert provider._auto_discovered_vector_field is True @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_multiple_vectorizable_fields( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: @@ -941,8 +966,8 @@ async def test_multiple_vectorizable_fields( assert provider._auto_discovered_vector_field is True @pytest.mark.asyncio - @patch("agent_framework_aisearch._search_provider.SearchIndexClient") - @patch("agent_framework_aisearch._search_provider.SearchClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") + @patch("agent_framework_azure_ai_search._search_provider.SearchClient") async def test_single_vectorizable_field_detected( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index d85dc951116..a9c01fb066d 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -118,6 +118,7 @@ def __init__( agents_client: AgentsClient | None = None, agent_id: str | None = None, agent_name: str | None = None, + agent_description: str | None = None, thread_id: str | None = None, project_endpoint: str | None = None, model_deployment_name: str | None = None, @@ -135,6 +136,7 @@ def __init__( a new agent will be created (and deleted after the request). If neither agents_client nor agent_id is provided, both will be created and managed automatically. agent_name: The name to use when creating new agents. + agent_description: The description to use when creating new agents. thread_id: Default thread ID to use for conversations. Can be overridden by conversation_id property when making a request. project_endpoint: The Azure AI Project endpoint URL. @@ -215,6 +217,7 @@ def __init__( self.credential = async_credential self.agent_id = agent_id self.agent_name = agent_name + self.agent_description = agent_description self.model_id = azure_ai_settings.model_deployment_name self.thread_id = thread_id self.should_cleanup_agent = should_cleanup_agent # Track whether we should delete the agent @@ -311,6 +314,7 @@ async def _get_agent_id_or_create(self, run_options: dict[str, Any] | None = Non args: dict[str, Any] = { "model": run_options["model"], "name": agent_name, + "description": self.agent_description, } if "tools" in run_options: args["tools"] = run_options["tools"] @@ -1038,16 +1042,19 @@ def _convert_required_action_to_tool_output( return run_id, tool_outputs, tool_approvals - def _update_agent_name(self, agent_name: str | None) -> None: + def _update_agent_name_and_description(self, agent_name: str | None, description: str | None) -> None: """Update the agent name in the chat client. Args: agent_name: The new name for the agent. + description: The new description for the agent. """ # This is a no-op in the base class, but can be overridden by subclasses # to update the agent name in the client. if agent_name and not self.agent_name: self.agent_name = agent_name + if description and not self.agent_description: + self.agent_description = description def service_url(self) -> str: """Get the service URL for the chat client. diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index c5c198bce53..ad0e3ac9615 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import sys -from collections.abc import MutableSequence +from collections.abc import Mapping, MutableSequence from typing import Any, ClassVar, TypeVar from agent_framework import ( @@ -14,7 +14,7 @@ use_chat_middleware, use_function_invocation, ) -from agent_framework.exceptions import ServiceInitializationError +from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError from agent_framework.observability import use_observability from agent_framework.openai._responses_client import OpenAIBaseResponsesClient from azure.ai.projects.aio import AIProjectClient @@ -22,7 +22,9 @@ MCPTool, PromptAgentDefinition, PromptAgentDefinitionText, + ResponseTextFormatConfigurationJsonObject, ResponseTextFormatConfigurationJsonSchema, + ResponseTextFormatConfigurationText, ) from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError @@ -60,6 +62,7 @@ def __init__( project_client: AIProjectClient | None = None, agent_name: str | None = None, agent_version: str | None = None, + agent_description: str | None = None, conversation_id: str | None = None, project_endpoint: str | None = None, model_deployment_name: str | None = None, @@ -75,6 +78,7 @@ def __init__( project_client: An existing AIProjectClient to use. If not provided, one will be created. agent_name: The name to use when creating new agents or using existing agents. agent_version: The version of the agent to use. + agent_description: The description to use when creating new agents. conversation_id: Default conversation ID to use for conversations. Can be overridden by conversation_id property when making a request. project_endpoint: The Azure AI Project endpoint URL. @@ -148,12 +152,17 @@ def __init__( # Initialize instance variables self.agent_name = agent_name self.agent_version = agent_version + self.agent_description = agent_description self.use_latest_version = use_latest_version self.project_client = project_client self.credential = async_credential self.model_id = azure_ai_settings.model_deployment_name self.conversation_id = conversation_id - self._should_close_client = should_close_client # Track whether we should close client connection + + # Track whether the application endpoint is used + self._is_application_endpoint = "/applications/" in project_client._config.endpoint # type: ignore + # Track whether we should close client connection + self._should_close_client = should_close_client async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None: """Use this method to setup tracing in your Azure AI Project. @@ -188,6 +197,40 @@ async def close(self) -> None: """Close the project_client.""" await self._close_client_if_needed() + def _create_text_format_config( + self, response_format: Any + ) -> ( + ResponseTextFormatConfigurationJsonSchema + | ResponseTextFormatConfigurationJsonObject + | ResponseTextFormatConfigurationText + ): + """Convert response_format into Azure text format configuration.""" + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return ResponseTextFormatConfigurationJsonSchema( + name=response_format.__name__, + schema=response_format.model_json_schema(), + ) + + if isinstance(response_format, Mapping): + format_config = self._convert_response_format(response_format) + format_type = format_config.get("type") + if format_type == "json_schema": + config_kwargs: dict[str, Any] = { + "name": format_config.get("name") or "response", + "schema": format_config["schema"], + } + if "strict" in format_config: + config_kwargs["strict"] = format_config["strict"] + if "description" in format_config: + config_kwargs["description"] = format_config["description"] + return ResponseTextFormatConfigurationJsonSchema(**config_kwargs) + if format_type == "json_object": + return ResponseTextFormatConfigurationJsonObject() + if format_type == "text": + return ResponseTextFormatConfigurationText() + + raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.") + async def _get_agent_reference_or_create( self, run_options: dict[str, Any], messages_instructions: str | None ) -> dict[str, str]: @@ -228,12 +271,7 @@ async def _get_agent_reference_or_create( if "response_format" in run_options: response_format = run_options["response_format"] - args["text"] = PromptAgentDefinitionText( - format=ResponseTextFormatConfigurationJsonSchema( - name=response_format.__name__, - schema=response_format.model_json_schema(), - ) - ) + args["text"] = PromptAgentDefinitionText(format=self._create_text_format_config(response_format)) # Combine instructions from messages and options combined_instructions = [ @@ -245,7 +283,9 @@ async def _get_agent_reference_or_create( args["instructions"] = "".join(combined_instructions) created_agent = await self.project_client.agents.create_version( - agent_name=self.agent_name, definition=PromptAgentDefinition(**args) + agent_name=self.agent_name, + definition=PromptAgentDefinition(**args), + description=self.agent_description, ) self.agent_version = created_agent.version @@ -277,15 +317,19 @@ def _prepare_input(self, messages: MutableSequence[ChatMessage]) -> tuple[list[C return result, instructions async def prepare_options( - self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + self, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, ) -> dict[str, Any]: """Take ChatOptions and create the specific options for Azure AI.""" - chat_options.store = bool(chat_options.store or chat_options.store is None) prepared_messages, instructions = self._prepare_input(messages) - run_options = await super().prepare_options(prepared_messages, chat_options) - agent_reference = await self._get_agent_reference_or_create(run_options, instructions) + run_options = await super().prepare_options(prepared_messages, chat_options, **kwargs) - run_options["extra_body"] = {"agent": agent_reference} + if not self._is_application_endpoint: + # Application-scoped response APIs do not support "agent" property. + agent_reference = await self._get_agent_reference_or_create(run_options, instructions) + run_options["extra_body"] = {"agent": agent_reference} conversation_id = chat_options.conversation_id or self.conversation_id @@ -313,16 +357,19 @@ async def initialize_client(self) -> None: """Initialize OpenAI client.""" self.client = self.project_client.get_openai_client() # type: ignore - def _update_agent_name(self, agent_name: str | None) -> None: + def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: """Update the agent name in the chat client. Args: agent_name: The new name for the agent. + description: The new description for the agent. """ # This is a no-op in the base class, but can be overridden by subclasses # to update the agent name in the client. if agent_name and not self.agent_name: self.agent_name = agent_name + if description and not self.agent_description: + self.agent_description = description def get_mcp_tool(self, tool: HostedMCPTool) -> Any: """Get MCP tool from HostedMCPTool.""" @@ -347,12 +394,12 @@ def get_conversation_id( self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None ) -> str | None: """Get the conversation ID from the response if store is True.""" - if store: - # If conversation ID exists, it means that we operate with conversation - # so we use conversation ID as input and output. - if response.conversation and response.conversation.id: - return response.conversation.id - # If conversation ID doesn't exist, we operate with responses - # so we use response ID as input and output. - return response.id - return None + if store is False: + return None + # If conversation ID exists, it means that we operate with conversation + # so we use conversation ID as input and output. + if response.conversation and response.conversation.id: + return response.conversation.id + # If conversation ID doesn't exist, we operate with responses + # so we use response ID as input and output. + return response.id diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index bd6ea232502..821d3a2578a 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index d839eca3767..98c90970728 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -86,6 +86,7 @@ def create_test_azure_ai_chat_client( client.credential = None client.agent_id = agent_id client.agent_name = agent_name + client.agent_description = None client.model_id = azure_ai_settings.model_deployment_name client.thread_id = thread_id client.should_cleanup_agent = should_cleanup_agent @@ -441,34 +442,43 @@ async def test_azure_ai_chat_client_close_client_when_should_close_false(mock_ag mock_agents_client.close.assert_not_called() -def test_azure_ai_chat_client_update_agent_name_when_current_is_none(mock_agents_client: MagicMock) -> None: - """Test _update_agent_name updates name when current agent_name is None.""" +def test_azure_ai_chat_client_update_agent_name_and_description_when_current_is_none( + mock_agents_client: MagicMock, +) -> None: + """Test _update_agent_name_and_description updates name when current agent_name is None.""" chat_client = create_test_azure_ai_chat_client(mock_agents_client) chat_client.agent_name = None # type: ignore - chat_client._update_agent_name("NewAgentName") # type: ignore + chat_client._update_agent_name_and_description("NewAgentName", "description") # type: ignore assert chat_client.agent_name == "NewAgentName" + assert chat_client.agent_description == "description" -def test_azure_ai_chat_client_update_agent_name_when_current_exists(mock_agents_client: MagicMock) -> None: - """Test _update_agent_name does not update when current agent_name exists.""" +def test_azure_ai_chat_client_update_agent_name_and_description_when_current_exists( + mock_agents_client: MagicMock, +) -> None: + """Test _update_agent_name_and_description does not update when current agent_name exists.""" chat_client = create_test_azure_ai_chat_client(mock_agents_client) chat_client.agent_name = "ExistingName" # type: ignore + chat_client.agent_description = "ExistingDescription" # type: ignore - chat_client._update_agent_name("NewAgentName") # type: ignore + chat_client._update_agent_name_and_description("NewAgentName", "description") # type: ignore assert chat_client.agent_name == "ExistingName" + assert chat_client.agent_description == "ExistingDescription" -def test_azure_ai_chat_client_update_agent_name_with_none_input(mock_agents_client: MagicMock) -> None: - """Test _update_agent_name with None input.""" +def test_azure_ai_chat_client_update_agent_name_and_description_with_none_input(mock_agents_client: MagicMock) -> None: + """Test _update_agent_name_and_description with None input.""" chat_client = create_test_azure_ai_chat_client(mock_agents_client) chat_client.agent_name = None # type: ignore + chat_client.agent_description = None # type: ignore - chat_client._update_agent_name(None) # type: ignore + chat_client._update_agent_name_and_description(None, None) # type: ignore assert chat_client.agent_name is None + assert chat_client.agent_description is None async def test_azure_ai_chat_client_create_run_options_with_messages(mock_agents_client: MagicMock) -> None: diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index 9aaf0b3f77f..151ab620413 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -1,9 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Annotated from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( + AgentRunResponse, + AgentRunResponseUpdate, + ChatAgent, ChatClientProtocol, ChatMessage, ChatOptions, @@ -11,15 +18,50 @@ TextContent, ) from agent_framework.exceptions import ServiceInitializationError +from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( ResponseTextFormatConfigurationJsonSchema, ) +from azure.identity.aio import AzureCliCredential from openai.types.responses.parsed_response import ParsedResponse from openai.types.responses.response import Response as OpenAIResponse -from pydantic import BaseModel, ConfigDict, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError from agent_framework_azure_ai import AzureAIClient, AzureAISettings +skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" + or os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") + or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "", + reason=( + "No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests." + if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true" + else "Integration tests are disabled." + ), +) + + +@asynccontextmanager +async def temporary_chat_client(agent_name: str) -> AsyncIterator[AzureAIClient]: + """Async context manager that creates an Azure AI agent and yields an `AzureAIClient`. + + The underlying agent version is cleaned up automatically after use. + Tests can construct their own `ChatAgent` instances from the yielded client. + """ + endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + async with ( + AzureCliCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, + ): + chat_client = AzureAIClient( + project_client=project_client, + agent_name=agent_name, + ) + try: + yield chat_client + finally: + await project_client.agents.delete(agent_name=agent_name) + def create_test_azure_ai_client( mock_project_client: MagicMock, @@ -42,9 +84,11 @@ def create_test_azure_ai_client( client.credential = None client.agent_name = agent_name client.agent_version = agent_version + client.agent_description = None client.use_latest_version = use_latest_version client.model_id = azure_ai_settings.model_deployment_name client.conversation_id = conversation_id + client._is_application_endpoint = False # type: ignore client._should_close_client = should_close_client # type: ignore client.additional_properties = {} client.middleware = None @@ -263,6 +307,84 @@ async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicM assert run_options["extra_body"]["agent"]["name"] == "test-agent" +@pytest.mark.parametrize( + "endpoint,expects_agent", + [ + ("https://example.com/api/projects/my-project/applications/my-application/protocols", False), + ("https://example.com/api/projects/my-project", True), + ], +) +async def test_azure_ai_client_prepare_options_with_application_endpoint( + mock_azure_credential: MagicMock, endpoint: str, expects_agent: bool +) -> None: + client = AzureAIClient( + project_endpoint=endpoint, + model_deployment_name="test-model", + async_credential=mock_azure_credential, + agent_name="test-agent", + agent_version="1", + ) + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() + + with ( + patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + if expects_agent: + assert "extra_body" in run_options + assert run_options["extra_body"]["agent"]["name"] == "test-agent" + else: + assert "extra_body" not in run_options + + +@pytest.mark.parametrize( + "endpoint,expects_agent", + [ + ("https://example.com/api/projects/my-project/applications/my-application/protocols", False), + ("https://example.com/api/projects/my-project", True), + ], +) +async def test_azure_ai_client_prepare_options_with_application_project_client( + mock_project_client: MagicMock, endpoint: str, expects_agent: bool +) -> None: + mock_project_client._config = MagicMock() + mock_project_client._config.endpoint = endpoint + + client = AzureAIClient( + project_client=mock_project_client, + model_deployment_name="test-model", + agent_name="test-agent", + agent_version="1", + ) + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() + + with ( + patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + if expects_agent: + assert "extra_body" in run_options + assert run_options["extra_body"]["agent"]["name"] == "test-agent" + else: + assert "extra_body" not in run_options + + async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None: """Test initialize_client method.""" client = create_test_azure_ai_client(mock_project_client) @@ -276,14 +398,14 @@ async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) mock_project_client.get_openai_client.assert_called_once() -def test_azure_ai_client_update_agent_name(mock_project_client: MagicMock) -> None: - """Test _update_agent_name method.""" +def test_azure_ai_client_update_agent_name_and_description(mock_project_client: MagicMock) -> None: + """Test _update_agent_name_and_description method.""" client = create_test_azure_ai_client(mock_project_client) # Test updating agent name when current is None - with patch.object(client, "_update_agent_name") as mock_update: + with patch.object(client, "_update_agent_name_and_description") as mock_update: mock_update.return_value = None - client._update_agent_name("new-agent") # type: ignore + client._update_agent_name_and_description("new-agent") # type: ignore mock_update.assert_called_once_with("new-agent") # Test behavior when agent name is updated @@ -291,9 +413,9 @@ def test_azure_ai_client_update_agent_name(mock_project_client: MagicMock) -> No client.agent_name = "test-agent" # Manually set for the test # Test with None input - with patch.object(client, "_update_agent_name") as mock_update: + with patch.object(client, "_update_agent_name_and_description") as mock_update: mock_update.return_value = None - client._update_agent_name(None) # type: ignore + client._update_agent_name_and_description(None) # type: ignore mock_update.assert_called_once_with(None) @@ -519,6 +641,56 @@ async def test_azure_ai_client_agent_creation_with_response_format( assert "description" in schema["properties"] +async def test_azure_ai_client_agent_creation_with_mapping_response_format( + mock_project_client: MagicMock, +) -> None: + """Test agent creation when response_format is provided as a mapping.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + runtime_schema = { + "title": "WeatherDigest", + "type": "object", + "properties": { + "location": {"type": "string"}, + "conditions": {"type": "string"}, + "temperature_c": {"type": "number"}, + "advisory": {"type": "string"}, + }, + "required": ["location", "conditions", "temperature_c", "advisory"], + "additionalProperties": False, + } + + run_options = { + "model": "test-model", + "response_format": { + "type": "json_schema", + "json_schema": { + "name": runtime_schema["title"], + "strict": True, + "schema": runtime_schema, + }, + }, + } + + await client._get_agent_reference_or_create(run_options, None) # type: ignore + + call_args = mock_project_client.agents.create_version.call_args + created_definition = call_args[1]["definition"] + + assert hasattr(created_definition, "text") + assert created_definition.text is not None + format_config = created_definition.text.format + assert isinstance(format_config, ResponseTextFormatConfigurationJsonSchema) + assert format_config.name == runtime_schema["title"] + assert format_config.schema == runtime_schema + assert format_config.strict is True + + async def test_azure_ai_client_prepare_options_excludes_response_format( mock_project_client: MagicMock, ) -> None: @@ -751,3 +923,64 @@ def mock_project_client() -> MagicMock: mock_client.close = AsyncMock() return mock_client + + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + return f"The weather in {location} is sunny with a high of 25°C." + + +@pytest.mark.flaky +@skip_if_azure_ai_integration_tests_disabled +async def test_azure_ai_chat_client_agent_basic_run() -> None: + """Test ChatAgent basic run functionality with AzureAIClient.""" + async with ( + temporary_chat_client(agent_name="BasicRunAgent") as chat_client, + ChatAgent(chat_client=chat_client) as agent, + ): + response = await agent.run("Hello! Please respond with 'Hello World' exactly.") + + # Validate response + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + assert "Hello World" in response.text + + +@pytest.mark.flaky +@skip_if_azure_ai_integration_tests_disabled +async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None: + """Test ChatAgent basic streaming functionality with AzureAIClient.""" + async with ( + temporary_chat_client(agent_name="BasicRunStreamingAgent") as chat_client, + ChatAgent(chat_client=chat_client) as agent, + ): + full_message: str = "" + async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"): + assert chunk is not None + assert isinstance(chunk, AgentRunResponseUpdate) + if chunk.text: + full_message += chunk.text + + # Validate streaming response + assert len(full_message) > 0 + assert "streaming response test" in full_message.lower() + + +@pytest.mark.flaky +@skip_if_azure_ai_integration_tests_disabled +async def test_azure_ai_chat_client_agent_with_tools() -> None: + """Test ChatAgent tools with AzureAIClient.""" + async with ( + temporary_chat_client(agent_name="RunToolsAgent") as chat_client, + ChatAgent(chat_client=chat_client, tools=[get_weather]) as agent, + ): + response = await agent.run("What's the weather like in Seattle?") + + # Validate response + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + assert any(word in response.text.lower() for word in ["sunny", "25"]) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index e0bc3ba51a1..7d8ebe02646 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -9,6 +9,7 @@ import json import re from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, TypeVar, cast import azure.durable_functions as df @@ -39,6 +40,22 @@ EntityHandler = Callable[[df.DurableEntityContext], None] HandlerT = TypeVar("HandlerT", bound=Callable[..., Any]) + +@dataclass +class AgentMetadata: + """Metadata for a registered agent. + + Attributes: + agent: The agent instance implementing AgentProtocol + http_endpoint_enabled: Whether HTTP endpoint is enabled for this agent + mcp_tool_enabled: Whether MCP tool endpoint is enabled for this agent + """ + + agent: AgentProtocol + http_endpoint_enabled: bool + mcp_tool_enabled: bool + + if TYPE_CHECKING: class DFAppBase: @@ -56,6 +73,15 @@ def orchestration_trigger(self, context_name: str) -> Callable[[HandlerT], Handl def activity_trigger(self, input_name: str) -> Callable[[HandlerT], HandlerT]: ... + def mcp_tool_trigger( + self, + arg_name: str, + tool_name: str, + description: str, + tool_properties: str, + data_type: func.DataType, + ) -> Callable[[HandlerT], HandlerT]: ... + else: DFAppBase = df.DFApp # type: ignore[assignment] @@ -117,14 +143,15 @@ def my_orchestration(context): agents: Dictionary of agent name to AgentProtocol instance enable_health_check: Whether health check endpoint is enabled enable_http_endpoints: Whether HTTP endpoints are created for agents + enable_mcp_tool_trigger: Whether MCP tool triggers are created for agents max_poll_retries: Maximum polling attempts when waiting for responses poll_interval_seconds: Delay (seconds) between polling attempts """ - agents: dict[str, AgentProtocol] + _agent_metadata: dict[str, AgentMetadata] enable_health_check: bool enable_http_endpoints: bool - agent_http_endpoint_flags: dict[str, bool] + enable_mcp_tool_trigger: bool def __init__( self, @@ -134,6 +161,7 @@ def __init__( enable_http_endpoints: bool = True, max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES, poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, + enable_mcp_tool_trigger: bool = False, default_callback: AgentResponseCallbackProtocol | None = None, ): """Initialize the AgentFunctionApp. @@ -142,6 +170,8 @@ def __init__( :param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``). :param enable_health_check: Enable the built-in health check endpoint (default: ``True``). :param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``). + :param enable_mcp_tool_trigger: Enable MCP tool triggers for agents (default: ``False``). + When enabled, agents will be exposed as MCP tools that can be invoked by MCP-compatible clients. :param max_poll_retries: Maximum polling attempts when waiting for a response. Defaults to ``DEFAULT_MAX_POLL_RETRIES``. :param poll_interval_seconds: Delay in seconds between polling attempts. @@ -155,11 +185,11 @@ def __init__( # Initialize parent DFApp super().__init__(http_auth_level=http_auth_level) - # Initialize agents dictionary - self.agents = {} - self.agent_http_endpoint_flags = {} + # Initialize agent metadata dictionary + self._agent_metadata = {} self.enable_health_check = enable_health_check self.enable_http_endpoints = enable_http_endpoints + self.enable_mcp_tool_trigger = enable_mcp_tool_trigger self.default_callback = default_callback try: @@ -186,11 +216,21 @@ def __init__( logger.debug("[AgentFunctionApp] Initialization complete") + @property + def agents(self) -> dict[str, AgentProtocol]: + """Returns dict of agent names to agent instances. + + Returns: + Dictionary mapping agent names to their AgentProtocol instances. + """ + return {name: metadata.agent for name, metadata in self._agent_metadata.items()} + def add_agent( self, agent: AgentProtocol, callback: AgentResponseCallbackProtocol | None = None, enable_http_endpoint: bool | None = None, + enable_mcp_tool_trigger: bool | None = None, ) -> None: """Add an agent to the function app after initialization. @@ -198,8 +238,10 @@ def add_agent( agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol) The agent must have a 'name' attribute. callback: Optional callback invoked during agent execution - enable_http_endpoint: Optional flag that overrides the app-level - HTTP endpoint setting for this agent + enable_http_endpoint: Optional flag to enable/disable HTTP endpoint for this agent. + The app level enable_http_endpoints setting will override this setting. + enable_mcp_tool_trigger: Optional flag to enable/disable MCP tool trigger for this agent. + The app level enable_mcp_tool_trigger setting will override this setting. Raises: ValueError: If the agent doesn't have a 'name' attribute or if an agent @@ -210,12 +252,17 @@ def add_agent( if name is None: raise ValueError("Agent does not have a 'name' attribute. All agents must have a 'name' attribute.") - if name in self.agents: + if name in self._agent_metadata: raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.") effective_enable_http_endpoint = ( self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint) ) + effective_enable_mcp_endpoint = ( + self.enable_mcp_tool_trigger + if enable_mcp_tool_trigger is None + else self._coerce_to_bool(enable_mcp_tool_trigger) + ) logger.debug(f"[AgentFunctionApp] Adding agent: {name}") logger.debug(f"[AgentFunctionApp] Route: /api/agents/{name}") @@ -224,17 +271,21 @@ def add_agent( "enabled" if effective_enable_http_endpoint else "disabled", name, ) + logger.debug( + f"[AgentFunctionApp] MCP tool trigger: {'enabled' if effective_enable_mcp_endpoint else 'disabled'}" + ) - self.agents[name] = agent - self.agent_http_endpoint_flags[name] = effective_enable_http_endpoint + # Store agent metadata + self._agent_metadata[name] = AgentMetadata( + agent=agent, + http_endpoint_enabled=effective_enable_http_endpoint, + mcp_tool_enabled=effective_enable_mcp_endpoint, + ) effective_callback = callback or self.default_callback self._setup_agent_functions( - agent, - name, - effective_callback, - effective_enable_http_endpoint, + agent, name, effective_callback, effective_enable_http_endpoint, effective_enable_mcp_endpoint ) logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully") @@ -258,7 +309,7 @@ def get_agent( """ normalized_name = str(agent_name) - if normalized_name not in self.agents: + if normalized_name not in self._agent_metadata: raise ValueError(f"Agent '{normalized_name}' is not registered with this app.") return DurableAIAgent(context, normalized_name) @@ -269,15 +320,16 @@ def _setup_agent_functions( agent_name: str, callback: AgentResponseCallbackProtocol | None, enable_http_endpoint: bool, + enable_mcp_tool_trigger: bool, ) -> None: - """Set up the HTTP trigger and entity for a specific agent. + """Set up the HTTP trigger, entity, and MCP tool trigger for a specific agent. Args: agent: The agent instance agent_name: The name to use for routing and entity registration callback: Optional callback to receive response updates - enable_http_endpoint: Whether the HTTP run route is enabled for - this agent + enable_http_endpoint: Whether to create HTTP endpoint + enable_mcp_tool_trigger: Whether to create MCP tool trigger """ logger.debug(f"[AgentFunctionApp] Setting up functions for agent '{agent_name}'...") @@ -290,6 +342,12 @@ def _setup_agent_functions( ) self._setup_agent_entity(agent, agent_name, callback) + if enable_mcp_tool_trigger: + agent_description = agent.description + self._setup_mcp_tool_trigger(agent_name, agent_description) + else: + logger.debug(f"[AgentFunctionApp] MCP tool trigger disabled for agent '{agent_name}'") + def _setup_http_run_route(self, agent_name: str) -> None: """Register the POST route that triggers agent execution. @@ -448,6 +506,162 @@ def entity_function(context: df.DurableEntityContext) -> None: entity_function.__name__ = entity_name_with_prefix self.entity_trigger(context_name="context", entity_name=entity_name_with_prefix)(entity_function) + def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None) -> None: + """Register an MCP tool trigger for an agent using Azure Functions native MCP support. + + This creates a native Azure Functions MCP tool trigger that exposes the agent + as an MCP tool, allowing it to be invoked by MCP-compatible clients. + + Args: + agent_name: The agent name (used as the MCP tool name) + agent_description: Optional description for the MCP tool (shown to clients) + """ + mcp_function_name = self._build_function_name(agent_name, "mcptool") + + # Define tool properties as JSON (MCP tool parameters) + tool_properties = json.dumps([ + { + "propertyName": "query", + "propertyType": "string", + "description": "The query to send to the agent.", + "isRequired": True, + "isArray": False, + }, + { + "propertyName": "threadId", + "propertyType": "string", + "description": "Optional thread identifier for conversation continuity.", + "isRequired": False, + "isArray": False, + }, + ]) + + function_name_decorator = self.function_name(mcp_function_name) + mcp_tool_decorator = self.mcp_tool_trigger( + arg_name="context", + tool_name=agent_name, + description=agent_description or f"Interact with {agent_name} agent", + tool_properties=tool_properties, + data_type=func.DataType.UNDEFINED, + ) + durable_client_decorator = self.durable_client_input(client_name="client") + + @function_name_decorator + @mcp_tool_decorator + @durable_client_decorator + async def mcp_tool_handler(context: str, client: df.DurableOrchestrationClient) -> str: + """Handle MCP tool invocation for the agent. + + Args: + context: MCP tool invocation context containing arguments (query, threadId) + client: Durable orchestration client for entity communication + + Returns: + Agent response text + """ + logger.debug("[MCP Tool Trigger] Received invocation for agent: %s", agent_name) + return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) + + _ = mcp_tool_handler + logger.debug("[AgentFunctionApp] Registered MCP tool trigger for agent: %s", agent_name) + + async def _handle_mcp_tool_invocation( + self, agent_name: str, context: str, client: df.DurableOrchestrationClient + ) -> str: + """Handle an MCP tool invocation. + + This method processes MCP tool requests and delegates to the agent entity. + + Args: + agent_name: Name of the agent being invoked + context: MCP tool invocation context as a JSON string + client: Durable orchestration client + + Returns: + Agent response text + + Raises: + ValueError: If required arguments are missing or context is invalid JSON + RuntimeError: If agent execution fails + """ + logger.debug("[MCP Tool Handler] Processing invocation for agent '%s'", agent_name) + + # Parse JSON context string + try: + parsed_context: Any = json.loads(context) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid MCP context format: {e}") from e + + parsed_context = cast(Mapping[str, Any], parsed_context) if isinstance(parsed_context, dict) else {} + + # Extract arguments from MCP context + arguments: dict[str, Any] = parsed_context.get("arguments", {}) + + # Validate required 'query' argument + query: Any = arguments.get("query") + if not query or not isinstance(query, str): + raise ValueError("MCP Tool invocation is missing required 'query' argument of type string.") + + # Extract optional threadId + thread_id = arguments.get("threadId") + + # Create or parse session ID + if thread_id and isinstance(thread_id, str) and thread_id.strip(): + try: + session_id = AgentSessionId.parse(thread_id) + except ValueError as e: + logger.warning( + "Failed to parse AgentSessionId from thread_id '%s': %s. Falling back to new session ID.", + thread_id, + e, + ) + session_id = AgentSessionId(name=agent_name, key=thread_id) + else: + # Generate new session ID + session_id = AgentSessionId.with_random_key(agent_name) + + # Build entity instance ID + entity_instance_id = session_id.to_entity_id() + + # Create run request + correlation_id = self._generate_unique_id() + run_request = self._build_request_data( + req_body={"message": query, "role": "user"}, + message=query, + thread_id=str(session_id), + correlation_id=correlation_id, + request_response_format=REQUEST_RESPONSE_FORMAT_TEXT, + ) + + query_preview = query[:50] + "..." if len(query) > 50 else query + logger.info("[MCP Tool] Invoking agent '%s' with query: %s", agent_name, query_preview) + + # Signal entity to run agent + await client.signal_entity(entity_instance_id, "run_agent", run_request) + + # Poll for response (similar to HTTP handler) + try: + result = await self._get_response_from_entity( + client=client, + entity_instance_id=entity_instance_id, + correlation_id=correlation_id, + message=query, + thread_id=str(session_id), + ) + + # Extract and return response text + if result.get("status") == "success": + response_text = str(result.get("response", "No response")) + logger.info("[MCP Tool] Agent '%s' responded successfully", agent_name) + return response_text + error_msg = result.get("error", "Unknown error") + logger.error("[MCP Tool] Agent '%s' execution failed: %s", agent_name, error_msg) + raise RuntimeError(f"Agent execution failed: {error_msg}") + + except Exception as exc: + logger.error("[MCP Tool] Error invoking agent '%s': %s", agent_name, exc, exc_info=True) + raise + def _setup_health_route(self) -> None: """Register the optional health check route.""" health_route = self.route(route="health", methods=["GET"]) @@ -458,16 +672,14 @@ def health_check(req: func.HttpRequest) -> func.HttpResponse: agent_info = [ { "name": name, - "type": type(agent).__name__, - "http_endpoint_enabled": self.agent_http_endpoint_flags.get( - name, - self.enable_http_endpoints, - ), + "type": type(metadata.agent).__name__, + "http_endpoint_enabled": metadata.http_endpoint_enabled, + "mcp_tool_enabled": metadata.mcp_tool_enabled, } - for name, agent in self.agents.items() + for name, metadata in self._agent_metadata.items() ] return func.HttpResponse( - json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}), + json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self._agent_metadata)}), status_code=200, mimetype=MIMETYPE_APPLICATION_JSON, ) @@ -742,10 +954,9 @@ def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]: """Create a lowercase header mapping from the incoming request.""" headers: dict[str, str] = {} raw_headers = req.headers - if isinstance(raw_headers, Mapping): - for key, value in raw_headers.items(): - if value is not None: - headers[str(key).lower()] = str(value) + for key, value in cast(Mapping[str, str], raw_headers).items(): + headers[key.lower()] = value + return headers @staticmethod diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py index 73695e61f23..ffb71d2367b 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py @@ -32,7 +32,7 @@ import json from datetime import datetime, timezone from enum import Enum -from typing import Any +from typing import Any, cast from agent_framework import ( AgentRunResponse, @@ -53,7 +53,7 @@ ) from dateutil import parser as date_parser -from ._models import RunRequest, _serialize_response_format +from ._models import RunRequest, serialize_response_format logger = get_logger("agent_framework.azurefunctions.durable_agent_state") @@ -74,6 +74,130 @@ def _parse_created_at(value: Any) -> datetime: return datetime.now(tz=timezone.utc) +def _parse_messages(data: dict[str, Any]) -> list[DurableAgentStateMessage]: + """Parse messages from a dictionary, converting dicts to DurableAgentStateMessage objects. + + Args: + data: Dictionary containing a 'messages' key with a list of message data + + Returns: + List of DurableAgentStateMessage objects + """ + messages: list[DurableAgentStateMessage] = [] + raw_messages: list[Any] = data.get("messages", []) + for raw_msg in raw_messages: + if isinstance(raw_msg, dict): + messages.append(DurableAgentStateMessage.from_dict(cast(dict[str, Any], raw_msg))) + elif isinstance(raw_msg, DurableAgentStateMessage): + messages.append(raw_msg) + return messages + + +def _parse_history_entries(data_dict: dict[str, Any]) -> list[DurableAgentStateEntry]: + """Parse conversation history entries from a dictionary. + + Args: + data_dict: Dictionary containing a 'conversationHistory' key with a list of entry data + + Returns: + List of DurableAgentStateEntry objects (requests and responses) + """ + history_data: list[Any] = data_dict.get("conversationHistory", []) + deserialized_history: list[DurableAgentStateEntry] = [] + for raw_entry in history_data: + if isinstance(raw_entry, dict): + entry_dict = cast(dict[str, Any], raw_entry) + entry_type = entry_dict.get("$type") or entry_dict.get("json_type") + if entry_type == DurableAgentStateEntryJsonType.RESPONSE: + deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) + elif entry_type == DurableAgentStateEntryJsonType.REQUEST: + deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict)) + else: + deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict)) + elif isinstance(raw_entry, DurableAgentStateEntry): + deserialized_history.append(raw_entry) + return deserialized_history + + +def _parse_contents(data: dict[str, Any]) -> list[DurableAgentStateContent]: + """Parse content items from a dictionary. + + Args: + data: Dictionary containing a 'contents' key with a list of content data + + Returns: + List of DurableAgentStateContent objects + """ + contents: list[DurableAgentStateContent] = [] + raw_contents: list[Any] = data.get("contents", []) + for raw_content in raw_contents: + if isinstance(raw_content, dict): + content_dict = cast(dict[str, Any], raw_content) + content_type: str | None = content_dict.get("$type") + if content_type == DurableAgentStateTextContent.type: + contents.append(DurableAgentStateTextContent(text=content_dict.get("text"))) + elif content_type == DurableAgentStateDataContent.type: + contents.append( + DurableAgentStateDataContent( + uri=str(content_dict.get("uri", "")), + media_type=content_dict.get("mediaType"), + ) + ) + elif content_type == DurableAgentStateErrorContent.type: + contents.append( + DurableAgentStateErrorContent( + message=content_dict.get("message"), + error_code=content_dict.get("errorCode"), + details=content_dict.get("details"), + ) + ) + elif content_type == DurableAgentStateFunctionCallContent.type: + contents.append( + DurableAgentStateFunctionCallContent( + call_id=str(content_dict.get("callId", "")), + name=str(content_dict.get("name", "")), + arguments=content_dict.get("arguments", {}), + ) + ) + elif content_type == DurableAgentStateFunctionResultContent.type: + contents.append( + DurableAgentStateFunctionResultContent( + call_id=str(content_dict.get("callId", "")), + result=content_dict.get("result"), + ) + ) + elif content_type == DurableAgentStateHostedFileContent.type: + contents.append(DurableAgentStateHostedFileContent(file_id=str(content_dict.get("fileId", "")))) + elif content_type == DurableAgentStateHostedVectorStoreContent.type: + contents.append( + DurableAgentStateHostedVectorStoreContent( + vector_store_id=str(content_dict.get("vectorStoreId", "")) + ) + ) + elif content_type == DurableAgentStateTextReasoningContent.type: + contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get("text"))) + elif content_type == DurableAgentStateUriContent.type: + contents.append( + DurableAgentStateUriContent( + uri=str(content_dict.get("uri", "")), + media_type=str(content_dict.get("mediaType", "")), + ) + ) + elif content_type == DurableAgentStateUsageContent.type: + usage_data = content_dict.get("usage") + if usage_data and isinstance(usage_data, dict): + contents.append( + DurableAgentStateUsageContent( + usage=DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_data)) + ) + ) + elif content_type == DurableAgentStateUnknownContent.type: + contents.append(DurableAgentStateUnknownContent(content=content_dict.get("content", {}))) + elif isinstance(raw_content, DurableAgentStateContent): + contents.append(raw_content) + return contents + + class DurableAgentStateContent: """Base class for all content types in durable agent state messages. @@ -197,25 +321,8 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data_dict: dict[str, Any]) -> DurableAgentStateData: - # Restore the conversation history - deserialize entries from dicts to objects - history_data = data_dict.get("conversationHistory", []) - deserialized_history: list[DurableAgentStateEntry] = [] - for entry_dict in history_data: - if isinstance(entry_dict, dict): - # Deserialize based on $type discriminator - entry_type = entry_dict.get("$type") or entry_dict.get("json_type") - if entry_type == DurableAgentStateEntryJsonType.RESPONSE: - deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) - elif entry_type == DurableAgentStateEntryJsonType.REQUEST: - deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict)) - else: - deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict)) - else: - # Already an object - deserialized_history.append(entry_dict) - return cls( - conversation_history=deserialized_history, + conversation_history=_parse_history_entries(data_dict), extension_data=data_dict.get("extensionData"), ) @@ -227,7 +334,7 @@ class DurableAgentState: in Azure Durable Entities. It maintains the conversation history as a sequence of request and response entries, each with their messages, timestamps, and metadata. - The state follows a versioned schema (currently 1.0.0) that defines the structure for: + The state follows a versioned schema (see SCHEMA_VERSION class constant) that defines the structure for: - Request entries: User/system messages with optional response format specifications - Response entries: Assistant messages with token usage information - Messages: Individual chat messages with role, content items, and timestamps @@ -235,7 +342,7 @@ class DurableAgentState: State is serialized to JSON with this structure: { - "schemaVersion": "1.0.0", + "schemaVersion": "", "data": { "conversationHistory": [ {"$type": "request", "correlationId": "...", "createdAt": "...", "messages": [...]}, @@ -246,17 +353,20 @@ class DurableAgentState: Attributes: data: Container for conversation history and optional extension data - schema_version: Schema version string (defaults to "1.0.0") + schema_version: Schema version string (defaults to SCHEMA_VERSION) """ + # Durable Agent Schema version + SCHEMA_VERSION: str = "1.1.0" + data: DurableAgentStateData - schema_version: str = "1.0.0" + schema_version: str = SCHEMA_VERSION - def __init__(self, schema_version: str = "1.0.0"): + def __init__(self, schema_version: str = SCHEMA_VERSION): """Initialize a new durable agent state. Args: - schema_version: Schema version to use (defaults to "1.0.0") + schema_version: Schema version to use (defaults to SCHEMA_VERSION) """ self.data = DurableAgentStateData() self.schema_version = schema_version @@ -283,7 +393,7 @@ def from_dict(cls, state: dict[str, Any]) -> DurableAgentState: logger.warning("Resetting state as it is incompatible with the current schema, all history will be lost") return cls() - instance = cls(schema_version=state.get("schemaVersion", "1.0.0")) + instance = cls(schema_version=state.get("schemaVersion", DurableAgentState.SCHEMA_VERSION)) instance.data = DurableAgentStateData.from_dict(state.get("data", {})) return instance @@ -325,7 +435,7 @@ def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse): # Found the entry, extract response data # Get the text content from assistant messages only - content = "\n".join(message.text for message in entry.messages if message.text is not None) + content = "\n".join(message.text for message in entry.messages if message.text) return {"content": content, "message_count": self.message_count, "correlationId": correlation_id} return None @@ -388,28 +498,17 @@ def __init__( self.extension_data = extension_data def to_dict(self) -> dict[str, Any]: - # Ensure createdAt is never null - created_at_value = self.created_at - if created_at_value is None: - created_at_value = datetime.now(tz=timezone.utc) - return { "$type": self.json_type, "correlationId": self.correlation_id, - "createdAt": created_at_value.isoformat() if isinstance(created_at_value, datetime) else created_at_value, + "createdAt": self.created_at.isoformat(), "messages": [m.to_dict() for m in self.messages], } @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateEntry: created_at = _parse_created_at(data.get("created_at")) - - messages = [] - for msg_dict in data.get("messages", []): - if isinstance(msg_dict, dict): - messages.append(DurableAgentStateMessage.from_dict(msg_dict)) - else: - messages.append(msg_dict) + messages = _parse_messages(data) return cls( json_type=DurableAgentStateEntryJsonType(data.get("$type", "entry")), @@ -430,6 +529,7 @@ class DurableAgentStateRequest(DurableAgentStateEntry): Attributes: response_type: Expected response type ("text" or "json") response_schema: JSON schema for structured responses (when response_type is "json") + orchestration_id: ID of the orchestration that initiated this request (if any) correlationId: Unique identifier linking this request to its response created_at: Timestamp when the request was created messages: List of messages included in this request @@ -438,6 +538,7 @@ class DurableAgentStateRequest(DurableAgentStateEntry): response_type: str | None = None response_schema: dict[str, Any] | None = None + orchestration_id: str | None = None def __init__( self, @@ -447,6 +548,7 @@ def __init__( extension_data: dict[str, Any] | None = None, response_type: str | None = None, response_schema: dict[str, Any] | None = None, + orchestration_id: str | None = None, ) -> None: super().__init__( json_type=DurableAgentStateEntryJsonType.REQUEST, @@ -457,9 +559,12 @@ def __init__( ) self.response_type = response_type self.response_schema = response_schema + self.orchestration_id = orchestration_id def to_dict(self) -> dict[str, Any]: data = super().to_dict() + if self.orchestration_id is not None: + data["orchestrationId"] = self.orchestration_id if self.response_type is not None: data["responseType"] = self.response_type if self.response_schema is not None: @@ -469,13 +574,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: created_at = _parse_created_at(data.get("created_at")) - - messages = [] - for msg_dict in data.get("messages", []): - if isinstance(msg_dict, dict): - messages.append(DurableAgentStateMessage.from_dict(msg_dict)) - else: - messages.append(msg_dict) + messages = _parse_messages(data) return cls( correlation_id=data.get("correlationId", ""), @@ -484,6 +583,7 @@ def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: extension_data=data.get("extensionData"), response_type=data.get("responseType"), response_schema=data.get("responseSchema"), + orchestration_id=data.get("orchestrationId"), ) @staticmethod @@ -494,7 +594,8 @@ def from_run_request(request: RunRequest) -> DurableAgentStateRequest: messages=[DurableAgentStateMessage.from_run_request(request)], created_at=datetime.now(tz=timezone.utc), response_type=request.request_response_format, - response_schema=_serialize_response_format(request.response_format), + response_schema=serialize_response_format(request.response_format), + orchestration_id=request.orchestration_id, ) @@ -545,20 +646,12 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateResponse: created_at = _parse_created_at(data.get("created_at")) - - messages = [] - for msg_dict in data.get("messages", []): - if isinstance(msg_dict, dict): - messages.append(DurableAgentStateMessage.from_dict(msg_dict)) - else: - messages.append(msg_dict) + messages = _parse_messages(data) usage_dict = data.get("usage") - usage = None + usage: DurableAgentStateUsage | None = None if usage_dict and isinstance(usage_dict, dict): - usage = DurableAgentStateUsage.from_dict(usage_dict) - elif usage_dict: - usage = usage_dict + usage = DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_dict)) return cls( correlation_id=data.get("correlationId", ""), @@ -639,68 +732,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: - contents: list[DurableAgentStateContent] = [] - for content_dict in data.get("contents", []): - if isinstance(content_dict, dict): - content_type = content_dict.get("$type") - if content_type == DurableAgentStateTextContent.type: - contents.append(DurableAgentStateTextContent(text=content_dict.get("text"))) - elif content_type == DurableAgentStateDataContent.type: - contents.append( - DurableAgentStateDataContent( - uri=content_dict.get("uri", ""), media_type=content_dict.get("mediaType") - ) - ) - elif content_type == DurableAgentStateErrorContent.type: - contents.append( - DurableAgentStateErrorContent( - message=content_dict.get("message"), - error_code=content_dict.get("errorCode"), - details=content_dict.get("details"), - ) - ) - elif content_type == DurableAgentStateFunctionCallContent.type: - contents.append( - DurableAgentStateFunctionCallContent( - call_id=content_dict.get("callId", ""), - name=content_dict.get("name", ""), - arguments=content_dict.get("arguments", {}), - ) - ) - elif content_type == DurableAgentStateFunctionResultContent.type: - contents.append( - DurableAgentStateFunctionResultContent( - call_id=content_dict.get("callId", ""), result=content_dict.get("result") - ) - ) - elif content_type == DurableAgentStateHostedFileContent.type: - contents.append(DurableAgentStateHostedFileContent(file_id=content_dict.get("fileId", ""))) - elif content_type == DurableAgentStateHostedVectorStoreContent.type: - contents.append( - DurableAgentStateHostedVectorStoreContent(vector_store_id=content_dict.get("vectorStoreId", "")) - ) - elif content_type == DurableAgentStateTextReasoningContent.type: - contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get("text"))) - elif content_type == DurableAgentStateUriContent.type: - contents.append( - DurableAgentStateUriContent( - uri=content_dict.get("uri", ""), media_type=content_dict.get("mediaType", "") - ) - ) - elif content_type == DurableAgentStateUsageContent.type: - usage_data = content_dict.get("usage") - if usage_data and isinstance(usage_data, dict): - contents.append( - DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_dict(usage_data)) - ) - elif content_type == DurableAgentStateUnknownContent.type: - contents.append(DurableAgentStateUnknownContent(content=content_dict.get("content", {}))) - else: - contents.append(content_dict) # type: ignore - return cls( role=data.get("role", ""), - contents=contents, + contents=_parse_contents(data), author_name=data.get("authorName"), created_at=_parse_created_at(data.get("createdAt")), extension_data=data.get("extensionData"), @@ -709,7 +743,7 @@ def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: @property def text(self) -> str: """Extract text from the contents list.""" - text_parts = [] + text_parts: list[str] = [] for content in self.contents: if isinstance(content, DurableAgentStateTextContent): text_parts.append(content.text or "") diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py index a79269bd4da..45872ce1a17 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py @@ -9,9 +9,7 @@ import asyncio import inspect -import json from collections.abc import AsyncIterable, Callable -from datetime import datetime, timezone from typing import Any, cast import azure.durable_functions as df @@ -30,11 +28,10 @@ DurableAgentState, DurableAgentStateData, DurableAgentStateEntry, - DurableAgentStateMessage, DurableAgentStateRequest, DurableAgentStateResponse, ) -from ._models import AgentResponse, RunRequest +from ._models import RunRequest logger = get_logger("agent_framework.azurefunctions.entities") @@ -97,7 +94,7 @@ async def run_agent( self, context: df.DurableEntityContext, request: RunRequest | dict[str, Any] | str, - ) -> dict[str, Any]: + ) -> AgentRunResponse: """Execute the agent with a message directly in the entity. Args: @@ -105,13 +102,8 @@ async def run_agent( request: RunRequest object, dict, or string message (for backward compatibility) Returns: - Dict with status information and response (serialized AgentResponse) - - Note: - The agent returns an AgentRunResponse object which is stored in state. - This method extracts the text/structured response and returns an AgentResponse dict. + AgentRunResponse enriched with execution metadata. """ - # Convert string or dict to RunRequest if isinstance(request, str): run_request = RunRequest(message=request, role=Role.USER) elif isinstance(request, dict): @@ -135,8 +127,6 @@ async def run_agent( logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}") try: - logger.debug("[AgentEntity.run_agent] Starting agent invocation") - # Build messages from conversation history, excluding error responses # Error responses are kept in history for tracking but not sent to the agent chat_messages: list[ChatMessage] = [ @@ -164,83 +154,39 @@ async def run_agent( type(agent_run_response).__name__, ) - response_text = None - structured_response = None - response_str: str | None = None - try: - if response_format: - try: - response_str = agent_run_response.text - structured_response = json.loads(response_str) - logger.debug("Parsed structured JSON response") - except json.JSONDecodeError as decode_error: - logger.warning(f"Failed to parse JSON response: {decode_error}") - response_text = response_str - else: - raw_text = agent_run_response.text - response_text = raw_text if raw_text else "No response" - preview = response_text - logger.debug(f"Response: {preview[:100]}..." if len(preview) > 100 else f"Response: {preview}") + response_text = agent_run_response.text if agent_run_response.text else "No response" + logger.debug(f"Response: {response_text[:100]}...") except Exception as extraction_error: logger.error( - f"Error extracting response: {extraction_error}", + "Error extracting response text: %s", + extraction_error, exc_info=True, ) - response_text = "Error extracting response" state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response) self.state.data.conversation_history.append(state_response) - agent_response = AgentResponse( - response=response_text, - message=str(message), - thread_id=str(thread_id), - status="success", - message_count=len(self.state.data.conversation_history), - structured_response=structured_response, - ) - result = agent_response.to_dict() - logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history") - return result + return agent_run_response except Exception as exc: - import traceback - - error_traceback = traceback.format_exc() - logger.error("[AgentEntity.run_agent] Agent execution failed") - logger.error(f"Error: {exc!s}") - logger.error(f"Error type: {type(exc).__name__}") - logger.error(f"Full traceback:\n{error_traceback}") + logger.exception("[AgentEntity.run_agent] Agent execution failed.") # Create error message - error_message = DurableAgentStateMessage.from_chat_message( - ChatMessage( - role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)] - ) + error_message = ChatMessage( + role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)] ) + error_response = AgentRunResponse(messages=[error_message]) + # Create and store error response in conversation history - error_state_response = DurableAgentStateResponse( - correlation_id=correlation_id, - created_at=datetime.now(tz=timezone.utc), - messages=[error_message], - is_error=True, - ) + error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response) + error_state_response.is_error = True self.state.data.conversation_history.append(error_state_response) - error_response = AgentResponse( - response=f"Error: {exc!s}", - message=str(message), - thread_id=str(thread_id), - status="error", - message_count=len(self.state.data.conversation_history), - error=str(exc), - error_type=type(exc).__name__, - ) - return error_response.to_dict() + return error_response async def _invoke_agent( self, @@ -432,7 +378,7 @@ async def _entity_coroutine(context: df.DurableEntityContext) -> None: request = "" if input_data is None else str(cast(object, input_data)) result = await entity.run_agent(context, request) - context.set_result(result) + context.set_result(result.to_dict()) elif operation == "reset": entity.reset(context) @@ -442,15 +388,13 @@ async def _entity_coroutine(context: df.DurableEntityContext) -> None: logger.error("[entity_function] Unknown operation: %s", operation) context.set_result({"error": f"Unknown operation: {operation}"}) - logger.debug("State dict: %s", entity.state.to_dict()) - context.set_state(entity.state.to_dict()) + serialized_state = entity.state.to_dict() + logger.debug("State dict: %s", serialized_state) + context.set_state(serialized_state) logger.info(f"[entity_function] Operation {operation} completed successfully") except Exception as exc: - import traceback - - logger.error("[entity_function] Error in entity: %s", exc) - logger.error(f"[entity_function] Traceback:\n{traceback.format_exc()}") + logger.exception("[entity_function] Error executing entity operation %s", exc) context.set_result({"error": str(exc), "status": "error"}) def entity_function(context: df.DurableEntityContext) -> None: diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py index 19f175a485c..2ab96675754 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py @@ -213,7 +213,7 @@ async def deserialize( return thread -def _serialize_response_format(response_format: type[BaseModel] | None) -> Any: +def serialize_response_format(response_format: type[BaseModel] | None) -> Any: """Serialize response format for transport across durable function boundaries.""" if response_format is None: return None @@ -287,6 +287,7 @@ class RunRequest: thread_id: Optional thread ID for tracking correlation_id: Optional correlation ID for tracking the response to this specific request created_at: Optional timestamp when the request was created + orchestration_id: Optional ID of the orchestration that initiated this request """ message: str @@ -297,6 +298,7 @@ class RunRequest: thread_id: str | None = None correlation_id: str | None = None created_at: str | None = None + orchestration_id: str | None = None def __init__( self, @@ -308,6 +310,7 @@ def __init__( thread_id: str | None = None, correlation_id: str | None = None, created_at: str | None = None, + orchestration_id: str | None = None, ) -> None: self.message = message self.role = self.coerce_role(role) @@ -317,6 +320,7 @@ def __init__( self.thread_id = thread_id self.correlation_id = correlation_id self.created_at = created_at + self.orchestration_id = orchestration_id @staticmethod def coerce_role(value: Role | str | None) -> Role: @@ -339,13 +343,15 @@ def to_dict(self) -> dict[str, Any]: "request_response_format": self.request_response_format, } if self.response_format: - result["response_format"] = _serialize_response_format(self.response_format) + result["response_format"] = serialize_response_format(self.response_format) if self.thread_id: result["thread_id"] = self.thread_id if self.correlation_id: result["correlationId"] = self.correlation_id if self.created_at: result["created_at"] = self.created_at + if self.orchestration_id: + result["orchestrationId"] = self.orchestration_id return result @@ -361,51 +367,5 @@ def from_dict(cls, data: dict[str, Any]) -> RunRequest: thread_id=data.get("thread_id"), correlation_id=data.get("correlationId"), created_at=data.get("created_at"), + orchestration_id=data.get("orchestrationId"), ) - - -@dataclass -class AgentResponse: - """Response from agent execution. - - Attributes: - response: The agent's text response (or None for structured responses) - message: The original message sent to the agent - thread_id: The thread identifier - status: Status of the execution (success, error, etc.) - message_count: Number of messages in the conversation - error: Error message if status is error - error_type: Type of error if status is error - structured_response: Structured response if response_format was provided - """ - - response: str | None - message: str - thread_id: str | None - status: str - message_count: int = 0 - error: str | None = None - error_type: str | None = None - structured_response: dict[str, Any] | None = None - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - result: dict[str, Any] = { - "message": self.message, - "thread_id": self.thread_id, - "status": self.status, - "message_count": self.message_count, - } - - # Add response or structured_response based on what's available - if self.structured_response is not None: - result["structured_response"] = self.structured_response - elif self.response is not None: - result["response"] = self.response - - if self.error: - result["error"] = self.error - if self.error_type: - result["error_type"] = self.error_type - - return result diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py index 2fd4522964c..0f7e786778e 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py @@ -6,21 +6,148 @@ """ import uuid -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from typing import TYPE_CHECKING, Any, TypeAlias, cast -from agent_framework import AgentProtocol, AgentRunResponseUpdate, AgentThread, ChatMessage, get_logger +from agent_framework import ( + AgentProtocol, + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + ChatMessage, + get_logger, +) +from azure.durable_functions.models import TaskBase +from azure.durable_functions.models.Task import CompoundTask, TaskState +from pydantic import BaseModel from ._models import AgentSessionId, DurableAgentThread, RunRequest logger = get_logger("agent_framework.azurefunctions.orchestration") +CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None + if TYPE_CHECKING: - from azure.durable_functions import DurableOrchestrationContext as _DurableOrchestrationContext + from azure.durable_functions import DurableOrchestrationContext + + class _TypedCompoundTask(CompoundTask): # type: ignore[misc] + _first_error: Any + + def __init__( + self, + tasks: list[TaskBase], + compound_action_constructor: CompoundActionConstructor = None, + ) -> None: ... - AgentOrchestrationContextType: TypeAlias = _DurableOrchestrationContext + AgentOrchestrationContextType: TypeAlias = DurableOrchestrationContext else: AgentOrchestrationContextType = Any + _TypedCompoundTask = CompoundTask + + +class AgentTask(_TypedCompoundTask): + """A custom Task that wraps entity calls and provides typed AgentRunResponse results. + + This task wraps the underlying entity call task and intercepts its completion + to convert the raw result into a typed AgentRunResponse object. + """ + + def __init__( + self, + entity_task: TaskBase, + response_format: type[BaseModel] | None, + correlation_id: str, + ): + """Initialize the AgentTask. + + Args: + entity_task: The underlying entity call task + response_format: Optional Pydantic model for response parsing + correlation_id: Correlation ID for logging + """ + super().__init__([entity_task]) + self._response_format = response_format + self._correlation_id = correlation_id + + # Override action_repr to expose the inner task's action directly + # This ensures compatibility with ReplaySchema V3 which expects Action objects. + self.action_repr = entity_task.action_repr + + # Also copy the task ID to match the entity task's identity + self.id = entity_task.id + + def try_set_value(self, child: TaskBase) -> None: + """Transition the AgentTask to a terminal state and set its value to `AgentRunResponse`. + + Parameters + ---------- + child : TaskBase + The entity call task that just completed + """ + if child.state is TaskState.SUCCEEDED: + # Delegate to parent class for standard completion logic + if len(self.pending_tasks) == 0: + # Transform the raw result before setting it + raw_result = child.result + logger.debug( + "[AgentTask] Converting raw result for correlation_id %s", + self._correlation_id, + ) + + try: + response = self._load_agent_response(raw_result) + + if self._response_format is not None: + self._ensure_response_format( + self._response_format, + self._correlation_id, + response, + ) + + # Set the typed AgentRunResponse as this task's result + self.set_value(is_error=False, value=response) + except Exception as e: + logger.exception( + "[AgentTask] Failed to convert result for correlation_id: %s", + self._correlation_id, + ) + self.set_value(is_error=True, value=e) + else: + # If error not handled by the parent, set it explicitly. + if self._first_error is None: + self._first_error = child.result + self.set_value(is_error=True, value=self._first_error) + + def _load_agent_response(self, agent_response: AgentRunResponse | dict[str, Any] | None) -> AgentRunResponse: + """Convert raw payloads into AgentRunResponse instance.""" + if agent_response is None: + raise ValueError("agent_response cannot be None") + + logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response)) + + if isinstance(agent_response, AgentRunResponse): + return agent_response + if isinstance(agent_response, dict): + logger.debug("[load_agent_response] Converting dict payload using AgentRunResponse.from_dict") + return AgentRunResponse.from_dict(agent_response) + + raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}") + + def _ensure_response_format( + self, + response_format: type[BaseModel] | None, + correlation_id: str, + response: AgentRunResponse, + ) -> None: + """Ensure the AgentRunResponse value is parsed into the expected response_format.""" + if response_format is not None and not isinstance(response.value, response_format): + response.try_parse_value(response_format) + + logger.debug( + "[DurableAIAgent] Loaded AgentRunResponse.value for correlation_id %s with type: %s", + correlation_id, + type(response.value).__name__, + ) class DurableAIAgent(AgentProtocol): @@ -59,7 +186,7 @@ def __init__(self, context: AgentOrchestrationContextType, agent_name: str): self._name = agent_name self._display_name = agent_name self._description = f"Durable agent proxy for {agent_name}" - logger.debug(f"[DurableAIAgent] Initialized for agent: {agent_name}") + logger.debug("[DurableAIAgent] Initialized for agent: %s", agent_name) @property def id(self) -> str: @@ -81,38 +208,45 @@ def description(self) -> str | None: """Get the description of the agent.""" return self._description - def run( + # We return an AgentTask here which is a TaskBase subclass. + # This is an intentional deviation from AgentProtocol which defines run() as async. + # The AgentTask can be yielded in Durable Functions orchestrations and will provide + # a typed AgentRunResponse result. + def run( # type: ignore[override] self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, *, thread: AgentThread | None = None, + response_format: type[BaseModel] | None = None, **kwargs: Any, - ) -> Any: # TODO(msft-team): Add a wrapper to respond correctly with `AgentRunResponse` - """Execute the agent with messages and return a Task for orchestrations. + ) -> AgentTask: + """Execute the agent with messages and return an AgentTask for orchestrations. - This method implements AgentProtocol and returns a Task that can be yielded - in Durable Functions orchestrations. + This method implements AgentProtocol and returns an AgentTask (subclass of TaskBase) + that can be yielded in Durable Functions orchestrations. The task's result will be + a typed AgentRunResponse. Args: messages: The message(s) to send to the agent thread: Optional agent thread for conversation context - **kwargs: Additional arguments (enable_tool_calls, response_format, etc.) + response_format: Optional Pydantic model for response parsing + **kwargs: Additional arguments (enable_tool_calls) Returns: - Task that will resolve to the agent response + An AgentTask that resolves to an AgentRunResponse when yielded Example: @app.orchestration_trigger(context_name="context") def my_orchestration(context): agent = app.get_agent(context, "MyAgent") thread = agent.get_new_thread() - result = yield agent.run("Hello", thread=thread) + response = yield agent.run("Hello", thread=thread) + # response is typed as AgentRunResponse """ message_str = self._normalize_messages(messages) # Extract optional parameters from kwargs enable_tool_calls = kwargs.get("enable_tool_calls", True) - response_format = kwargs.get("response_format") # Get the session ID for the entity if isinstance(thread, DurableAgentThread) and thread.session_id is not None: @@ -122,7 +256,7 @@ def my_orchestration(context): # This ensures each call gets its own conversation context session_key = str(self.context.new_uuid()) session_id = AgentSessionId(name=self.agent_name, key=session_key) - logger.warning(f"[DurableAIAgent] No thread provided, created unique session_id: {session_id}") + logger.warning("[DurableAIAgent] No thread provided, created unique session_id: %s", session_id) # Create entity ID from session ID entity_id = session_id.to_entity_id() @@ -130,21 +264,42 @@ def my_orchestration(context): # Generate a deterministic correlation ID for this call # This is required by the entity and must be unique per call correlation_id = str(self.context.new_uuid()) + logger.debug( + "[DurableAIAgent] Using correlation_id: %s for entity_id: %s for session_id: %s", + correlation_id, + entity_id, + session_id, + ) # Prepare the request using RunRequest model + # Include the orchestration's instance_id so it can be stored in the agent's entity state run_request = RunRequest( message=message_str, enable_tool_calls=enable_tool_calls, correlation_id=correlation_id, thread_id=session_id.key, response_format=response_format, + orchestration_id=self.context.instance_id, + ) + + logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100]) + + # Call the entity to get the underlying task + entity_task = self.context.call_entity(entity_id, "run_agent", run_request.to_dict()) + + # Wrap it in an AgentTask that will convert the result to AgentRunResponse + agent_task = AgentTask( + entity_task=entity_task, + response_format=response_format, + correlation_id=correlation_id, ) - logger.debug(f"[DurableAIAgent] Calling entity {entity_id} with message: {message_str[:100]}...") + logger.debug( + "[DurableAIAgent] Created AgentTask for correlation_id %s", + correlation_id, + ) - # Call the entity and return the Task directly - # The orchestration will yield this Task - return self.context.call_entity(entity_id, "run_agent", run_request.to_dict()) + return agent_task def run_stream( self, @@ -179,7 +334,7 @@ def get_new_thread(self, **kwargs: Any) -> AgentThread: thread = DurableAgentThread.from_session_id(session_id, **kwargs) - logger.debug(f"[DurableAIAgent] Created new thread with session_id: {session_id}") + logger.debug("[DurableAIAgent] Created new thread with session_id: %s", session_id) return thread def _messages_to_string(self, messages: list[ChatMessage]) -> str: diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index ecc4d8688e5..621237490ef 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index ebf6eef3e6d..817a81e856d 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -2,6 +2,7 @@ """Unit tests for AgentFunctionApp.""" +import json from collections.abc import Awaitable, Callable from typing import Any, TypeVar from unittest.mock import ANY, AsyncMock, Mock, patch @@ -9,7 +10,7 @@ import azure.durable_functions as df import azure.functions as func import pytest -from agent_framework import AgentRunResponse, ChatMessage +from agent_framework import AgentRunResponse, ChatMessage, ErrorContent from agent_framework_azurefunctions import AgentFunctionApp from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER @@ -87,7 +88,7 @@ def test_add_agent_uses_specific_callback(self) -> None: app.add_agent(mock_agent, callback=specific_callback) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is specific_callback assert enable_http_endpoint is True @@ -103,7 +104,7 @@ def test_default_callback_applied_when_no_specific(self) -> None: app.add_agent(mock_agent) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is default_callback assert enable_http_endpoint is True @@ -118,7 +119,7 @@ def test_init_with_agents_uses_default_callback(self) -> None: AgentFunctionApp(agents=[mock_agent], default_callback=default_callback) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is default_callback assert enable_http_endpoint is True @@ -239,7 +240,7 @@ def test_agent_override_enables_http_route_when_app_disabled(self) -> None: http_route_mock.assert_called_once_with("OverrideAgent") agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY) - assert app.agent_http_endpoint_flags["OverrideAgent"] is True + assert app._agent_metadata["OverrideAgent"].http_endpoint_enabled is True def test_agent_override_disables_http_route_when_app_enabled(self) -> None: """Agent-level override should disable HTTP route even when app enables it.""" @@ -256,7 +257,7 @@ def test_agent_override_disables_http_route_when_app_enabled(self) -> None: http_route_mock.assert_not_called() agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY) - assert app.agent_http_endpoint_flags["DisabledOverride"] is False + assert app._agent_metadata["DisabledOverride"].http_endpoint_enabled is False def test_multiple_apps_independent(self) -> None: """Test that multiple AgentFunctionApp instances are independent.""" @@ -342,10 +343,8 @@ async def test_entity_run_agent_operation(self) -> None: {"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"}, ) - assert result["status"] == "success" - assert result["response"] == "Test response" - assert result["message"] == "Test message" - assert result["thread_id"] == "test-conv-123" + assert isinstance(result, AgentRunResponse) + assert result.text == "Test response" assert entity.state.message_count == 2 async def test_entity_stores_conversation_history(self) -> None: @@ -590,10 +589,12 @@ async def test_entity_handles_agent_error(self) -> None: mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"} ) - assert result["status"] == "error" - assert "error" in result - assert "Agent error" in result["error"] - assert result["error_type"] == "Exception" + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert "Agent error" in (content.message or "") + assert content.error_code == "Exception" def test_entity_function_handles_exception(self) -> None: """Test that the entity function handles exceptions gracefully.""" @@ -797,5 +798,271 @@ async def test_http_run_rejects_empty_message(self) -> None: client.signal_entity.assert_not_called() +class TestMCPToolEndpoint: + """Test suite for MCP tool endpoint functionality.""" + + def test_init_with_mcp_tool_endpoint_enabled(self) -> None: + """Test initialization with MCP tool endpoint enabled.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) + + assert app.enable_mcp_tool_trigger is True + + def test_init_with_mcp_tool_endpoint_disabled(self) -> None: + """Test initialization with MCP tool endpoint disabled (default).""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + + assert app.enable_mcp_tool_trigger is False + + def test_add_agent_with_mcp_tool_trigger_enabled(self) -> None: + """Test adding an agent with MCP tool trigger explicitly enabled.""" + mock_agent = Mock() + mock_agent.name = "MCPAgent" + mock_agent.description = "Test MCP Agent" + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + app = AgentFunctionApp() + app.add_agent(mock_agent, enable_mcp_tool_trigger=True) + + setup_mock.assert_called_once() + _, _, _, _, enable_mcp = setup_mock.call_args[0] + assert enable_mcp is True + + def test_add_agent_with_mcp_tool_trigger_disabled(self) -> None: + """Test adding an agent with MCP tool trigger explicitly disabled.""" + mock_agent = Mock() + mock_agent.name = "NoMCPAgent" + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + app = AgentFunctionApp(enable_mcp_tool_trigger=True) + app.add_agent(mock_agent, enable_mcp_tool_trigger=False) + + setup_mock.assert_called_once() + _, _, _, _, enable_mcp = setup_mock.call_args[0] + assert enable_mcp is False + + def test_agent_override_enables_mcp_when_app_disabled(self) -> None: + """Test that per-agent override can enable MCP when app-level is disabled.""" + mock_agent = Mock() + mock_agent.name = "OverrideAgent" + + with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock: + app = AgentFunctionApp(enable_mcp_tool_trigger=False) + app.add_agent(mock_agent, enable_mcp_tool_trigger=True) + + mcp_setup_mock.assert_called_once() + + def test_agent_override_disables_mcp_when_app_enabled(self) -> None: + """Test that per-agent override can disable MCP when app-level is enabled.""" + mock_agent = Mock() + mock_agent.name = "NoOverrideAgent" + + with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock: + app = AgentFunctionApp(enable_mcp_tool_trigger=True) + app.add_agent(mock_agent, enable_mcp_tool_trigger=False) + + mcp_setup_mock.assert_not_called() + + def test_setup_mcp_tool_trigger_registers_decorators(self) -> None: + """Test that _setup_mcp_tool_trigger registers the correct decorators.""" + mock_agent = Mock() + mock_agent.name = "MCPToolAgent" + mock_agent.description = "Test MCP Tool" + + app = AgentFunctionApp() + + # Mock the decorators + with ( + patch.object(app, "function_name") as func_name_mock, + patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, + patch.object(app, "durable_client_input") as client_mock, + ): + # Setup mock decorator chain + func_name_mock.return_value = lambda f: f + mcp_trigger_mock.return_value = lambda f: f + client_mock.return_value = lambda f: f + + app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description) + + # Verify decorators were called with correct parameters + func_name_mock.assert_called_once() + mcp_trigger_mock.assert_called_once_with( + arg_name="context", + tool_name=mock_agent.name, + description=mock_agent.description, + tool_properties=ANY, + data_type=func.DataType.UNDEFINED, + ) + client_mock.assert_called_once_with(client_name="client") + + def test_setup_mcp_tool_trigger_uses_default_description(self) -> None: + """Test that _setup_mcp_tool_trigger uses default description when none provided.""" + mock_agent = Mock() + mock_agent.name = "NoDescAgent" + + app = AgentFunctionApp() + + with ( + patch.object(app, "function_name", return_value=lambda f: f), + patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, + patch.object(app, "durable_client_input", return_value=lambda f: f), + ): + mcp_trigger_mock.return_value = lambda f: f + + app._setup_mcp_tool_trigger(mock_agent.name, None) + + # Verify default description was used + call_args = mcp_trigger_mock.call_args + assert call_args[1]["description"] == f"Interact with {mock_agent.name} agent" + + async def test_handle_mcp_tool_invocation_with_json_string(self) -> None: + """Test _handle_mcp_tool_invocation with JSON string context.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Mock the entity response + mock_state = Mock() + mock_state.entity_state = { + "schemaVersion": "1.0.0", + "data": {"conversationHistory": []}, + } + client.read_entity_state.return_value = mock_state + + # Create JSON string context + context = '{"arguments": {"query": "test query", "threadId": "test-thread"}}' + + with patch.object(app, "_get_response_from_entity") as get_response_mock: + get_response_mock.return_value = {"status": "success", "response": "Test response"} + + result = await app._handle_mcp_tool_invocation("TestAgent", context, client) + + assert result == "Test response" + get_response_mock.assert_called_once() + + async def test_handle_mcp_tool_invocation_with_json_context(self) -> None: + """Test _handle_mcp_tool_invocation with JSON string context.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Mock the entity response + mock_state = Mock() + mock_state.entity_state = { + "schemaVersion": "1.0.0", + "data": {"conversationHistory": []}, + } + client.read_entity_state.return_value = mock_state + + # Create JSON string context + context = json.dumps({"arguments": {"query": "test query", "threadId": "test-thread"}}) + + with patch.object(app, "_get_response_from_entity") as get_response_mock: + get_response_mock.return_value = {"status": "success", "response": "Test response"} + + result = await app._handle_mcp_tool_invocation("TestAgent", context, client) + + assert result == "Test response" + get_response_mock.assert_called_once() + + async def test_handle_mcp_tool_invocation_missing_query(self) -> None: + """Test _handle_mcp_tool_invocation raises ValueError when query is missing.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Context missing query (as JSON string) + context = json.dumps({"arguments": {}}) + + with pytest.raises(ValueError, match="missing required 'query' argument"): + await app._handle_mcp_tool_invocation("TestAgent", context, client) + + async def test_handle_mcp_tool_invocation_invalid_json(self) -> None: + """Test _handle_mcp_tool_invocation raises ValueError for invalid JSON.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Invalid JSON string + context = "not valid json" + + with pytest.raises(ValueError, match="Invalid MCP context format"): + await app._handle_mcp_tool_invocation("TestAgent", context, client) + + async def test_handle_mcp_tool_invocation_runtime_error(self) -> None: + """Test _handle_mcp_tool_invocation raises RuntimeError when agent fails.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Mock the entity response + mock_state = Mock() + mock_state.entity_state = { + "schemaVersion": "1.0.0", + "data": {"conversationHistory": []}, + } + client.read_entity_state.return_value = mock_state + + context = '{"arguments": {"query": "test query"}}' + + with patch.object(app, "_get_response_from_entity") as get_response_mock: + get_response_mock.return_value = {"status": "failed", "error": "Agent error"} + + with pytest.raises(RuntimeError, match="Agent execution failed"): + await app._handle_mcp_tool_invocation("TestAgent", context, client) + + def test_health_check_includes_mcp_tool_enabled(self) -> None: + """Test that health check endpoint includes mcp_tool_enabled field.""" + mock_agent = Mock() + mock_agent.name = "HealthAgent" + + app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) + + # Capture the health check handler function + captured_handler = None + + def capture_decorator(*args, **kwargs): + def decorator(func): + nonlocal captured_handler + captured_handler = func + return func + + return decorator + + with patch.object(app, "route", side_effect=capture_decorator): + app._setup_health_route() + + # Verify we captured the handler + assert captured_handler is not None + + # Call the health handler + request = Mock() + response = captured_handler(request) + + # Verify response includes mcp_tool_enabled + import json + + body = json.loads(response.get_body().decode("utf-8")) + assert "agents" in body + assert len(body["agents"]) == 1 + assert "mcp_tool_enabled" in body["agents"][0] + assert body["agents"][0]["mcp_tool_enabled"] is True + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index 2f73f1daa82..1c3f5168a5d 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role +from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, ErrorContent, Role from pydantic import BaseModel from agent_framework_azurefunctions._durable_agent_state import ( @@ -79,7 +79,7 @@ def test_init_creates_entity(self) -> None: assert entity.agent == mock_agent assert len(entity.state.data.conversation_history) == 0 assert entity.state.data.extension_data is None - assert entity.state.schema_version == "1.0.0" + assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION def test_init_stores_agent_reference(self) -> None: """Test that the agent reference is stored correctly.""" @@ -124,8 +124,7 @@ async def test_run_agent_executes_agent(self) -> None: # Verify agent.run was called mock_agent.run.assert_called_once() _, kwargs = mock_agent.run.call_args - sent_messages = kwargs.get("messages") - assert isinstance(sent_messages, list) + sent_messages: list[Any] = kwargs.get("messages") assert len(sent_messages) == 1 sent_message = sent_messages[0] assert isinstance(sent_message, ChatMessage) @@ -133,10 +132,8 @@ async def test_run_agent_executes_agent(self) -> None: assert getattr(sent_message.role, "value", sent_message.role) == "user" # Verify result - assert result["status"] == "success" - assert result["response"] == "Test response" - assert result["message"] == "Test message" - assert result["thread_id"] == "conv-123" + assert isinstance(result, AgentRunResponse) + assert result.text == "Test response" async def test_run_agent_streaming_callbacks_invoked(self) -> None: """Ensure streaming updates trigger callbacks and run() is not used.""" @@ -168,8 +165,8 @@ async def update_generator() -> AsyncIterator[AgentRunResponseUpdate]: }, ) - assert result["status"] == "success" - assert "Hello" in result.get("response", "") + assert isinstance(result, AgentRunResponse) + assert "Hello" in result.text assert callback.stream_mock.await_count == len(updates) assert callback.response_mock.await_count == 1 mock_agent.run.assert_not_called() @@ -215,8 +212,8 @@ async def test_run_agent_final_callback_without_streaming(self) -> None: }, ) - assert result["status"] == "success" - assert result.get("response") == "Final response" + assert isinstance(result, AgentRunResponse) + assert result.text == "Final response" assert callback.stream_mock.await_count == 0 assert callback.response_mock.await_count == 1 @@ -294,44 +291,6 @@ async def test_run_agent_with_none_thread_id(self) -> None: mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"} ) - async def test_run_agent_handles_response_without_text_attribute(self) -> None: - """Test that run_agent handles responses without a text attribute.""" - mock_agent = Mock() - - class NoTextResponse(AgentRunResponse): - @property - def text(self) -> str: # type: ignore[override] - raise AttributeError("text attribute missing") - - mock_response = NoTextResponse(messages=[ChatMessage(role="assistant", text="ignored")]) - mock_agent.run = AsyncMock(return_value=mock_response) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run_agent( - mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-6"} - ) - - # Should handle gracefully - assert result["status"] == "success" - assert result["response"] == "Error extracting response" - - async def test_run_agent_handles_none_response_text(self) -> None: - """Test that run_agent handles responses with None text.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response(None)) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run_agent( - mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-7"} - ) - - assert result["status"] == "success" - assert result["response"] == "No response" - async def test_run_agent_multiple_conversations(self) -> None: """Test that run_agent maintains history across multiple messages.""" mock_agent = Mock() @@ -621,10 +580,12 @@ async def test_run_agent_handles_agent_exception(self) -> None: mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"} ) - assert result["status"] == "error" - assert "error" in result - assert "Agent failed" in result["error"] - assert result["error_type"] == "Exception" + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert "Agent failed" in (content.message or "") + assert content.error_code == "Exception" async def test_run_agent_handles_value_error(self) -> None: """Test that run_agent handles ValueError instances.""" @@ -638,9 +599,12 @@ async def test_run_agent_handles_value_error(self) -> None: mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"} ) - assert result["status"] == "error" - assert result["error_type"] == "ValueError" - assert "Invalid input" in result["error"] + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert content.error_code == "ValueError" + assert "Invalid input" in str(content.message) async def test_run_agent_handles_timeout_error(self) -> None: """Test that run_agent handles TimeoutError instances.""" @@ -654,8 +618,11 @@ async def test_run_agent_handles_timeout_error(self) -> None: mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"} ) - assert result["status"] == "error" - assert result["error_type"] == "TimeoutError" + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert content.error_code == "TimeoutError" def test_entity_function_handles_exception_in_operation(self) -> None: """Test that the entity function handles exceptions gracefully.""" @@ -690,9 +657,10 @@ async def test_run_agent_preserves_message_on_error(self) -> None: ) # Even on error, message info should be preserved - assert result["message"] == "Test message" - assert result["thread_id"] == "conv-123" - assert result["status"] == "error" + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) class TestConversationHistory: @@ -800,10 +768,8 @@ async def test_run_agent_with_run_request_object(self) -> None: result = await entity.run_agent(mock_context, request) - assert result["status"] == "success" - assert result["response"] == "Response" - assert result["message"] == "Test message" - assert result["thread_id"] == "conv-123" + assert isinstance(result, AgentRunResponse) + assert result.text == "Response" async def test_run_agent_with_dict_request(self) -> None: """Test run_agent with a dictionary request.""" @@ -823,9 +789,8 @@ async def test_run_agent_with_dict_request(self) -> None: result = await entity.run_agent(mock_context, request_dict) - assert result["status"] == "success" - assert result["message"] == "Test message" - assert result["thread_id"] == "conv-456" + assert isinstance(result, AgentRunResponse) + assert result.text == "Response" async def test_run_agent_with_string_raises_without_correlation(self) -> None: """Test that run_agent rejects legacy string input without correlation ID.""" @@ -879,10 +844,9 @@ async def test_run_agent_with_response_format(self) -> None: result = await entity.run_agent(mock_context, request) - assert result["status"] == "success" - # Should have structured_response - if "structured_response" in result: - assert result["structured_response"]["answer"] == 42 + assert isinstance(result, AgentRunResponse) + assert result.text == '{"answer": 42}' + assert result.value is None async def test_run_agent_disable_tool_calls(self) -> None: """Test run_agent with tool calls disabled.""" @@ -898,7 +862,7 @@ async def test_run_agent_disable_tool_calls(self) -> None: result = await entity.run_agent(mock_context, request) - assert result["status"] == "success" + assert isinstance(result, AgentRunResponse) # Agent should have been called (tool disabling is framework-dependent) mock_agent.run.assert_called_once() @@ -925,8 +889,117 @@ async def test_entity_function_with_run_request_dict(self) -> None: # Verify result was set assert mock_context.set_result.called result = mock_context.set_result.call_args[0][0] - assert result["status"] == "success" - assert result["message"] == "Test message" + assert isinstance(result, dict) + + # Check if messages are present + assert "messages" in result + assert len(result["messages"]) > 0 + message = result["messages"][0] + + # Check for text in various possible locations + text_found = False + if "text" in message and message["text"] == "Response": + text_found = True + elif "contents" in message: + for content in message["contents"]: + if isinstance(content, dict) and content.get("text") == "Response": + text_found = True + break + + assert text_found, f"Response text not found in message: {message}" + + +class TestDurableAgentStateRequestOrchestrationId: + """Test suite for DurableAgentStateRequest orchestration_id field.""" + + def test_request_with_orchestration_id(self) -> None: + """Test creating a request with an orchestration_id.""" + request = DurableAgentStateRequest( + correlation_id="corr-123", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="user", + contents=[DurableAgentStateTextContent(text="test")], + ) + ], + orchestration_id="orch-456", + ) + + assert request.orchestration_id == "orch-456" + + def test_request_to_dict_includes_orchestration_id(self) -> None: + """Test that to_dict includes orchestrationId when set.""" + request = DurableAgentStateRequest( + correlation_id="corr-123", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="user", + contents=[DurableAgentStateTextContent(text="test")], + ) + ], + orchestration_id="orch-789", + ) + + data = request.to_dict() + + assert "orchestrationId" in data + assert data["orchestrationId"] == "orch-789" + + def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None: + """Test that to_dict excludes orchestrationId when not set.""" + request = DurableAgentStateRequest( + correlation_id="corr-123", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="user", + contents=[DurableAgentStateTextContent(text="test")], + ) + ], + ) + + data = request.to_dict() + + assert "orchestrationId" not in data + + def test_request_from_dict_with_orchestration_id(self) -> None: + """Test from_dict correctly parses orchestrationId.""" + data = { + "$type": "request", + "correlationId": "corr-123", + "createdAt": "2024-01-01T00:00:00Z", + "messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}], + "orchestrationId": "orch-from-dict", + } + + request = DurableAgentStateRequest.from_dict(data) + + assert request.orchestration_id == "orch-from-dict" + + def test_request_from_run_request_with_orchestration_id(self) -> None: + """Test from_run_request correctly transfers orchestration_id.""" + run_request = RunRequest( + message="test message", + correlation_id="corr-run", + orchestration_id="orch-from-run-request", + ) + + durable_request = DurableAgentStateRequest.from_run_request(run_request) + + assert durable_request.orchestration_id == "orch-from-run-request" + + def test_request_from_run_request_without_orchestration_id(self) -> None: + """Test from_run_request correctly handles missing orchestration_id.""" + run_request = RunRequest( + message="test message", + correlation_id="corr-run", + ) + + durable_request = DurableAgentStateRequest.from_run_request(run_request) + + assert durable_request.orchestration_id is None if __name__ == "__main__": diff --git a/python/packages/azurefunctions/tests/test_models.py b/python/packages/azurefunctions/tests/test_models.py index 5b803ead138..74efa9c1661 100644 --- a/python/packages/azurefunctions/tests/test_models.py +++ b/python/packages/azurefunctions/tests/test_models.py @@ -7,7 +7,7 @@ from agent_framework import Role from pydantic import BaseModel -from agent_framework_azurefunctions._models import AgentResponse, AgentSessionId, RunRequest +from agent_framework_azurefunctions._models import AgentSessionId, RunRequest class ModuleStructuredResponse(BaseModel): @@ -336,106 +336,70 @@ def test_round_trip_with_correlationId(self) -> None: assert restored.correlation_id == original.correlation_id assert restored.thread_id == original.thread_id + def test_init_with_orchestration_id(self) -> None: + """Test RunRequest initialization with orchestration_id.""" + request = RunRequest( + message="Test message", + thread_id="thread-orch-init", + orchestration_id="orch-123", + ) -class TestAgentResponse: - """Test suite for AgentResponse.""" + assert request.message == "Test message" + assert request.orchestration_id == "orch-123" - def test_init_with_required_fields(self) -> None: - """Test AgentResponse initialization with required fields.""" - response = AgentResponse( - response="Test response", message="Test message", thread_id="thread-123", status="success" + def test_to_dict_with_orchestration_id(self) -> None: + """Test to_dict includes orchestrationId.""" + request = RunRequest( + message="Test", + thread_id="thread-orch-to-dict", + orchestration_id="orch-456", ) + data = request.to_dict() - assert response.response == "Test response" - assert response.message == "Test message" - assert response.thread_id == "thread-123" - assert response.status == "success" - assert response.message_count == 0 - assert response.error is None - assert response.error_type is None - assert response.structured_response is None + assert data["message"] == "Test" + assert data["orchestrationId"] == "orch-456" - def test_init_with_all_fields(self) -> None: - """Test AgentResponse initialization with all fields.""" - structured = {"answer": "42"} - response = AgentResponse( - response=None, - message="What is the answer?", - thread_id="thread-456", - status="success", - message_count=5, - error=None, - error_type=None, - structured_response=structured, + def test_to_dict_excludes_orchestration_id_when_none(self) -> None: + """Test to_dict excludes orchestrationId when not set.""" + request = RunRequest( + message="Test", + thread_id="thread-orch-none", ) + data = request.to_dict() - assert response.response is None - assert response.structured_response == structured - assert response.message_count == 5 + assert "orchestrationId" not in data - def test_to_dict_with_text_response(self) -> None: - """Test to_dict with text response.""" - response = AgentResponse( - response="Text response", message="Message", thread_id="thread-1", status="success", message_count=3 - ) - data = response.to_dict() - - assert data["response"] == "Text response" - assert data["message"] == "Message" - assert data["thread_id"] == "thread-1" - assert data["status"] == "success" - assert data["message_count"] == 3 - assert "structured_response" not in data - assert "error" not in data - assert "error_type" not in data - - def test_to_dict_with_structured_response(self) -> None: - """Test to_dict with structured response.""" - structured = {"answer": 42, "confidence": 0.95} - response = AgentResponse( - response=None, - message="Question", - thread_id="thread-2", - status="success", - structured_response=structured, - ) - data = response.to_dict() - - assert data["structured_response"] == structured - assert "response" not in data - - def test_to_dict_with_error(self) -> None: - """Test to_dict with error.""" - response = AgentResponse( - response=None, - message="Failed message", - thread_id="thread-3", - status="error", - error="Something went wrong", - error_type="ValueError", - ) - data = response.to_dict() - - assert data["status"] == "error" - assert data["error"] == "Something went wrong" - assert data["error_type"] == "ValueError" - - def test_to_dict_prefers_structured_over_text(self) -> None: - """Test to_dict prefers structured_response over response.""" - structured = {"result": "structured"} - response = AgentResponse( - response="Text response", - message="Message", - thread_id="thread-4", - status="success", - structured_response=structured, + def test_from_dict_with_orchestration_id(self) -> None: + """Test from_dict with orchestrationId.""" + data = { + "message": "Test", + "orchestrationId": "orch-789", + "thread_id": "thread-orch-from-dict", + } + request = RunRequest.from_dict(data) + + assert request.message == "Test" + assert request.orchestration_id == "orch-789" + assert request.thread_id == "thread-orch-from-dict" + + def test_round_trip_with_orchestration_id(self) -> None: + """Test round-trip to_dict and from_dict with orchestration_id.""" + original = RunRequest( + message="Test message", + thread_id="thread-123", + role=Role.SYSTEM, + correlation_id="corr-123", + orchestration_id="orch-123", ) - data = response.to_dict() - assert "structured_response" in data - assert data["structured_response"] == structured - # Text response should not be included when structured is present - assert "response" not in data + data = original.to_dict() + restored = RunRequest.from_dict(data) + + assert restored.message == original.message + assert restored.role == original.role + assert restored.correlation_id == original.correlation_id + assert restored.orchestration_id == original.orchestration_id + assert restored.thread_id == original.thread_id class TestModelIntegration: @@ -450,21 +414,6 @@ def test_run_request_with_session_id(self) -> None: assert request.thread_id == str(session_id) assert request.thread_id.startswith("@AgentEntity@") - def test_response_from_run_request(self) -> None: - """Test creating AgentResponse from RunRequest.""" - request = RunRequest(message="What is 2+2?", thread_id="thread-123", role=Role.USER) - - response = AgentResponse( - response="4", - message=request.message, - thread_id=request.thread_id, - status="success", - message_count=1, - ) - - assert response.message == request.message - assert response.thread_id == request.thread_id - if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py index 93201a64e95..0f845d41050 100644 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -6,10 +6,12 @@ from unittest.mock import Mock import pytest -from agent_framework import AgentThread +from agent_framework import AgentRunResponse, AgentThread, ChatMessage +from azure.durable_functions.models.Task import TaskBase, TaskState from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread +from agent_framework_azurefunctions._orchestration import AgentTask def _app_with_registered_agents(*agent_names: str) -> AgentFunctionApp: @@ -21,6 +23,169 @@ def _app_with_registered_agents(*agent_names: str) -> AgentFunctionApp: return app +class _FakeTask(TaskBase): + """Concrete TaskBase for testing AgentTask wiring.""" + + def __init__(self, task_id: int = 1): + super().__init__(task_id, []) + self._set_is_scheduled(False) + self.action_repr = [] + self.state = TaskState.RUNNING + + +def _create_entity_task(task_id: int = 1) -> TaskBase: + """Create a minimal TaskBase instance for AgentTask tests.""" + return _FakeTask(task_id) + + +class TestAgentResponseHelpers: + """Tests for helper utilities that prepare AgentRunResponse values.""" + + @staticmethod + def _create_agent_task() -> AgentTask: + entity_task = _create_entity_task() + return AgentTask(entity_task, None, "correlation-id") + + def test_load_agent_response_from_instance(self) -> None: + task = self._create_agent_task() + response = AgentRunResponse(messages=[ChatMessage(role="assistant", text='{"foo": "bar"}')]) + + loaded = task._load_agent_response(response) + + assert loaded is response + assert loaded.value is None + + def test_load_agent_response_from_serialized(self) -> None: + task = self._create_agent_task() + serialized = AgentRunResponse(messages=[ChatMessage(role="assistant", text="structured")]).to_dict() + serialized["value"] = {"answer": 42} + + loaded = task._load_agent_response(serialized) + + assert loaded is not None + assert loaded.value == {"answer": 42} + loaded_dict = loaded.to_dict() + assert loaded_dict["type"] == "agent_run_response" + + def test_load_agent_response_rejects_none(self) -> None: + task = self._create_agent_task() + + with pytest.raises(ValueError): + task._load_agent_response(None) + + def test_load_agent_response_rejects_unsupported_type(self) -> None: + task = self._create_agent_task() + + with pytest.raises(TypeError, match="Unsupported type"): + task._load_agent_response(["invalid", "list"]) # type: ignore[arg-type] + + def test_try_set_value_success(self) -> None: + """Test try_set_value correctly processes successful task completion.""" + entity_task = _create_entity_task() + task = AgentTask(entity_task, None, "correlation-id") + + # Simulate successful entity task completion + entity_task.state = TaskState.SUCCEEDED + entity_task.result = AgentRunResponse(messages=[ChatMessage(role="assistant", text="Test response")]).to_dict() + + # Clear pending_tasks to simulate that parent has processed the child + task.pending_tasks.clear() + + # Call try_set_value + task.try_set_value(entity_task) + + # Verify task completed successfully with AgentRunResponse + assert task.state == TaskState.SUCCEEDED + assert isinstance(task.result, AgentRunResponse) + assert task.result.text == "Test response" + + def test_try_set_value_failure(self) -> None: + """Test try_set_value correctly handles failed task completion.""" + entity_task = _create_entity_task() + task = AgentTask(entity_task, None, "correlation-id") + + # Simulate failed entity task + entity_task.state = TaskState.FAILED + entity_task.result = Exception("Entity call failed") + + # Call try_set_value + task.try_set_value(entity_task) + + # Verify task failed with the error + assert task.state == TaskState.FAILED + assert isinstance(task.result, Exception) + assert str(task.result) == "Entity call failed" + + def test_try_set_value_with_response_format(self) -> None: + """Test try_set_value parses structured output when response_format is provided.""" + from pydantic import BaseModel + + class TestSchema(BaseModel): + answer: str + + entity_task = _create_entity_task() + task = AgentTask(entity_task, TestSchema, "correlation-id") + + # Simulate successful entity task with JSON response + entity_task.state = TaskState.SUCCEEDED + entity_task.result = AgentRunResponse( + messages=[ChatMessage(role="assistant", text='{"answer": "42"}')] + ).to_dict() + + # Clear pending_tasks to simulate that parent has processed the child + task.pending_tasks.clear() + + # Call try_set_value + task.try_set_value(entity_task) + + # Verify task completed and value was parsed + assert task.state == TaskState.SUCCEEDED + assert isinstance(task.result, AgentRunResponse) + assert isinstance(task.result.value, TestSchema) + assert task.result.value.answer == "42" + + def test_ensure_response_format_parses_value(self) -> None: + """Test _ensure_response_format correctly parses response value.""" + from pydantic import BaseModel + + class SampleSchema(BaseModel): + name: str + + task = self._create_agent_task() + response = AgentRunResponse(messages=[ChatMessage(role="assistant", text='{"name": "test"}')]) + + # Value should be None initially + assert response.value is None + + # Parse the value + task._ensure_response_format(SampleSchema, "test-correlation", response) + + # Value should now be parsed + assert isinstance(response.value, SampleSchema) + assert response.value.name == "test" + + def test_ensure_response_format_skips_if_already_parsed(self) -> None: + """Test _ensure_response_format does not re-parse if value already matches format.""" + from pydantic import BaseModel + + class SampleSchema(BaseModel): + name: str + + task = self._create_agent_task() + existing_value = SampleSchema(name="existing") + response = AgentRunResponse( + messages=[ChatMessage(role="assistant", text='{"name": "new"}')], + value=existing_value, + ) + + # Call _ensure_response_format + task._ensure_response_format(SampleSchema, "test-correlation", response) + + # Value should remain unchanged (not re-parsed) + assert response.value is existing_value + assert response.value.name == "existing" + + class TestDurableAIAgent: """Test suite for DurableAIAgent wrapper.""" @@ -111,22 +276,19 @@ def test_run_creates_entity_call(self) -> None: mock_context.instance_id = "test-instance-001" mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) - # Mock call_entity to return a Task-like object - mock_task = Mock() - mock_task._is_scheduled = False # Task attribute that orchestration checks - - mock_context.call_entity = Mock(return_value=mock_task) + entity_task = _create_entity_task() + mock_context.call_entity = Mock(return_value=entity_task) agent = DurableAIAgent(mock_context, "TestAgent") # Create thread thread = agent.get_new_thread() - # Call run() - it should return the Task directly + # Call run() - returns AgentTask directly task = agent.run(messages="Test message", thread=thread, enable_tool_calls=True) - # Verify run() returns the Task from call_entity - assert task == mock_task + assert isinstance(task, AgentTask) + assert task.children[0] == entity_task # Verify call_entity was called with correct parameters assert mock_context.call_entity.called @@ -140,24 +302,45 @@ def test_run_creates_entity_call(self) -> None: assert request["correlationId"] == "correlation-guid" assert "thread_id" in request assert request["thread_id"] == "thread-guid" + # Verify orchestration ID is set from context.instance_id + assert "orchestrationId" in request + assert request["orchestrationId"] == "test-instance-001" + + def test_run_sets_orchestration_id(self) -> None: + """Test that run() sets the orchestration_id from context.instance_id.""" + mock_context = Mock() + mock_context.instance_id = "my-orchestration-123" + mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) + + entity_task = _create_entity_task() + mock_context.call_entity = Mock(return_value=entity_task) + + agent = DurableAIAgent(mock_context, "TestAgent") + thread = agent.get_new_thread() + + agent.run(messages="Test", thread=thread) + + call_args = mock_context.call_entity.call_args + request = call_args[0][2] + + assert request["orchestrationId"] == "my-orchestration-123" def test_run_without_thread(self) -> None: """Test that run() works without explicit thread (creates unique session key).""" mock_context = Mock() mock_context.instance_id = "test-instance-002" - # Two calls to new_uuid: one for session_key, one for correlationId mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"]) - mock_task = Mock() - mock_task._is_scheduled = False - mock_context.call_entity = Mock(return_value=mock_task) + entity_task = _create_entity_task() + mock_context.call_entity = Mock(return_value=entity_task) agent = DurableAIAgent(mock_context, "TestAgent") # Call without thread task = agent.run(messages="Test message") - assert task == mock_task + assert isinstance(task, AgentTask) + assert task.children[0] == entity_task # Verify the entity ID uses the auto-generated GUID with dafx- prefix call_args = mock_context.call_entity.call_args @@ -172,9 +355,8 @@ def test_run_with_response_format(self) -> None: mock_context = Mock() mock_context.instance_id = "test-instance-003" - mock_task = Mock() - mock_task._is_scheduled = False - mock_context.call_entity = Mock(return_value=mock_task) + entity_task = _create_entity_task() + mock_context.call_entity = Mock(return_value=entity_task) agent = DurableAIAgent(mock_context, "TestAgent") @@ -188,7 +370,8 @@ class SampleSchema(BaseModel): task = agent.run(messages="Test message", thread=thread, response_format=SampleSchema) - assert task == mock_task + assert isinstance(task, AgentTask) + assert task.children[0] == entity_task # Verify schema was passed in the call_entity arguments call_args = mock_context.call_entity.call_args @@ -221,8 +404,8 @@ def test_run_with_chat_message(self) -> None: mock_context = Mock() mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) - mock_task = Mock() - mock_context.call_entity = Mock(return_value=mock_task) + entity_task = _create_entity_task() + mock_context.call_entity = Mock(return_value=entity_task) agent = DurableAIAgent(mock_context, "TestAgent") thread = agent.get_new_thread() @@ -231,7 +414,8 @@ def test_run_with_chat_message(self) -> None: msg = ChatMessage(role="user", text="Hello") task = agent.run(messages=msg, thread=thread) - assert task == mock_task + assert isinstance(task, AgentTask) + assert task.children[0] == entity_task # Verify message was converted to string call_args = mock_context.call_entity.call_args @@ -255,7 +439,7 @@ def test_entity_id_format(self) -> None: mock_context = Mock() mock_context.new_uuid = Mock(return_value="test-guid-789") - mock_context.call_entity = Mock(return_value=Mock()) + mock_context.call_entity = Mock(return_value=_create_entity_task()) agent = DurableAIAgent(mock_context, "WriterAgent") thread = agent.get_new_thread() @@ -314,13 +498,9 @@ def test_sequential_agent_calls_simulation(self) -> None: # Track entity calls entity_calls: list[dict[str, Any]] = [] - def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> Mock: + def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> TaskBase: entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data}) - - # Return a mock Task - mock_task = Mock() - mock_task._is_scheduled = False - return mock_task + return _create_entity_task() mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect) @@ -330,13 +510,13 @@ def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dic # Create thread thread = agent.get_new_thread() - # First call - returns Task + # First call - returns AgentTask task1 = agent.run("Write something", thread=thread) - assert hasattr(task1, "_is_scheduled") + assert isinstance(task1, AgentTask) - # Second call - returns Task + # Second call - returns AgentTask task2 = agent.run("Improve: something", thread=thread) - assert hasattr(task2, "_is_scheduled") + assert isinstance(task2, AgentTask) # Verify both calls used the same entity (same session key) assert len(entity_calls) == 2 @@ -356,11 +536,9 @@ def test_multiple_agents_in_orchestration(self) -> None: entity_calls: list[str] = [] - def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> Mock: + def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> TaskBase: entity_calls.append(str(entity_id)) - mock_task = Mock() - mock_task._is_scheduled = False - return mock_task + return _create_entity_task() mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect) @@ -371,12 +549,12 @@ def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dic writer_thread = writer.get_new_thread() editor_thread = editor.get_new_thread() - # Call both agents - returns Tasks + # Call both agents - returns AgentTasks writer_task = writer.run("Write", thread=writer_thread) editor_task = editor.run("Edit", thread=editor_thread) - assert hasattr(writer_task, "_is_scheduled") - assert hasattr(editor_task, "_is_scheduled") + assert isinstance(writer_task, AgentTask) + assert isinstance(editor_task, AgentTask) # Verify different entity IDs were used assert len(entity_calls) == 2 diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index 5997ec49b5a..afdb6f237f8 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -20,6 +20,37 @@ pip install agent-framework-chatkit --pre This will install `agent-framework-core` and `openai-chatkit` as dependencies. +## Requirements and Limitations + +### Frontend Requirements + +The ChatKit integration requires the OpenAI ChatKit frontend library, which has the following requirements: + +1. **Internet Connectivity Required**: The ChatKit UI is loaded from OpenAI's CDN (`cdn.platform.openai.com`). This library cannot be self-hosted or bundled locally. + +2. **External Network Requests**: The ChatKit frontend makes requests to: + - `cdn.platform.openai.com` - UI library (required) + - `chatgpt.com/ces/v1/projects/oai/settings` - Configuration + - `api-js.mixpanel.com` - Telemetry (metadata only, not user messages) + +3. **Domain Registration for Production**: Production deployments require registering your domain at [platform.openai.com](https://platform.openai.com/settings/organization/security/domain-allowlist) and configuring a domain key. + +### Air-Gapped / Regulated Environments + +**The ChatKit frontend is not suitable for air-gapped or highly-regulated environments** where outbound connections to OpenAI domains are restricted. + +**What IS self-hostable:** + +- The backend components (`chatkit-python`, `agent-framework-chatkit`) are fully open source and have no external dependencies + +**What is NOT self-hostable:** + +- The frontend UI (`chatkit.js`) requires connectivity to OpenAI's CDN + +For environments with network restrictions, consider building a custom frontend that consumes the ChatKit server protocol, or using alternative UI libraries like `ai-sdk`. + +See [openai/chatkit-js#57](https://github.com/openai/chatkit-js/issues/57) for tracking self-hosting feature requests. + ## Example Usage Here's a minimal example showing how to integrate Agent Framework with ChatKit: diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index 4c911f56049..0adf0401568 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -27,6 +27,7 @@ EndOfTurnItem, HiddenContextItem, ImageAttachment, + SDKHiddenContextItem, TaskItem, ThreadItem, UserMessageItem, @@ -180,8 +181,10 @@ async def fetch_data(attachment_id: str) -> bytes: # Subclasses can override this method to provide custom handling return None - def hidden_context_to_input(self, item: HiddenContextItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit HiddenContextItem to Agent Framework ChatMessage(s). + def hidden_context_to_input( + self, item: HiddenContextItem | SDKHiddenContextItem + ) -> ChatMessage | list[ChatMessage] | None: + """Convert a ChatKit HiddenContextItem or SDKHiddenContextItem to Agent Framework ChatMessage(s). This method is called internally by `to_agent_input()`. Override this method to customize how hidden context is converted. @@ -522,6 +525,9 @@ async def _thread_item_to_input_item( case HiddenContextItem(): out = self.hidden_context_to_input(item) or [] return out if isinstance(out, list) else [out] + case SDKHiddenContextItem(): + out = self.hidden_context_to_input(item) or [] + return out if isinstance(out, list) else [out] case _: assert_never(item) diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 1e2e7bdbd8a..c9ec466a0eb 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core", - "openai-chatkit>=1.1.0,<2.0.0", + "openai-chatkit>=1.4.0,<2.0.0", ] [tool.uv] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 9251f040667..39291b9dcb0 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/README.md b/python/packages/core/README.md index 80cf6b40688..4113eca061e 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -213,7 +213,10 @@ if __name__ == "__main__": asyncio.run(main()) ``` -**Note**: Advanced orchestration patterns like GroupChat, Sequential, and Concurrent orchestrations are coming soon. +**Note**: GroupChat, Sequential, and Concurrent orchestrations are available today. See examples in: +- [python/samples/getting_started/workflows/orchestration/](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/workflows/orchestration) +- [group_chat_simple_selector.py](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py) +- [group_chat_prompt_based_manager.py](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/workflows/orchestration/group_chat_prompt_based_manager.py) ## More Examples & Samples @@ -228,4 +231,4 @@ if __name__ == "__main__": - [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python) - [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet) - [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design) -- Learn docs are coming soon. +- [Learn Documentation](https://learn.microsoft.com/en-us/agent-framework/user-guide/workflows/orchestrations/overview) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 591d2554904..1399a4c5f08 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -337,6 +337,7 @@ async def _notify_thread_of_new_messages( thread: AgentThread, input_messages: ChatMessage | Sequence[ChatMessage], response_messages: ChatMessage | Sequence[ChatMessage], + **kwargs: Any, ) -> None: """Notify the thread of new messages. @@ -346,13 +347,14 @@ async def _notify_thread_of_new_messages( thread: The thread to notify of new messages. input_messages: The input messages to notify about. response_messages: The response messages to notify about. + **kwargs: Any extra arguments to pass from the agent run. """ if isinstance(input_messages, ChatMessage) or len(input_messages) > 0: await thread.on_new_messages(input_messages) if isinstance(response_messages, ChatMessage) or len(response_messages) > 0: await thread.on_new_messages(response_messages) if thread.context_provider: - await thread.context_provider.invoked(input_messages, response_messages) + await thread.context_provider.invoked(input_messages, response_messages, **kwargs) @property def display_name(self) -> str: @@ -717,7 +719,7 @@ def __init__( additional_properties=additional_chat_options or {}, # type: ignore ) self._async_exit_stack = AsyncExitStack() - self._update_agent_name() + self._update_agent_name_and_description() async def __aenter__(self) -> "Self": """Enter the async context manager. @@ -753,15 +755,17 @@ async def __aexit__( """ await self._async_exit_stack.aclose() - def _update_agent_name(self) -> None: + def _update_agent_name_and_description(self) -> None: """Update the agent name in the chat client. Checks if the chat client supports agent name updates. The implementation should check if there is already an agent name defined, and if not set it to this value. """ - if hasattr(self.chat_client, "_update_agent_name") and callable(self.chat_client._update_agent_name): # type: ignore[reportAttributeAccessIssue, attr-defined] - self.chat_client._update_agent_name(self.name) # type: ignore[reportAttributeAccessIssue, attr-defined] + if hasattr(self.chat_client, "_update_agent_name_and_description") and callable( + self.chat_client._update_agent_name_and_description + ): # type: ignore[reportAttributeAccessIssue, attr-defined] + self.chat_client._update_agent_name_and_description(self.name, self.description) # type: ignore[reportAttributeAccessIssue, attr-defined] async def run( self, @@ -853,6 +857,7 @@ async def run( await self._async_exit_stack.enter_async_context(mcp_server) final_tools.extend(mcp_server.functions) + merged_additional_options = additional_chat_options or {} co = run_chat_options & ChatOptions( model_id=model_id, conversation_id=thread.service_thread_id, @@ -871,11 +876,15 @@ async def run( tools=final_tools, top_p=top_p, user=user, - **(additional_chat_options or {}), + additional_properties=merged_additional_options, # type: ignore[arg-type] ) # Filter chat_options from kwargs to prevent duplicate keyword argument filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"} - response = await self.chat_client.get_response(messages=thread_messages, chat_options=co, **filtered_kwargs) + response = await self.chat_client.get_response( + messages=thread_messages, + chat_options=co, + **filtered_kwargs, + ) await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id) @@ -964,7 +973,7 @@ async def run_stream( """ input_messages = self._normalize_messages(messages) thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages( - thread=thread, input_messages=input_messages + thread=thread, input_messages=input_messages, **kwargs ) agent_name = self._get_agent_name() # Resolve final tool list (runtime provided tools + local MCP server tools) @@ -986,6 +995,7 @@ async def run_stream( await self._async_exit_stack.enter_async_context(mcp_server) final_tools.extend(mcp_server.functions) + merged_additional_options = additional_chat_options or {} co = run_chat_options & ChatOptions( conversation_id=thread.service_thread_id, allow_multiple_tool_calls=allow_multiple_tool_calls, @@ -1004,14 +1014,16 @@ async def run_stream( tools=final_tools, top_p=top_p, user=user, - **(additional_chat_options or {}), + additional_properties=merged_additional_options, # type: ignore[arg-type] ) # Filter chat_options from kwargs to prevent duplicate keyword argument filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"} response_updates: list[ChatResponseUpdate] = [] async for update in self.chat_client.get_streaming_response( - messages=thread_messages, chat_options=co, **filtered_kwargs + messages=thread_messages, + chat_options=co, + **filtered_kwargs, ): response_updates.append(update) @@ -1031,7 +1043,7 @@ async def run_stream( response = ChatResponse.from_chat_response_updates(response_updates, output_format_type=co.response_format) await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id) - await self._notify_thread_of_new_messages(thread, input_messages, response.messages) + await self._notify_thread_of_new_messages(thread, input_messages, response.messages, **kwargs) @override def get_new_thread( @@ -1226,6 +1238,7 @@ async def _prepare_thread_and_messages( *, thread: AgentThread | None, input_messages: list[ChatMessage] | None = None, + **kwargs: Any, ) -> tuple[AgentThread, ChatOptions, list[ChatMessage]]: """Prepare the thread and messages for agent execution. @@ -1235,6 +1248,7 @@ async def _prepare_thread_and_messages( Keyword Args: thread: The conversation thread. input_messages: Messages to process. + **kwargs: Any extra arguments to pass from the agent run. Returns: A tuple containing: @@ -1255,7 +1269,7 @@ async def _prepare_thread_and_messages( context: Context | None = None if self.context_provider: async with self.context_provider: - context = await self.context_provider.invoking(input_messages or []) + context = await self.context_provider.invoking(input_messages or [], **kwargs) if context: if context.messages: thread_messages.extend(context.messages) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 40c13a2037c..4d91492822e 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -568,10 +568,6 @@ async def get_response( additional_properties=additional_properties, ) - # Validate that store is True when conversation_id is set - if chat_options.conversation_id is not None and chat_options.store is not True: - chat_options.store = True - if chat_options.instructions: system_msg = ChatMessage(role="system", text=chat_options.instructions) prepped_messages = [system_msg, *prepare_messages(messages)] @@ -666,10 +662,6 @@ async def get_streaming_response( additional_properties=additional_properties, ) - # Validate that store is True when conversation_id is set - if chat_options.conversation_id is not None and chat_options.store is not True: - chat_options.store = True - if chat_options.instructions: system_msg = ChatMessage(role="system", text=chat_options.instructions) prepped_messages = [system_msg, *prepare_messages(messages)] diff --git a/python/packages/core/agent_framework/_logging.py b/python/packages/core/agent_framework/_logging.py index 16385be6bcf..012de28bf11 100644 --- a/python/packages/core/agent_framework/_logging.py +++ b/python/packages/core/agent_framework/_logging.py @@ -4,12 +4,15 @@ from .exceptions import AgentFrameworkException -logging.basicConfig( - format="[%(asctime)s - %(pathname)s:%(lineno)d - %(levelname)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", -) +__all__ = ["get_logger", "setup_logging"] -__all__ = ["get_logger"] + +def setup_logging() -> None: + """Setup the logging configuration for the agent framework.""" + logging.basicConfig( + format="[%(asctime)s - %(pathname)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) def get_logger(name: str = "agent_framework") -> logging.Logger: diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 66c96425c86..b4caaea4f5c 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -5,7 +5,7 @@ import re import sys from abc import abstractmethod -from collections.abc import Collection +from collections.abc import Collection, Sequence from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore from datetime import timedelta from functools import partial @@ -22,7 +22,16 @@ from pydantic import BaseModel, Field, create_model from ._tools import AIFunction, HostedMCPSpecificApproval -from ._types import ChatMessage, Contents, DataContent, Role, TextContent, UriContent +from ._types import ( + ChatMessage, + Contents, + DataContent, + FunctionCallContent, + FunctionResultContent, + Role, + TextContent, + UriContent, +) from .exceptions import ToolException, ToolExecutionException if sys.version_info >= (3, 11): @@ -61,7 +70,7 @@ def _mcp_prompt_message_to_chat_message( """Convert a MCP container type to a Agent Framework type.""" return ChatMessage( role=Role(value=mcp_type.role), - contents=[_mcp_type_to_ai_content(mcp_type.content)], + contents=_mcp_type_to_ai_content(mcp_type.content), raw_representation=mcp_type, ) @@ -69,44 +78,138 @@ def _mcp_prompt_message_to_chat_message( def _mcp_call_tool_result_to_ai_contents( mcp_type: types.CallToolResult, ) -> list[Contents]: - """Convert a MCP container type to a Agent Framework type.""" - return [_mcp_type_to_ai_content(item) for item in mcp_type.content] + """Convert a MCP container type to a Agent Framework type. + + This function extracts the complete _meta field from CallToolResult objects + and merges all metadata into the additional_properties field of converted + content items. + + Note: The _meta field from CallToolResult is applied to ALL content items + in the result, as the Agent Framework's content model doesn't have a + result-level metadata container. This ensures metadata is preserved but + means it will be duplicated across multiple content items if present. + + Args: + mcp_type: The MCP CallToolResult object to convert. + + Returns: + A list of Agent Framework content items with metadata merged into + additional_properties. + """ + meta_data = mcp_type.meta + + # Prepare merged metadata once if present + merged_meta_props = None + if meta_data: + merged_meta_props = {} + if hasattr(meta_data, "__dict__"): + merged_meta_props.update(meta_data.__dict__) + elif isinstance(meta_data, dict): + merged_meta_props.update(meta_data) + else: + merged_meta_props["_meta"] = meta_data + + # Convert each content item and merge metadata + result_contents = [] + for item in mcp_type.content: + contents = _mcp_type_to_ai_content(item) + + if merged_meta_props: + for content in contents: + existing_props = getattr(content, "additional_properties", None) or {} + # Merge with content-specific properties, letting content-specific props override + final_props = merged_meta_props.copy() + final_props.update(existing_props) + content.additional_properties = final_props + result_contents.extend(contents) + return result_contents def _mcp_type_to_ai_content( - mcp_type: types.ImageContent | types.TextContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink, -) -> Contents: + mcp_type: types.ImageContent + | types.TextContent + | types.AudioContent + | types.EmbeddedResource + | types.ResourceLink + | types.ToolUseContent + | types.ToolResultContent + | Sequence[ + types.ImageContent + | types.TextContent + | types.AudioContent + | types.EmbeddedResource + | types.ResourceLink + | types.ToolUseContent + | types.ToolResultContent + ], +) -> list[Contents]: """Convert a MCP type to a Agent Framework type.""" - match mcp_type: - case types.TextContent(): - return TextContent(text=mcp_type.text, raw_representation=mcp_type) - case types.ImageContent() | types.AudioContent(): - return DataContent( - uri=mcp_type.data, - media_type=mcp_type.mimeType, - raw_representation=mcp_type, - ) - case types.ResourceLink(): - return UriContent( - uri=str(mcp_type.uri), - media_type=mcp_type.mimeType or "application/json", - raw_representation=mcp_type, - ) - case _: - match mcp_type.resource: - case types.TextResourceContents(): - return TextContent( - text=mcp_type.resource.text, + mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type] + return_types: list[Contents] = [] + for mcp_type in mcp_types: + match mcp_type: + case types.TextContent(): + return_types.append(TextContent(text=mcp_type.text, raw_representation=mcp_type)) + case types.ImageContent() | types.AudioContent(): + return_types.append( + DataContent( + uri=mcp_type.data, + media_type=mcp_type.mimeType, raw_representation=mcp_type, - additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None), ) - case types.BlobResourceContents(): - return DataContent( - uri=mcp_type.resource.blob, - media_type=mcp_type.resource.mimeType, + ) + case types.ResourceLink(): + return_types.append( + UriContent( + uri=str(mcp_type.uri), + media_type=mcp_type.mimeType or "application/json", + raw_representation=mcp_type, + ) + ) + case types.ToolUseContent(): + return_types.append( + FunctionCallContent( + call_id=mcp_type.id, + name=mcp_type.name, + arguments=mcp_type.input, + raw_representation=mcp_type, + ) + ) + case types.ToolResultContent(): + return_types.append( + FunctionResultContent( + call_id=mcp_type.toolUseId, + result=_mcp_type_to_ai_content(mcp_type.content) + if mcp_type.content + else mcp_type.structuredContent, + exception=Exception() if mcp_type.isError else None, raw_representation=mcp_type, - additional_properties=(mcp_type.annotations.model_dump() if mcp_type.annotations else None), ) + ) + case types.EmbeddedResource(): + match mcp_type.resource: + case types.TextResourceContents(): + return_types.append( + TextContent( + text=mcp_type.resource.text, + raw_representation=mcp_type, + additional_properties=( + mcp_type.annotations.model_dump() if mcp_type.annotations else None + ), + ) + ) + case types.BlobResourceContents(): + return_types.append( + DataContent( + uri=mcp_type.resource.blob, + media_type=mcp_type.resource.mimeType, + raw_representation=mcp_type, + additional_properties=( + mcp_type.annotations.model_dump() if mcp_type.annotations else None + ), + ) + ) + return return_types def _ai_content_to_mcp_types( @@ -234,18 +337,30 @@ def resolve_type(prop_details: dict[str, Any]) -> type: python_type = resolve_type(prop_details) description = prop_details.get("description", "") + # Build field kwargs (description, array items schema, etc.) + field_kwargs: dict[str, Any] = {} + if description: + field_kwargs["description"] = description + + # Preserve array items schema if present + if prop_details.get("type") == "array" and "items" in prop_details: + items_schema = prop_details["items"] + if items_schema and items_schema != {}: + field_kwargs["json_schema_extra"] = {"items": items_schema} + # Create field definition for create_model if prop_name in required: - field_definitions[prop_name] = ( - (python_type, Field(description=description)) if description else (python_type, ...) - ) + if field_kwargs: + field_definitions[prop_name] = (python_type, Field(**field_kwargs)) + else: + field_definitions[prop_name] = (python_type, ...) else: default_value = prop_details.get("default", None) - field_definitions[prop_name] = ( - (python_type, Field(default=default_value, description=description)) - if description - else (python_type, default_value) - ) + field_kwargs["default"] = default_value + if field_kwargs and any(k != "default" for k in field_kwargs): + field_definitions[prop_name] = (python_type, Field(**field_kwargs)) + else: + field_definitions[prop_name] = (python_type, default_value) return create_model(f"{tool.name}_input", **field_definitions) diff --git a/python/packages/core/agent_framework/_threads.py b/python/packages/core/agent_framework/_threads.py index f7603a7c3cd..92469a78d57 100644 --- a/python/packages/core/agent_framework/_threads.py +++ b/python/packages/core/agent_framework/_threads.py @@ -140,6 +140,7 @@ def __init__( """ if not messages: self.messages: list[ChatMessage] = [] + return if not isinstance(messages, list): raise TypeError("Messages should be a list") new_messages: list[ChatMessage] = [] diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 12297fe82a9..171db56c461 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1805,6 +1805,8 @@ def _prepare_function_call_results_as_dumpable(content: Contents | Any | list[Co return [_prepare_function_call_results_as_dumpable(item) for item in content] if isinstance(content, dict): return {k: _prepare_function_call_results_as_dumpable(v) for k, v in content.items()} + if isinstance(content, BaseModel): + return content.model_dump() if hasattr(content, "to_dict"): return content.to_dict(exclude={"raw_representation", "additional_properties"}) return content @@ -1973,6 +1975,7 @@ class ChatMessage(SerializationMixin): author_name: The name of the author of the message. message_id: The ID of the chat message. additional_properties: Any additional properties associated with the chat message. + Additional properties are used within Agent Framework, they are not sent to services. raw_representation: The raw representation of the chat message from an underlying implementation. Examples: @@ -2033,6 +2036,7 @@ def __init__( author_name: Optional name of the author of the message. message_id: Optional ID of the chat message. additional_properties: Optional additional properties associated with the chat message. + Additional properties are used within Agent Framework, they are not sent to services. raw_representation: Optional raw representation of the chat message. **kwargs: Additional keyword arguments. """ @@ -2059,6 +2063,7 @@ def __init__( author_name: Optional name of the author of the message. message_id: Optional ID of the chat message. additional_properties: Optional additional properties associated with the chat message. + Additional properties are used within Agent Framework, they are not sent to services. raw_representation: Optional raw representation of the chat message. **kwargs: Additional keyword arguments. """ @@ -2086,6 +2091,7 @@ def __init__( author_name: Optional name of the author of the message. message_id: Optional ID of the chat message. additional_properties: Optional additional properties associated with the chat message. + Additional properties are used within Agent Framework, they are not sent to services. raw_representation: Optional raw representation of the chat message. kwargs: will be combined with additional_properties if provided. """ diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 18dd674a923..04623c87d9d 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -61,19 +61,28 @@ GroupChatDirective, GroupChatStateSnapshot, ManagerDirectiveModel, + ManagerSelectionRequest, + ManagerSelectionResponse, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest from ._magentic import ( - MagenticAgentDeltaEvent, - MagenticAgentMessageEvent, + MAGENTIC_EVENT_TYPE_AGENT_DELTA, + MAGENTIC_EVENT_TYPE_ORCHESTRATOR, + ORCH_MSG_KIND_INSTRUCTION, + ORCH_MSG_KIND_NOTICE, + ORCH_MSG_KIND_TASK_LEDGER, + ORCH_MSG_KIND_USER_TASK, MagenticBuilder, MagenticContext, - MagenticFinalResultEvent, + MagenticHumanInputRequest, + MagenticHumanInterventionDecision, + MagenticHumanInterventionKind, + MagenticHumanInterventionReply, + MagenticHumanInterventionRequest, MagenticManagerBase, - MagenticOrchestratorMessageEvent, - MagenticPlanReviewDecision, - MagenticPlanReviewReply, - MagenticPlanReviewRequest, + MagenticStallInterventionDecision, + MagenticStallInterventionReply, + MagenticStallInterventionRequest, StandardMagenticManager, ) from ._orchestration_state import OrchestrationState @@ -104,6 +113,12 @@ "DEFAULT_MANAGER_INSTRUCTIONS", "DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT", "DEFAULT_MAX_ITERATIONS", + "MAGENTIC_EVENT_TYPE_AGENT_DELTA", + "MAGENTIC_EVENT_TYPE_ORCHESTRATOR", + "ORCH_MSG_KIND_INSTRUCTION", + "ORCH_MSG_KIND_NOTICE", + "ORCH_MSG_KIND_TASK_LEDGER", + "ORCH_MSG_KIND_USER_TASK", "AgentExecutor", "AgentExecutorRequest", "AgentExecutorResponse", @@ -132,17 +147,20 @@ "HandoffUserInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", - "MagenticAgentDeltaEvent", - "MagenticAgentMessageEvent", "MagenticBuilder", "MagenticContext", - "MagenticFinalResultEvent", + "MagenticHumanInputRequest", + "MagenticHumanInterventionDecision", + "MagenticHumanInterventionKind", + "MagenticHumanInterventionReply", + "MagenticHumanInterventionRequest", "MagenticManagerBase", - "MagenticOrchestratorMessageEvent", - "MagenticPlanReviewDecision", - "MagenticPlanReviewReply", - "MagenticPlanReviewRequest", + "MagenticStallInterventionDecision", + "MagenticStallInterventionReply", + "MagenticStallInterventionRequest", "ManagerDirectiveModel", + "ManagerSelectionRequest", + "ManagerSelectionResponse", "Message", "OrchestrationState", "RequestInfoEvent", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi deleted file mode 100644 index c9f8c6cb620..00000000000 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from ._agent import WorkflowAgent -from ._agent_executor import ( - AgentExecutor, - AgentExecutorRequest, - AgentExecutorResponse, -) -from ._checkpoint import ( - CheckpointStorage, - FileCheckpointStorage, - InMemoryCheckpointStorage, - WorkflowCheckpoint, -) -from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary -from ._concurrent import ConcurrentBuilder -from ._const import DEFAULT_MAX_ITERATIONS -from ._edge import ( - Case, - Default, - Edge, - FanInEdgeGroup, - FanOutEdgeGroup, - SingleEdgeGroup, - SwitchCaseEdgeGroup, - SwitchCaseEdgeGroupCase, - SwitchCaseEdgeGroupDefault, -) -from ._edge_runner import create_edge_runner -from ._events import ( - AgentRunEvent, - AgentRunUpdateEvent, - ExecutorCompletedEvent, - ExecutorEvent, - ExecutorFailedEvent, - ExecutorInvokedEvent, - RequestInfoEvent, - SuperStepCompletedEvent, - SuperStepStartedEvent, - WorkflowErrorDetails, - WorkflowEvent, - WorkflowEventSource, - WorkflowFailedEvent, - WorkflowLifecycleEvent, - WorkflowOutputEvent, - WorkflowRunState, - WorkflowStartedEvent, - WorkflowStatusEvent, -) -from ._executor import ( - Executor, - handler, -) -from ._function_executor import FunctionExecutor, executor -from ._group_chat import ( - DEFAULT_MANAGER_INSTRUCTIONS, - DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT, - GroupChatBuilder, - GroupChatDirective, - GroupChatStateSnapshot, -) -from ._handoff import HandoffBuilder, HandoffUserInputRequest -from ._magentic import ( - MagenticAgentDeltaEvent, - MagenticAgentMessageEvent, - MagenticBuilder, - MagenticContext, - MagenticFinalResultEvent, - MagenticManagerBase, - MagenticOrchestratorMessageEvent, - MagenticPlanReviewDecision, - MagenticPlanReviewReply, - MagenticPlanReviewRequest, - StandardMagenticManager, -) -from ._orchestration_state import OrchestrationState -from ._request_info_mixin import response_handler -from ._runner import Runner -from ._runner_context import ( - InProcRunnerContext, - Message, - RunnerContext, -) -from ._sequential import SequentialBuilder -from ._shared_state import SharedState -from ._validation import ( - EdgeDuplicationError, - GraphConnectivityError, - TypeCompatibilityError, - ValidationTypeEnum, - WorkflowValidationError, - validate_workflow_graph, -) -from ._viz import WorkflowViz -from ._workflow import Workflow, WorkflowRunResult -from ._workflow_builder import WorkflowBuilder -from ._workflow_context import WorkflowContext -from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor - -__all__ = [ - "DEFAULT_MANAGER_INSTRUCTIONS", - "DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT", - "DEFAULT_MAX_ITERATIONS", - "AgentExecutor", - "AgentExecutorRequest", - "AgentExecutorResponse", - "AgentRunEvent", - "AgentRunUpdateEvent", - "Case", - "CheckpointStorage", - "ConcurrentBuilder", - "Default", - "Edge", - "EdgeDuplicationError", - "Executor", - "ExecutorCompletedEvent", - "ExecutorEvent", - "ExecutorFailedEvent", - "ExecutorInvokedEvent", - "FanInEdgeGroup", - "FanOutEdgeGroup", - "FileCheckpointStorage", - "FunctionExecutor", - "GraphConnectivityError", - "GroupChatBuilder", - "GroupChatDirective", - "GroupChatStateSnapshot", - "HandoffBuilder", - "HandoffUserInputRequest", - "InMemoryCheckpointStorage", - "InProcRunnerContext", - "MagenticAgentDeltaEvent", - "MagenticAgentMessageEvent", - "MagenticBuilder", - "MagenticContext", - "MagenticFinalResultEvent", - "MagenticManagerBase", - "MagenticOrchestratorMessageEvent", - "MagenticPlanReviewDecision", - "MagenticPlanReviewReply", - "MagenticPlanReviewRequest", - "Message", - "OrchestrationState", - "RequestInfoEvent", - "Runner", - "RunnerContext", - "SequentialBuilder", - "SharedState", - "SingleEdgeGroup", - "StandardMagenticManager", - "SubWorkflowRequestMessage", - "SubWorkflowResponseMessage", - "SuperStepCompletedEvent", - "SuperStepStartedEvent", - "SwitchCaseEdgeGroup", - "SwitchCaseEdgeGroupCase", - "SwitchCaseEdgeGroupDefault", - "TypeCompatibilityError", - "ValidationTypeEnum", - "Workflow", - "WorkflowAgent", - "WorkflowBuilder", - "WorkflowCheckpoint", - "WorkflowCheckpointSummary", - "WorkflowContext", - "WorkflowErrorDetails", - "WorkflowEvent", - "WorkflowEventSource", - "WorkflowExecutor", - "WorkflowFailedEvent", - "WorkflowLifecycleEvent", - "WorkflowOutputEvent", - "WorkflowRunResult", - "WorkflowRunState", - "WorkflowStartedEvent", - "WorkflowStatusEvent", - "WorkflowValidationError", - "WorkflowViz", - "create_edge_runner", - "executor", - "get_checkpoint_summary", - "handler", - "response_handler", - "validate_workflow_graph", -] diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index f2a0ea9d75b..81fe1f3b738 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -5,7 +5,7 @@ import uuid from collections.abc import AsyncIterable from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast from agent_framework import ( @@ -236,14 +236,15 @@ def _convert_workflow_event_to_agent_update( ) -> AgentRunResponseUpdate | None: """Convert a workflow event to an AgentRunResponseUpdate. - Only AgentRunUpdateEvent and RequestInfoEvent are processed and the rest - are not relevant. Returns None if the event is not relevant. + Only AgentRunUpdateEvent and RequestInfoEvent are processed. + Other workflow events are ignored as they are workflow-internal and should + have corresponding AgentRunUpdateEvent emissions if relevant to agent consumers. """ match event: case AgentRunUpdateEvent(data=update): # Direct pass-through of update in an agent streaming event if update: - return cast(AgentRunResponseUpdate, update) + return update return None case RequestInfoEvent(request_id=request_id): @@ -268,12 +269,11 @@ def _convert_workflow_event_to_agent_update( author_name=self.name, response_id=response_id, message_id=str(uuid.uuid4()), - created_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), ) case _: - # Ignore non-agent workflow events + # Ignore workflow-internal events pass - # We only care about the above two events and discard the rest. return None def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]: diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index b681544876e..57c600519de 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -367,6 +367,8 @@ def __repr__(self) -> str: # pragma: no cover - representation only class AgentRunUpdateEvent(ExecutorEvent): """Event triggered when an agent is streaming messages.""" + data: AgentRunResponseUpdate | None + def __init__(self, executor_id: str, data: AgentRunResponseUpdate | None = None): """Initialize the agent streaming event.""" super().__init__(executor_id, data) @@ -379,6 +381,8 @@ def __repr__(self) -> str: class AgentRunEvent(ExecutorEvent): """Event triggered when an agent run is completed.""" + data: AgentRunResponse | None + def __init__(self, executor_id: str, data: AgentRunResponse | None = None): """Initialize the agent run event.""" super().__init__(executor_id, data) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 80df16592ba..3624a7c267d 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -264,7 +264,7 @@ async def execute( # Invoke the handler with the message and context with _framework_event_origin(): - invoke_event = ExecutorInvokedEvent(self.id) + invoke_event = ExecutorInvokedEvent(self.id, message) await context.add_event(invoke_event) try: await handler(message, context) @@ -275,7 +275,9 @@ async def execute( await context.add_event(failure_event) raise with _framework_event_origin(): - completed_event = ExecutorCompletedEvent(self.id) + # Include sent messages as the completion data + sent_messages = context.get_sent_messages() + completed_event = ExecutorCompletedEvent(self.id, sent_messages if sent_messages else None) await context.add_event(completed_event) def _create_context_for_handler( diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 84859a4f0c1..78ddb5c2eb6 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -24,13 +24,12 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from types import MappingProxyType -from typing import Any, TypeAlias +from typing import Any, TypeAlias, cast from uuid import uuid4 from pydantic import BaseModel, Field -from .._agents import AgentProtocol -from .._clients import ChatClientProtocol +from .._agents import AgentProtocol, ChatAgent from .._types import ChatMessage, Role from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator @@ -87,6 +86,75 @@ class GroupChatDirective: final_message: ChatMessage | None = None +@dataclass +class ManagerSelectionRequest: + """Request sent to manager agent for next speaker selection. + + This dataclass packages the full conversation state and task context + for the manager agent to analyze and make a speaker selection decision. + + Attributes: + task: Original user task message + participants: Mapping of participant names to their descriptions + conversation: Full conversation history including all messages + round_index: Number of manager selection rounds completed so far + metadata: Optional metadata for extensibility + """ + + task: ChatMessage + participants: dict[str, str] # type: ignore + conversation: list[ChatMessage] # type: ignore + round_index: int + metadata: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "task": self.task.to_dict(), + "participants": dict(self.participants), + "conversation": [msg.to_dict() for msg in self.conversation], + "round_index": self.round_index, + "metadata": self.metadata, + } + + +class ManagerSelectionResponse(BaseModel): + """Response from manager agent with speaker selection decision. + + The manager agent must produce this structure (or compatible dict/JSON) + to communicate its decision back to the orchestrator. + + Attributes: + selected_participant: Name of participant to speak next (None = finish conversation) + instruction: Optional instruction to provide to the selected participant + finish: Whether the conversation should be completed + final_message: Optional final message string when finishing conversation (will be converted to ChatMessage) + """ + + model_config = {"extra": "forbid"} + + selected_participant: str | None = None + instruction: str | None = None + finish: bool = False + final_message: str | None = Field(default=None, description="Optional text content for final message") + + @staticmethod + def from_dict(data: dict[str, Any]) -> "ManagerSelectionResponse": + """Create from dictionary representation.""" + return ManagerSelectionResponse( + selected_participant=data.get("selected_participant"), + instruction=data.get("instruction"), + finish=data.get("finish", False), + final_message=data.get("final_message"), + ) + + def get_final_message_as_chat_message(self) -> ChatMessage | None: + """Convert final_message string to ChatMessage if present.""" + if self.final_message: + return ChatMessage(role=Role.ASSISTANT, text=self.final_message) + return None + + # endregion @@ -112,17 +180,23 @@ class _GroupChatConfig: """Internal: Configuration passed to factories during workflow assembly. Attributes: - manager: Manager instance responsible for orchestration decisions (None when custom factory handles it) + manager: Manager callable for orchestration decisions (used by set_select_speakers_func) + manager_participant: Manager agent/executor instance (used by set_manager) manager_name: Display name for the manager in conversation history participants: Mapping of participant names to their specifications max_rounds: Optional limit on manager selection rounds to prevent infinite loops + termination_condition: Optional callable that halts the conversation when it returns True orchestrator: Orchestrator executor instance (populated during build) + participant_aliases: Mapping of aliases to executor IDs + participant_executors: Mapping of participant names to their executor instances """ manager: _GroupChatManagerFn | None + manager_participant: AgentProtocol | Executor | None manager_name: str participants: Mapping[str, GroupChatParticipantSpec] max_rounds: int | None = None + termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None orchestrator: Executor | None = None participant_aliases: dict[str, str] = field(default_factory=dict) # type: ignore[type-arg] participant_executors: dict[str, Executor] = field(default_factory=dict) # type: ignore[type-arg] @@ -220,6 +294,7 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator): participants: Mapping of participant names to descriptions (for manager context) manager_name: Display name for manager in conversation history max_rounds: Optional limit on manager selection rounds (None = unlimited) + termination_condition: Optional callable that halts the conversation when it returns True executor_id: Optional custom ID for observability (auto-generated if not provided) """ @@ -230,6 +305,7 @@ def __init__( participants: Mapping[str, str], manager_name: str, max_rounds: int | None = None, + termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None, executor_id: str | None = None, ) -> None: super().__init__(executor_id or f"groupchat_orchestrator_{uuid4().hex[:8]}") @@ -237,9 +313,11 @@ def __init__( self._participants = dict(participants) self._manager_name = manager_name self._max_rounds = max_rounds + self._termination_condition = termination_condition self._history: list[_GroupChatTurn] = [] self._task_message: ChatMessage | None = None self._pending_agent: str | None = None + self._pending_finalization: bool = False # Stashes the initial conversation list until _handle_task_message normalizes it into _conversation. self._pending_initial_conversation: list[ChatMessage] | None = None @@ -317,10 +395,75 @@ def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None: for turn in metadata["history"] ] + async def _complete_on_termination( + self, + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], + ) -> bool: + """Finish the conversation early when the termination condition is met.""" + if not await self._check_termination(): + return False + + if self._is_manager_agent(): + if self._pending_finalization: + return True + + self._pending_finalization = True + termination_prompt = ChatMessage( + role=Role.SYSTEM, + text="Termination condition met. Provide a final manager summary and finish the conversation.", + ) + manager_conversation = [ + self._build_manager_context_message(), + termination_prompt, + *list(self._conversation), + ] + self._pending_agent = self._manager_name + await self._route_to_participant( + participant_name=self._manager_name, + conversation=manager_conversation, + ctx=ctx, + instruction="", + task=self._task_message, + metadata={"termination_condition": True}, + ) + return True + + final_message: ChatMessage | None = None + if self._manager is not None and not self._is_manager_agent(): + try: + directive = await self._manager(self._build_state()) + except Exception: + logger.warning("Manager finalization failed during termination; using default termination message.") + else: + if directive.final_message is not None: + final_message = ensure_author(directive.final_message, self._manager_name) + elif directive.finish: + final_message = ensure_author( + self._create_completion_message( + text="Conversation completed.", + reason="termination_condition_manager_finish", + ), + self._manager_name, + ) + + if final_message is None: + final_message = ensure_author( + self._create_completion_message( + text="Conversation halted after termination condition was met.", + reason="termination_condition", + ), + self._manager_name, + ) + self._conversation.append(final_message) + self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message)) + self._pending_agent = None + await ctx.yield_output(list(self._conversation)) + return True + async def _apply_directive( self, directive: GroupChatDirective, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Execute a manager directive by either finishing the workflow or routing to a participant. @@ -366,7 +509,7 @@ async def _apply_directive( self._conversation.extend((final_message,)) self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message)) self._pending_agent = None - await ctx.yield_output(final_message) + await ctx.yield_output(list(self._conversation)) return agent_name = directive.agent_name @@ -386,6 +529,9 @@ async def _apply_directive( self._conversation.extend((manager_message,)) self._history.append(_GroupChatTurn(self._manager_name, "manager", manager_message)) + if await self._complete_on_termination(ctx): + return + self._pending_agent = agent_name self._increment_round() @@ -415,7 +561,7 @@ async def _ingest_participant_message( self, participant_name: str, message: ChatMessage, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Common response ingestion logic shared by agent and custom participants.""" if participant_name not in self._participants: @@ -426,17 +572,213 @@ async def _ingest_participant_message( self._history.append(_GroupChatTurn(participant_name, "agent", message)) self._pending_agent = None + if await self._complete_on_termination(ctx): + return + if self._check_round_limit(): - await ctx.yield_output( - self._create_completion_message( - text="Conversation halted after reaching manager round limit.", - reason="max_rounds reached after response", + final_message = self._create_completion_message( + text="Conversation halted after reaching manager round limit.", + reason="max_rounds reached after response", + ) + self._conversation.extend((final_message,)) + self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message)) + await ctx.yield_output(list(self._conversation)) + return + + # Query manager for next speaker selection + if self._is_manager_agent(): + # Agent-based manager: route request through workflow graph + # Prepend system message with participant context + manager_conversation = [self._build_manager_context_message(), *list(self._conversation)] + await self._route_to_participant( + participant_name=self._manager_name, + conversation=manager_conversation, + ctx=ctx, + instruction="", + task=self._task_message, + metadata=None, + ) + else: + # Callable manager: invoke directly + directive = await self._manager(self._build_state()) + await self._apply_directive(directive, ctx) + + def _is_manager_agent(self) -> bool: + """Check if orchestrator is using an agent-based manager (vs callable manager).""" + return self._registry.is_participant_registered(self._manager_name) + + def _build_manager_context_message(self) -> ChatMessage: + """Build system message with participant context for manager agent. + + This message is prepended to the conversation when querying the manager + to provide up-to-date participant information for selection decisions. + + Returns: + System message with participant names and descriptions + """ + participant_list = "\n".join(f"- {name}: {desc}" for name, desc in self._participants.items()) + context_text = ( + "Available participants:\n" + f"{participant_list}\n\n" + "IMPORTANT: Choose only from these exact participant names (case-sensitive)." + ) + return ChatMessage(role=Role.SYSTEM, text=context_text) + + def _parse_manager_selection(self, response: AgentExecutorResponse) -> ManagerSelectionResponse: + """Extract manager selection decision from agent response. + + Attempts to parse structured output from the manager agent using multiple strategies: + 1. response.value (structured output from response_format) + 2. JSON parsing from message text + 3. Fallback error handling + + Args: + response: AgentExecutor response from manager agent + + Returns: + Parsed ManagerSelectionResponse with speaker selection + + Raises: + RuntimeError: If manager response cannot be parsed into valid selection + """ + import json + + # Strategy 1: agent_run_response.value (structured output) + agent_value = response.agent_run_response.value + if agent_value is not None: + if isinstance(agent_value, ManagerSelectionResponse): + return agent_value + if isinstance(agent_value, dict): + return ManagerSelectionResponse.from_dict(cast(dict[str, Any], agent_value)) + if isinstance(agent_value, str): + try: + data = json.loads(agent_value) + return ManagerSelectionResponse.from_dict(data) + except (json.JSONDecodeError, TypeError, KeyError) as e: + raise RuntimeError(f"Manager response.value contains invalid JSON: {e}") from e + + # Strategy 2: Parse from message text + messages = response.agent_run_response.messages or [] + if messages: + last_msg = messages[-1] + text = last_msg.text or "" + try: + return ManagerSelectionResponse.model_validate_json(text) + except (json.JSONDecodeError, TypeError, KeyError): + pass + + # Fallback: Cannot parse manager decision + raise RuntimeError( + "Manager response did not contain valid selection data. " + "Ensure manager agent uses response_format=ManagerSelectionResponse " + "or returns compatible JSON structure." + ) + + async def _handle_manager_response( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], + ) -> None: + """Process manager agent's speaker selection decision. + + Parses the manager's response and either finishes the conversation or routes + to the selected participant. This method implements the core orchestration + logic for agent-based managers. + + Args: + response: AgentExecutor response from manager agent + ctx: Workflow context for routing and output + + Behavior: + - Parses manager selection from response + - If finish=True: yields final message and completes workflow + - If participant selected: routes request to that participant + - Validates selected participant exists + - Enforces round limits if configured + + Raises: + ValueError: If manager selects invalid/unknown participant + RuntimeError: If manager response cannot be parsed + """ + selection = self._parse_manager_selection(response) + + if self._pending_finalization: + self._pending_finalization = False + final_message_obj = selection.get_final_message_as_chat_message() + if final_message_obj is None: + final_message_obj = self._create_completion_message( + text="Conversation halted after termination condition was met.", + reason="termination_condition_manager", + ) + final_message_obj = ensure_author(final_message_obj, self._manager_name) + + self._conversation.append(final_message_obj) + self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message_obj)) + self._pending_agent = None + await ctx.yield_output(list(self._conversation)) + return + + if selection.finish: + # Manager decided to complete conversation + final_message_obj = selection.get_final_message_as_chat_message() + if final_message_obj is None: + final_message_obj = self._create_completion_message( + text="Conversation completed.", + reason="manager_finish", ) + final_message_obj = ensure_author(final_message_obj, self._manager_name) + + self._conversation.append(final_message_obj) + self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message_obj)) + self._pending_agent = None + await ctx.yield_output(list(self._conversation)) + return + + # Manager selected next participant + selected = selection.selected_participant + if not selected: + raise ValueError("Manager selection missing selected_participant when finish=False.") + if selected not in self._participants: + raise ValueError(f"Manager selected unknown participant: '{selected}'") + + # Route to selected participant + instruction = selection.instruction or "" + conversation = list(self._conversation) + if instruction: + manager_message = ensure_author( + self._create_completion_message(text=instruction, reason="manager_instruction"), + self._manager_name, ) + conversation.append(manager_message) + self._conversation.append(manager_message) + self._history.append(_GroupChatTurn(self._manager_name, "manager", manager_message)) + + if await self._complete_on_termination(ctx): return - directive = await self._manager(self._build_state()) - await self._apply_directive(directive, ctx) + self._pending_agent = selected + self._increment_round() + + await self._route_to_participant( + participant_name=selected, + conversation=conversation, + ctx=ctx, + instruction=instruction, + task=self._task_message, + metadata=None, + ) + + if self._check_round_limit(): + await self._apply_directive( + GroupChatDirective( + finish=True, + final_message=self._create_completion_message( + text="Conversation halted after reaching manager round limit.", + reason="max_rounds reached after manager selection", + ), + ), + ctx, + ) @staticmethod def _extract_agent_message(response: AgentExecutorResponse, participant_name: str) -> ChatMessage: @@ -469,7 +811,7 @@ def _extract_agent_message(response: AgentExecutorResponse, participant_name: st async def _handle_task_message( self, task_message: ChatMessage, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Initialize orchestrator state and start the manager-directed conversation loop. @@ -519,14 +861,33 @@ async def _handle_task_message( self._history = [_GroupChatTurn("user", "user", task_message)] self._pending_agent = None self._round_index = 0 - directive = await self._manager(self._build_state()) - await self._apply_directive(directive, ctx) + + if await self._complete_on_termination(ctx): + return + + # Query manager for first speaker selection + if self._is_manager_agent(): + # Agent-based manager: route request through workflow graph + # Prepend system message with participant context + manager_conversation = [self._build_manager_context_message(), *list(self._conversation)] + await self._route_to_participant( + participant_name=self._manager_name, + conversation=manager_conversation, + ctx=ctx, + instruction="", + task=self._task_message, + metadata=None, + ) + else: + # Callable manager: invoke directly + directive = await self._manager(self._build_state()) + await self._apply_directive(directive, ctx) @handler async def handle_str( self, task: str, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Handler for string input as workflow entry point. @@ -545,7 +906,7 @@ async def handle_str( async def handle_chat_message( self, task_message: ChatMessage, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Handler for ChatMessage input as workflow entry point. @@ -564,7 +925,7 @@ async def handle_chat_message( async def handle_conversation( self, conversation: list[ChatMessage], - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Handler for conversation history as workflow entry point. @@ -602,7 +963,7 @@ async def handle_conversation( async def handle_agent_response( self, response: _GroupChatResponseMessage, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: """Handle responses from custom participant executors.""" await self._ingest_participant_message(response.agent_name, response.message, ctx) @@ -611,9 +972,14 @@ async def handle_agent_response( async def handle_agent_executor_response( self, response: AgentExecutorResponse, - ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]], ) -> None: - """Handle direct AgentExecutor responses.""" + """Handle responses from both manager agent and regular participants. + + Routes responses based on whether they come from the manager or a participant: + - Manager responses: parsed for speaker selection decisions + - Participant responses: ingested as conversation messages + """ participant_name = self._registry.get_participant_name(response.executor_id) if participant_name is None: logger.debug( @@ -621,8 +987,14 @@ async def handle_agent_executor_response( response.executor_id, ) return - message = self._extract_agent_message(response, participant_name) - await self._ingest_participant_message(participant_name, message, ctx) + + # Check if response is from manager agent + if participant_name == self._manager_name and self._is_manager_agent(): + await self._handle_manager_response(response, ctx) + else: + # Regular participant response + message = self._extract_agent_message(response, participant_name) + await self._ingest_participant_message(participant_name, message, ctx) def _default_orchestrator_factory(wiring: _GroupChatConfig) -> Executor: @@ -640,8 +1012,9 @@ def _default_orchestrator_factory(wiring: _GroupChatConfig) -> Executor: Behavior: - Extracts participant names and descriptions for manager context - - Forwards manager instance, manager name, and max_rounds settings + - Forwards manager instance, manager name, max_rounds, and termination_condition settings - Allows orchestrator to auto-generate its executor ID + - Supports both callable managers (set_select_speakers_func) and agent-based managers (set_manager) Why descriptions are extracted: The manager needs participant descriptions (not full specs) to make informed @@ -649,16 +1022,30 @@ def _default_orchestrator_factory(wiring: _GroupChatConfig) -> Executor: since routing is handled by the workflow graph. Raises: - RuntimeError: If manager is None (should not happen when using default factory) + RuntimeError: If neither manager nor manager_participant is configured """ - if wiring.manager is None: - raise RuntimeError("Default orchestrator factory requires a manager to be set") + if wiring.manager is None and wiring.manager_participant is None: + raise RuntimeError( + "Default orchestrator factory requires a manager to be configured. " + "Call set_manager(...) or set_select_speakers_func(...) before build()." + ) + + manager_callable = wiring.manager + if manager_callable is None: + # Keep orchestrator signature satisfied; agent managers are routed via the workflow graph + async def _agent_manager_placeholder(_: GroupChatStateSnapshot) -> GroupChatDirective: # noqa: RUF029 + raise RuntimeError( + "Manager callable invoked unexpectedly. Agent-based managers should route through the workflow graph." + ) + + manager_callable = _agent_manager_placeholder return GroupChatOrchestratorExecutor( - manager=wiring.manager, + manager=manager_callable, participants={name: spec.description for name, spec in wiring.participants.items()}, manager_name=wiring.manager_name, max_rounds=wiring.max_rounds, + termination_condition=wiring.termination_condition, ) @@ -684,8 +1071,41 @@ def assemble_group_chat_workflow( wiring.orchestrator = orchestrator workflow_builder = builder or WorkflowBuilder() - workflow_builder = workflow_builder.set_start_executor(orchestrator) + start_executor = getattr(workflow_builder, "_start_executor", None) + if start_executor is None: + workflow_builder = workflow_builder.set_start_executor(orchestrator) + + # Wire manager as participant if agent-based manager is configured + if wiring.manager_participant is not None: + manager_spec = GroupChatParticipantSpec( + name=wiring.manager_name, + participant=wiring.manager_participant, + description="Coordination manager", + ) + manager_pipeline = list(participant_factory(manager_spec, wiring)) + if not manager_pipeline: + raise ValueError("Participant factory returned empty pipeline for manager.") + manager_entry = manager_pipeline[0] + manager_exit = manager_pipeline[-1] + + # Register manager with orchestrator + register_entry = getattr(orchestrator, "register_participant_entry", None) + if callable(register_entry): + register_entry( + wiring.manager_name, + entry_id=manager_entry.id, + is_agent=not isinstance(wiring.manager_participant, Executor), + ) + + # Wire manager edges: Orchestrator ↔ Manager + workflow_builder = workflow_builder.add_edge(orchestrator, manager_entry) + for upstream, downstream in itertools.pairwise(manager_pipeline): + workflow_builder = workflow_builder.add_edge(upstream, downstream) + if manager_exit is not orchestrator: + workflow_builder = workflow_builder.add_edge(manager_exit, orchestrator) + + # Wire regular participants for name, spec in wiring.participants.items(): pipeline = list(participant_factory(spec, wiring)) if not pipeline: @@ -733,12 +1153,14 @@ class GroupChatBuilder: r"""High-level builder for manager-directed group chat workflows with dynamic orchestration. GroupChat coordinates multi-agent conversations using a manager that selects which participant - speaks next. The manager can be a simple Python function (select_speakers) or an LLM-based - selector (set_prompt_based_manager). These two approaches are mutually exclusive. + speaks next. The manager can be a simple Python function (:py:meth:`GroupChatBuilder.set_select_speakers_func`) + or an agent-based selector via :py:meth:`GroupChatBuilder.set_manager`. These two approaches are + mutually exclusive. **Core Workflow:** 1. Define participants: list of agents (uses their .name) or dict mapping names to agents - 2. Configure speaker selection: select_speakers() OR set_prompt_based_manager() (not both) + 2. Configure speaker selection: :py:meth:`GroupChatBuilder.set_select_speakers_func` OR + :py:meth:`GroupChatBuilder.set_manager` (not both) 3. Optional: set round limits, checkpointing, termination conditions 4. Build and run the workflow @@ -748,6 +1170,9 @@ class GroupChatBuilder: .. code-block:: python + from agent_framework import GroupChatBuilder, GroupChatStateSnapshot + + def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: # state contains: task, participants, conversation, history, round_index if state["round_index"] >= 5: @@ -760,7 +1185,7 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: workflow = ( GroupChatBuilder() - .select_speakers(select_next_speaker) + .set_select_speakers_func(select_next_speaker) .participants([researcher_agent, writer_agent]) # Uses agent.name .build() ) @@ -769,11 +1194,20 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: .. code-block:: python + from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient + manager_agent = AzureOpenAIChatClient().create_agent( + instructions="Coordinate the conversation and pick the next speaker.", + name="Coordinator", + temperature=0.3, + seed=42, + max_tokens=500, + ) + workflow = ( GroupChatBuilder() - .set_prompt_based_manager(chat_client=AzureOpenAIChatClient(), display_name="Coordinator") + .set_manager(manager_agent, display_name="Coordinator") .participants([researcher, writer]) # Or use dict: researcher=r, writer=w .with_max_rounds(10) .build() @@ -782,24 +1216,24 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: **Participant Specification:** Two ways to specify participants: - - List form: ``[agent1, agent2]`` - uses ``agent.name`` attribute for participant names - - Dict form: ``{name1: agent1, name2: agent2}`` - explicit name control - - Keyword form: ``participants(name1=agent1, name2=agent2)`` - explicit name control + - List form: `[agent1, agent2]` - uses `agent.name` attribute for participant names + - Dict form: `{name1: agent1, name2: agent2}` - explicit name control + - Keyword form: `participants(name1=agent1, name2=agent2)` - explicit name control **State Snapshot Structure:** - The GroupChatStateSnapshot passed to select_speakers contains: - - ``task``: ChatMessage - Original user task - - ``participants``: dict[str, str] - Mapping of participant names to descriptions - - ``conversation``: tuple[ChatMessage, ...] - Full conversation history - - ``history``: tuple[GroupChatTurn, ...] - Turn-by-turn record with speaker attribution - - ``round_index``: int - Number of manager selection rounds so far - - ``pending_agent``: str | None - Name of agent currently processing (if any) + The GroupChatStateSnapshot passed to set_select_speakers_func contains: + - `task`: ChatMessage - Original user task + - `participants`: dict[str, str] - Mapping of participant names to descriptions + - `conversation`: tuple[ChatMessage, ...] - Full conversation history + - `history`: tuple[GroupChatTurn, ...] - Turn-by-turn record with speaker attribution + - `round_index`: int - Number of manager selection rounds so far + - `pending_agent`: str | None - Name of agent currently processing (if any) **Important Constraints:** - - Cannot combine select_speakers() and set_prompt_based_manager() - choose one + - Cannot combine :py:meth:`GroupChatBuilder.set_select_speakers_func` and :py:meth:`GroupChatBuilder.set_manager` - Participant names must be unique - - When using list form, agents must have a non-empty ``name`` attribute + - When using list form, agents must have a non-empty `name` attribute """ def __init__( @@ -820,9 +1254,11 @@ def __init__( self._participants: dict[str, AgentProtocol | Executor] = {} self._participant_metadata: dict[str, Any] | None = None self._manager: _GroupChatManagerFn | None = None + self._manager_participant: AgentProtocol | Executor | None = None self._manager_name: str = "manager" self._checkpoint_storage: CheckpointStorage | None = None self._max_rounds: int | None = None + self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None self._interceptors: list[_InterceptorSpec] = [] self._orchestrator_factory = group_chat_orchestrator(_orchestrator_factory) self._participant_factory = _participant_factory or _default_participant_factory @@ -832,68 +1268,107 @@ def _set_manager_function( manager: _GroupChatManagerFn, display_name: str | None, ) -> "GroupChatBuilder": - if self._manager is not None: + if self._manager is not None or self._manager_participant is not None: raise ValueError( "GroupChatBuilder already has a manager configured. " - "Call select_speakers(...) or set_prompt_based_manager(...) at most once." + "Call set_select_speakers_func(...) or set_manager(...) at most once." ) resolved_name = display_name or getattr(manager, "name", None) or "manager" self._manager = manager self._manager_name = resolved_name return self - def set_prompt_based_manager( + def set_manager( self, - chat_client: ChatClientProtocol, + manager: AgentProtocol | Executor, *, - instructions: str | None = None, display_name: str | None = None, ) -> "GroupChatBuilder": - r"""Configure the default prompt-based manager driven by an LLM chat client. + """Configure the manager/coordinator agent for group chat orchestration. + + The manager coordinates participants by selecting who speaks next based on + conversation state and task requirements. The manager is a full workflow + participant with access to all agent infrastructure (tools, context, observability). - The manager coordinates participants by making selection decisions based on the conversation - state, task, and participant descriptions. It uses structured output (ManagerDirectiveModel) - to ensure reliable parsing of decisions. + The manager agent must produce structured output compatible with ManagerSelectionResponse + to communicate its speaker selection decisions. Use response_format for reliable parsing. + GroupChatBuilder enforces this when the manager is a ChatAgent and rejects incompatible + response formats. Args: - chat_client: Chat completion client used to run the coordinator LLM. - instructions: System instructions to steer the coordinator's decision-making. - If not provided, uses DEFAULT_MANAGER_INSTRUCTIONS. These instructions are combined - with the task description, participant list, and structured output format to guide - the LLM in selecting the next speaker or completing the conversation. - display_name: Optional conversational display name for manager messages. + manager: Agent or executor responsible for speaker selection and coordination. + Must return ManagerSelectionResponse or compatible dict/JSON structure. + display_name: Optional name for manager messages in conversation history. + If not provided, uses manager.name for AgentProtocol or manager.id for Executor. Returns: Self for fluent chaining. - Note: - Calling this method and :meth:`set_speaker_selector` together is not allowed; choose one. + Raises: + ValueError: If manager is already configured via :py:meth:`GroupChatBuilder.set_select_speakers_func` + TypeError: If manager is not AgentProtocol or Executor instance Example: .. code-block:: python - from agent_framework import GroupChatBuilder, DEFAULT_MANAGER_INSTRUCTIONS + from agent_framework import GroupChatBuilder, ChatAgent + from agent_framework.openai import OpenAIChatClient + + # Coordinator agent - response_format is enforced to ManagerSelectionResponse + coordinator = ChatAgent( + name="Coordinator", + description="Coordinates multi-agent collaboration", + instructions=''' + You coordinate a team conversation. Review the conversation history + and select the next participant to speak. - custom_instructions = ( - DEFAULT_MANAGER_INSTRUCTIONS + "\\n\\nPrioritize the researcher for data analysis tasks." + When ready to finish, set finish=True and provide a summary in final_message. + ''', + chat_client=OpenAIChatClient(), ) workflow = ( GroupChatBuilder() - .set_prompt_based_manager(chat_client, instructions=custom_instructions, display_name="Coordinator") - .participants(researcher=researcher, writer=writer) + .set_manager(coordinator, display_name="Orchestrator") + .participants([researcher, writer]) .build() ) + + Note: + The manager agent's response_format must be ManagerSelectionResponse for structured output. + Custom response formats raise ValueError instead of being overridden. """ - manager = _PromptBasedGroupChatManager( - chat_client, - instructions=instructions, - name=display_name, - ) - return self._set_manager_function(manager, display_name) + if self._manager is not None or self._manager_participant is not None: + raise ValueError( + "GroupChatBuilder already has a manager configured. " + "Call set_select_speakers_func(...) or set_manager(...) at most once." + ) - def select_speakers( + if not isinstance(manager, (AgentProtocol, Executor)): + raise TypeError(f"Manager must be AgentProtocol or Executor instance. Got {type(manager).__name__}.") + + # Infer display name from manager if not provided + if display_name is None: + display_name = manager.id if isinstance(manager, Executor) else manager.name or "manager" + + # Enforce ManagerSelectionResponse for ChatAgent managers + if isinstance(manager, ChatAgent): + configured_format = manager.chat_options.response_format + if configured_format is None: + manager.chat_options.response_format = ManagerSelectionResponse + elif configured_format is not ManagerSelectionResponse: + configured_format_name = getattr(configured_format, "__name__", str(configured_format)) + raise ValueError( + "Manager ChatAgent response_format must be ManagerSelectionResponse. " + f"Received '{configured_format_name}' for manager '{display_name}'." + ) + + self._manager_participant = manager + self._manager_name = display_name + return self + + def set_select_speakers_func( self, selector: ( Callable[[GroupChatStateSnapshot], Awaitable[str | None]] | Callable[[GroupChatStateSnapshot], str | None] @@ -908,6 +1383,15 @@ def select_speakers( function receives an immutable snapshot of the current conversation state and returns the name of the next participant to speak, or None to finish the conversation. + The selector function can implement any logic including: + - Simple round-robin or rule-based selection + - LLM-based decision making with custom prompts + - Conversation summarization before routing to the next agent + - Custom metadata or context passing + + For advanced scenarios, return a GroupChatDirective instead of a string to include + custom instructions or metadata for the next participant. + The selector function signature: def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: # state contains: task, participants, conversation, history, round_index @@ -917,6 +1401,7 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: Args: selector: Function that takes GroupChatStateSnapshot and returns the next speaker's name (str) to continue the conversation, or None to finish. May be sync or async. + Can also return GroupChatDirective for advanced control (instruction, metadata). display_name: Optional name shown in conversation history for orchestrator messages (defaults to "manager"). final_message: Optional final message (or factory) emitted when selector returns None @@ -925,7 +1410,7 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: Returns: Self for fluent chaining. - Example: + Example (simple): .. code-block:: python @@ -940,13 +1425,37 @@ def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: workflow = ( GroupChatBuilder() - .select_speakers(select_next_speaker) + .set_select_speakers_func(select_next_speaker) .participants(researcher=researcher_agent, writer=writer_agent) .build() ) + Example (with LLM and custom instructions): + + .. code-block:: python + + from agent_framework import GroupChatDirective + + + async def llm_based_selector(state: GroupChatStateSnapshot) -> GroupChatDirective | None: + if state["round_index"] >= 5: + return GroupChatDirective(finish=True) + + # Use LLM to decide next speaker and summarize conversation + conversation_summary = await summarize_with_llm(state["conversation"]) + next_agent = await pick_agent_with_llm(state["participants"], state["task"]) + + # Pass custom instruction to the selected agent + return GroupChatDirective( + agent_name=next_agent, + instruction=f"Context summary: {conversation_summary}", + ) + + + workflow = GroupChatBuilder().set_select_speakers_func(llm_based_selector).participants(...).build() + Note: - Cannot be combined with set_prompt_based_manager(). Choose one orchestration strategy. + Cannot be combined with :py:meth:`GroupChatBuilder.set_manager`. Choose one orchestration strategy. """ manager_name = display_name or "manager" adapter = _SpeakerSelectorAdapter( @@ -985,10 +1494,7 @@ def participants( from agent_framework import GroupChatBuilder workflow = ( - GroupChatBuilder() - .set_prompt_based_manager(chat_client) - .participants([writer_agent, reviewer_agent]) - .build() + GroupChatBuilder().set_manager(manager_agent).participants([writer_agent, reviewer_agent]).build() ) """ combined: dict[str, AgentProtocol | Executor] = {} @@ -998,6 +1504,11 @@ def _add(name: str, participant: AgentProtocol | Executor) -> None: raise ValueError("participant names must be non-empty strings") if name in combined or name in self._participants: raise ValueError(f"Duplicate participant name '{name}' supplied.") + if name == self._manager_name: + raise ValueError( + f"Participant name '{name}' conflicts with manager name. " + "Manager is automatically registered as a participant." + ) combined[name] = participant if participants: @@ -1050,7 +1561,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupCha storage = MemoryCheckpointStorage() workflow = ( GroupChatBuilder() - .set_prompt_based_manager(chat_client) + .set_manager(manager_agent) .participants(agent1=agent1, agent2=agent2) .with_checkpointing(storage) .build() @@ -1088,6 +1599,40 @@ def _factory(_: _GroupChatConfig) -> Executor: self._interceptors.append((factory, condition)) return self + def with_termination_condition( + self, + condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]], + ) -> "GroupChatBuilder": + """Define a custom termination condition for the group chat workflow. + + The condition receives the full conversation (including manager and agent messages) and may be async. + When it returns True, the orchestrator halts the conversation and emits a completion message authored + by the manager. + + Example: + + .. code-block:: python + + from agent_framework import ChatMessage, GroupChatBuilder, Role + + + def stop_after_two_calls(conversation: list[ChatMessage]) -> bool: + calls = sum(1 for msg in conversation if msg.role == Role.ASSISTANT and msg.author_name == "specialist") + return calls >= 2 + + + specialist_agent = ... + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(lambda _: "specialist") + .participants(specialist=specialist_agent) + .with_termination_condition(stop_after_two_calls) + .build() + ) + """ + self._termination_condition = condition + return self + def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": """Set a maximum number of manager rounds to prevent infinite conversations. @@ -1109,7 +1654,7 @@ def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": # Limit to 15 rounds workflow = ( GroupChatBuilder() - .set_prompt_based_manager(chat_client) + .set_manager(manager_agent) .participants(agent1=agent1, agent2=agent2) .with_max_rounds(15) .build() @@ -1117,11 +1662,7 @@ def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": # Unlimited rounds workflow = ( - GroupChatBuilder() - .set_prompt_based_manager(chat_client) - .participants(agent1=agent1) - .with_max_rounds(None) - .build() + GroupChatBuilder().set_manager(manager_agent).participants(agent1=agent1).with_max_rounds(None).build() ) """ self._max_rounds = max_rounds @@ -1182,19 +1723,21 @@ def build(self) -> Workflow: from agent_framework import GroupChatBuilder # Execute the workflow - workflow = ( - GroupChatBuilder() - .set_prompt_based_manager(chat_client) - .participants(agent1=agent1, agent2=agent2) - .build() - ) + workflow = GroupChatBuilder().set_manager(manager_agent).participants(agent1=agent1, agent2=agent2).build() async for message in workflow.run("Solve this problem collaboratively"): print(message.text) """ # Manager is only required when using the default orchestrator factory # Custom factories (e.g., MagenticBuilder) provide their own orchestrator with embedded manager - if self._manager is None and self._orchestrator_factory == _default_orchestrator_factory: - raise ValueError("manager must be configured before build() when using default orchestrator") + if ( + self._manager is None + and self._manager_participant is None + and self._orchestrator_factory == _default_orchestrator_factory + ): + raise ValueError( + "manager must be configured before build() when using default orchestrator. " + "Call set_manager(...) or set_select_speakers_func(...) before build()." + ) if not self._participants: raise ValueError("participants must be configured before build()") @@ -1202,9 +1745,11 @@ def build(self) -> Workflow: participant_specs = self._build_participant_specs() wiring = _GroupChatConfig( manager=self._manager, + manager_participant=self._manager_participant, manager_name=self._manager_name, participants=participant_specs, max_rounds=self._max_rounds, + termination_condition=self._termination_condition, participant_aliases=metadata["aliases"], participant_executors=metadata["executors"], ) @@ -1262,117 +1807,6 @@ class ManagerDirectiveModel(BaseModel): ) -class _PromptBasedGroupChatManager: - """LLM-backed manager that produces directives via structured output. - - This is the default manager implementation for group chat workflows. It uses an LLM - to make speaker selection decisions based on conversation state, participant - descriptions, and custom instructions. - - Coordination strategy: - - Receives immutable state snapshot with full conversation history - - Formats system prompt with instructions, task, and participant descriptions - - Appends conversation context and uses structured output (Pydantic model) for reliable parsing - - Converts LLM response to GroupChatDirective - - Flexibility: - - Custom instructions allow domain-specific coordination strategies - - Participant descriptions guide the LLM's selection logic - - Structured output ensures reliable parsing (no regex or brittle prompts) - - Example coordination patterns: - - Round-robin: "Rotate between participants in order" - - Task-based: "Select the participant best suited for the current sub-task" - - Dependency-aware: "Only call analyst after researcher provides data" - - Args: - chat_client: ChatClientProtocol implementation for LLM inference - instructions: Custom system instructions (defaults to DEFAULT_MANAGER_INSTRUCTIONS). - These instructions are combined with the task, participant list, and - structured output format (ManagerDirectiveModel) to coordinate the conversation. - name: Display name for the manager in conversation history - - Raises: - RuntimeError: If LLM response cannot be parsed into the directive payload - If directive is missing next_agent when finish=False - If selected agent is not in participants - """ - - def __init__( - self, - chat_client: ChatClientProtocol, - *, - instructions: str | None = None, - name: str | None = None, - ) -> None: - self._chat_client = chat_client - self._instructions = instructions or DEFAULT_MANAGER_INSTRUCTIONS - self._name = name or "GroupChatManager" - - @property - def name(self) -> str: - return self._name - - async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: - participants = state["participants"] - task_message = state["task"] - conversation = state["conversation"] - - participants_section = "\n".join(f"- {agent}: {description}" for agent, description in participants.items()) - - system_message = ChatMessage( - role=Role.SYSTEM, - text=( - f"{self._instructions}\n\n" - f"Task:\n{task_message.text}\n\n" - f"Participants:\n{participants_section}\n\n" - f"{DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT}" - ), - ) - - messages: list[ChatMessage] = [system_message, *conversation] - - response = await self._chat_client.get_response(messages, response_format=ManagerDirectiveModel) - - directive_model: ManagerDirectiveModel - if response.value is not None: - if isinstance(response.value, ManagerDirectiveModel): - directive_model = response.value - elif isinstance(response.value, str): - directive_model = ManagerDirectiveModel.model_validate_json(response.value) - elif isinstance(response.value, dict): - directive_model = ManagerDirectiveModel.model_validate(response.value) # type: ignore[arg-type] - else: - raise RuntimeError(f"Unexpected response.value type: {type(response.value)}") - elif response.messages: - text = response.messages[-1].text or "{}" - directive_model = ManagerDirectiveModel.model_validate_json(text) - else: - raise RuntimeError("LLM response did not contain structured output.") - - if directive_model.finish: - final_text = directive_model.final_response or "" - return GroupChatDirective( - finish=True, - final_message=ChatMessage( - role=Role.ASSISTANT, - text=final_text, - author_name=self._name, - ), - ) - - next_agent = directive_model.next_agent - if not next_agent: - raise RuntimeError("Manager directive missing next_agent while finish is False.") - if next_agent not in participants: - raise RuntimeError(f"Manager selected unknown participant '{next_agent}'.") - - return GroupChatDirective( - agent_name=next_agent, - instruction=directive_model.message or "", - ) - - class _SpeakerSelectorAdapter: """Adapter that turns a simple speaker selector into a full manager directive.""" diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index d18bc59562e..054c53f6e33 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -1424,6 +1424,7 @@ def build(self) -> Workflow: prompt=self._request_prompt, id="handoff-user-input", ) + builder = WorkflowBuilder(name=self._name, description=self._description).set_start_executor(input_node) specialist_aliases = {alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists} @@ -1440,6 +1441,7 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: wiring = _GroupChatConfig( manager=None, + manager_participant=None, manager_name=self._starting_agent_id, participants=participant_specs, max_rounds=None, @@ -1453,14 +1455,13 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: orchestrator_factory=_handoff_orchestrator_factory, interceptors=(), checkpoint_storage=self._checkpoint_storage, - builder=WorkflowBuilder(name=self._name, description=self._description), + builder=builder, return_builder=True, ) if not isinstance(result, tuple): raise TypeError("Expected tuple from assemble_group_chat_workflow with return_builder=True") builder, coordinator = result - builder = builder.set_start_executor(input_node) builder = builder.add_edge(input_node, starting_executor) builder = builder.add_edge(coordinator, user_gateway) builder = builder.add_edge(user_gateway, coordinator) diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index ea6fb259a6f..d91cf2a3b8a 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -10,16 +10,15 @@ from collections.abc import AsyncIterable, Sequence from dataclasses import dataclass, field from enum import Enum -from typing import Any, Protocol, TypeVar, Union, cast +from typing import Any, TypeVar, cast from uuid import uuid4 from agent_framework import ( AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, - ChatClientProtocol, ChatMessage, - FunctionCallContent, + FunctionApprovalRequestContent, FunctionResultContent, Role, ) @@ -27,7 +26,7 @@ from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._const import EXECUTOR_STATE_KEY -from ._events import WorkflowEvent +from ._events import AgentRunUpdateEvent, WorkflowEvent from ._executor import Executor, handler from ._group_chat import ( GroupChatBuilder, @@ -104,64 +103,13 @@ def _message_from_payload(payload: Any) -> ChatMessage: raise TypeError("Unable to reconstruct ChatMessage from payload") -# region Unified callback API (developer-facing) +# region Magentic event metadata constants +# Event type identifiers for magentic_event_type in additional_properties +MAGENTIC_EVENT_TYPE_ORCHESTRATOR = "orchestrator_message" +MAGENTIC_EVENT_TYPE_AGENT_DELTA = "agent_delta" -@dataclass -class MagenticOrchestratorMessageEvent(WorkflowEvent): - orchestrator_id: str = "" - message: ChatMessage | None = None - kind: str = "" - - def __post_init__(self) -> None: - super().__init__(data=self.message) - - -@dataclass -class MagenticAgentDeltaEvent(WorkflowEvent): - agent_id: str | None = None - text: str | None = None - function_call_id: str | None = None - function_call_name: str | None = None - function_call_arguments: Any | None = None - function_result_id: str | None = None - function_result: Any | None = None - role: Role | None = None - - def __post_init__(self) -> None: - super().__init__(data=self.text) - - -@dataclass -class MagenticAgentMessageEvent(WorkflowEvent): - agent_id: str = "" - message: ChatMessage | None = None - - def __post_init__(self) -> None: - super().__init__(data=self.message) - - -@dataclass -class MagenticFinalResultEvent(WorkflowEvent): - message: ChatMessage | None = None - - def __post_init__(self) -> None: - super().__init__(data=self.message) - - -MagenticCallbackEvent = Union[ - MagenticOrchestratorMessageEvent, - MagenticAgentDeltaEvent, - MagenticAgentMessageEvent, - MagenticFinalResultEvent, -] - - -class CallbackSink(Protocol): - async def __call__(self, event: MagenticCallbackEvent) -> None: ... - - -# endregion Unified callback API +# endregion Magentic event metadata constants # region Magentic One Prompts @@ -426,29 +374,101 @@ def from_dict(cls, value: dict[str, Any]) -> "_MagenticResponseMessage": return cls(body=body, target_agent=target_agent, broadcast=broadcast) +# region Human Intervention Types + + +class MagenticHumanInterventionKind(str, Enum): + """The kind of human intervention being requested.""" + + PLAN_REVIEW = "plan_review" # Review and approve/revise the initial plan + TOOL_APPROVAL = "tool_approval" # Approve a tool/function call + STALL = "stall" # Workflow has stalled and needs guidance + + +class MagenticHumanInterventionDecision(str, Enum): + """Decision options for human intervention responses.""" + + APPROVE = "approve" # Approve (plan review, tool approval) + REVISE = "revise" # Request revision with feedback (plan review) + REJECT = "reject" # Reject/deny (tool approval) + CONTINUE = "continue" # Continue with current state (stall) + REPLAN = "replan" # Trigger replanning (stall) + GUIDANCE = "guidance" # Provide guidance text (stall, tool approval) + + @dataclass -class _MagenticPlanReviewRequest: - """Internal: Human-in-the-loop request to review and optionally edit the plan before execution.""" +class _MagenticHumanInterventionRequest: + """Unified request for human intervention in a Magentic workflow. + + This request is emitted when the workflow needs human input. The `kind` field + indicates what type of intervention is needed, and the relevant fields are + populated based on the kind. + + Attributes: + request_id: Unique identifier for correlating responses + kind: The type of intervention needed (plan_review, tool_approval, stall) + + # Plan review fields + task_text: The task description (plan_review) + facts_text: Extracted facts from the task (plan_review) + plan_text: The proposed or current plan (plan_review, stall) + round_index: Number of review rounds so far (plan_review) + + # Tool approval fields + agent_id: The agent requesting input (tool_approval) + prompt: Description of what input is needed (tool_approval) + context: Additional context (tool_approval) + conversation_snapshot: Recent conversation history (tool_approval) + + # Stall intervention fields + stall_count: Number of consecutive stall rounds (stall) + max_stall_count: Threshold that triggered intervention (stall) + stall_reason: Description of why progress stalled (stall) + last_agent: Last active agent (stall) + """ request_id: str = field(default_factory=lambda: str(uuid4())) + kind: MagenticHumanInterventionKind = MagenticHumanInterventionKind.PLAN_REVIEW + + # Plan review fields task_text: str = "" facts_text: str = "" plan_text: str = "" - round_index: int = 0 # number of review rounds so far + round_index: int = 0 + # Tool approval fields + agent_id: str = "" + prompt: str = "" + context: str | None = None + conversation_snapshot: list[ChatMessage] = field(default_factory=list) # type: ignore -class MagenticPlanReviewDecision(str, Enum): - APPROVE = "approve" - REVISE = "revise" + # Stall intervention fields + stall_count: int = 0 + max_stall_count: int = 3 + stall_reason: str = "" + last_agent: str = "" @dataclass -class _MagenticPlanReviewReply: - """Internal: Human reply to a plan review request.""" +class _MagenticHumanInterventionReply: + """Unified reply to a human intervention request. + + The relevant fields depend on the original request kind and the decision made. + + Attributes: + decision: The human's decision (approve, revise, continue, replan, guidance) + edited_plan_text: New plan text if directly editing (plan_review with approve/revise) + comments: Feedback for revision or guidance text (plan_review, stall with guidance) + response_text: Free-form response text (tool_approval) + """ + + decision: MagenticHumanInterventionDecision + edited_plan_text: str | None = None + comments: str | None = None + response_text: str | None = None - decision: MagenticPlanReviewDecision - edited_plan_text: str | None = None # if supplied, becomes the new plan text verbatim - comments: str | None = None # guidance for replan if no edited text provided + +# endregion Human Intervention Types @dataclass @@ -703,10 +723,9 @@ class StandardMagenticManager(MagenticManagerBase): def __init__( self, - chat_client: ChatClientProtocol, + agent: AgentProtocol, task_ledger: _MagenticTaskLedger | None = None, *, - instructions: str | None = None, task_ledger_facts_prompt: str | None = None, task_ledger_plan_prompt: str | None = None, task_ledger_full_prompt: str | None = None, @@ -722,11 +741,11 @@ def __init__( """Initialize the Standard Magentic Manager. Args: - chat_client: The chat client to use for LLM calls. - instructions: Instructions for the orchestrator agent. + agent: An agent instance to use for LLM calls. The agent's configured + options (temperature, seed, instructions, etc.) will be applied. + task_ledger: Optional task ledger for managing task state. Keyword Args: - task_ledger: Optional task ledger for managing task state. task_ledger_facts_prompt: Optional prompt for the task ledger facts. task_ledger_plan_prompt: Optional prompt for the task ledger plan. task_ledger_full_prompt: Optional prompt for the full task ledger. @@ -745,8 +764,7 @@ def __init__( max_round_count=max_round_count, ) - self.chat_client: ChatClientProtocol = chat_client - self.instructions: str | None = instructions + self._agent: AgentProtocol = agent self.task_ledger: _MagenticTaskLedger | None = task_ledger # Prompts may be overridden if needed @@ -770,34 +788,20 @@ async def _complete( self, messages: list[ChatMessage], ) -> ChatMessage: - """Call the underlying ChatClientProtocol directly and return the last assistant message. + """Call the underlying agent and return the last assistant message. - If manager instructions are provided, they are injected as a SYSTEM message - at the start of the request to guide the model consistently without needing - an intermediate Agent wrapper. + The agent's run method is called which applies the agent's configured options + (temperature, seed, instructions, etc.). """ - # Prepend system instructions if present - request_messages: list[ChatMessage] = [] - if self.instructions: - request_messages.append(ChatMessage(role=Role.SYSTEM, text=self.instructions)) - request_messages.extend(messages) - - # Invoke the chat client non-streaming API - response = await self.chat_client.get_response(request_messages) - try: - out_messages: list[ChatMessage] | None = list(response.messages) # type: ignore[assignment] - except Exception: - out_messages = None - + response: AgentRunResponse = await self._agent.run(messages) + out_messages = response.messages if response else None if out_messages: last = out_messages[-1] return ChatMessage( - role=last.role or Role.ASSISTANT, - text=last.text or "", + role=last.role, + text=last.text, author_name=last.author_name or MAGENTIC_MANAGER_NAME, ) - - # Fallback if no messages return ChatMessage(role=Role.ASSISTANT, text="No output produced.", author_name=MAGENTIC_MANAGER_NAME) async def plan(self, magentic_context: MagenticContext) -> ChatMessage: @@ -971,6 +975,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator): _plan_review_round: int _max_plan_review_rounds: int _terminated: bool + _enable_stall_intervention: bool def __init__( self, @@ -979,6 +984,7 @@ def __init__( *, require_plan_signoff: bool = False, max_plan_review_rounds: int = 10, + enable_stall_intervention: bool = False, executor_id: str | None = None, ) -> None: """Initializes a new instance of the MagenticOrchestratorExecutor. @@ -988,6 +994,7 @@ def __init__( participants: A dictionary of participant IDs to their names. require_plan_signoff: Whether to require plan sign-off from a human. max_plan_review_rounds: The maximum number of plan review rounds. + enable_stall_intervention: Whether to request human input on stalls instead of auto-replan. executor_id: An optional executor ID. """ super().__init__(executor_id or f"magentic_orchestrator_{uuid4().hex[:8]}") @@ -998,6 +1005,7 @@ def __init__( self._require_plan_signoff = require_plan_signoff self._plan_review_round = 0 self._max_plan_review_rounds = max_plan_review_rounds + self._enable_stall_intervention = enable_stall_intervention # Registry of agent executors for internal coordination (e.g., resets) self._agent_executors = {} # Terminal state marker to stop further processing after completion/limits @@ -1014,15 +1022,14 @@ def register_agent_executor(self, name: str, executor: "MagenticAgentExecutor") async def _emit_orchestrator_message( self, - ctx: WorkflowContext[Any, ChatMessage], + ctx: WorkflowContext[Any, list[ChatMessage]], message: ChatMessage, kind: str, ) -> None: """Emit orchestrator message to the workflow event stream. - Orchestrator messages flow through the unified workflow event stream as - MagenticOrchestratorMessageEvent instances. Consumers should subscribe to - these events via workflow.run_stream(). + Emits an AgentRunUpdateEvent (for agent wrapper consumers) with metadata indicating + the orchestrator event type. Args: ctx: Workflow context for adding events to the stream @@ -1031,15 +1038,24 @@ async def _emit_orchestrator_message( Example: async for event in workflow.run_stream("task"): - if isinstance(event, MagenticOrchestratorMessageEvent): - print(f"Orchestrator {event.kind}: {event.message.text}") + if isinstance(event, AgentRunUpdateEvent): + props = event.data.additional_properties if event.data else None + if props and props.get("magentic_event_type") == "orchestrator_message": + kind = props.get("orchestrator_message_kind", "") + print(f"Orchestrator {kind}: {event.data.text}") """ - event = MagenticOrchestratorMessageEvent( - orchestrator_id=self.id, - message=message, - kind=kind, + # Emit AgentRunUpdateEvent with metadata + update = AgentRunResponseUpdate( + text=message.text, + role=message.role, + author_name=self._get_author_name(), + additional_properties={ + "magentic_event_type": MAGENTIC_EVENT_TYPE_ORCHESTRATOR, + "orchestrator_message_kind": kind, + "orchestrator_id": self.id, + }, ) - await ctx.add_event(event) + await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=update)) @override async def on_checkpoint_save(self) -> dict[str, Any]: @@ -1065,7 +1081,7 @@ async def on_checkpoint_save(self) -> dict[str, Any]: try: state["manager_state"] = self._manager.on_checkpoint_save() except Exception as exc: - logger.warning("Failed to save manager state for checkpoint: %s\nSkipping...", exc) + logger.warning(f"Failed to save manager state for checkpoint: {exc}\nSkipping...") return state @@ -1095,14 +1111,14 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: else: self._context = None except Exception as exc: # pragma: no cover - defensive - logger.warning("Failed to restore magentic context: %s", exc) + logger.warning(f"Failed to restore magentic context: {exc}") self._context = None ledger_payload = state.get("task_ledger") if ledger_payload is not None: try: self._task_ledger = _message_from_payload(ledger_payload) except Exception as exc: # pragma: no cover - logger.warning("Failed to restore task ledger message: %s", exc) + logger.warning(f"Failed to restore task ledger message: {exc}") self._task_ledger = None if "plan_review_round" in state: @@ -1122,7 +1138,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: try: self._manager.on_checkpoint_restore(manager_state) except Exception as exc: # pragma: no cover - logger.warning("Failed to restore manager state: %s", exc) + logger.warning(f"Failed to restore manager state: {exc}") self._reconcile_restored_participants() @@ -1155,7 +1171,7 @@ async def handle_start_message( self, message: _MagenticStartMessage, context: WorkflowContext[ - _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] ], ) -> None: """Handle the initial start message to begin orchestration.""" @@ -1190,7 +1206,7 @@ async def handle_start_message( # Start the inner loop ctx2 = cast( - WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], context, ) await self._run_inner_loop(ctx2) @@ -1200,7 +1216,7 @@ async def handle_task_text( self, task_text: str, context: WorkflowContext[ - _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] ], ) -> None: await self.handle_start_message(_MagenticStartMessage.from_string(task_text), context) @@ -1210,7 +1226,7 @@ async def handle_task_message( self, task_message: ChatMessage, context: WorkflowContext[ - _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] ], ) -> None: await self.handle_start_message(_MagenticStartMessage(task_message), context) @@ -1220,7 +1236,7 @@ async def handle_task_messages( self, conversation: list[ChatMessage], context: WorkflowContext[ - _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] ], ) -> None: await self.handle_start_message(_MagenticStartMessage(conversation), context) @@ -1229,7 +1245,7 @@ async def handle_task_messages( async def handle_response_message( self, message: _MagenticResponseMessage, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> None: """Handle responses from agents.""" if getattr(self, "_terminated", False): @@ -1255,22 +1271,45 @@ async def handle_response_message( await self._run_inner_loop(context) @response_handler - async def handle_plan_review_response( + async def handle_human_intervention_response( self, - original_request: _MagenticPlanReviewRequest, - response: _MagenticPlanReviewReply, + original_request: _MagenticHumanInterventionRequest, + response: _MagenticHumanInterventionReply, context: WorkflowContext[ - # may broadcast ledger next, or ask for another round of review - _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] ], ) -> None: + """Handle unified human intervention responses. + + Routes the response to the appropriate handler based on the original request kind. + """ if getattr(self, "_terminated", False): return if self._context is None: return - if response.decision == MagenticPlanReviewDecision.APPROVE: + if original_request.kind == MagenticHumanInterventionKind.PLAN_REVIEW: + await self._handle_plan_review_response(original_request, response, context) + elif original_request.kind == MagenticHumanInterventionKind.STALL: + await self._handle_stall_intervention_response(original_request, response, context) + # TOOL_APPROVAL is handled by MagenticAgentExecutor, not the orchestrator + + async def _handle_plan_review_response( + self, + original_request: _MagenticHumanInterventionRequest, + response: _MagenticHumanInterventionReply, + context: WorkflowContext[ + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] + ], + ) -> None: + """Handle plan review response.""" + if self._context is None: + return + + is_approve = response.decision == MagenticHumanInterventionDecision.APPROVE + + if is_approve: # Close the review loop on approval (no further plan review requests this run) self._require_plan_signoff = False # If the user supplied an edited plan, adopt it @@ -1291,13 +1330,11 @@ async def handle_plan_review_response( text=combined, author_name=MAGENTIC_MANAGER_NAME, ) - # If approved with comments but no edited text, apply comments via replan and proceed (no extra review) + # If approved with comments but no edited text, apply comments via replan and proceed elif response.comments: - # Record the human feedback for grounding self._context.chat_history.append( ChatMessage(role=Role.USER, text=f"Human plan feedback: {response.comments}") ) - # Ask the manager to replan based on comments; proceed immediately self._task_ledger = await self._manager.replan(self._context.clone(deep=True)) # Record the signed-off plan (no broadcast) @@ -1307,7 +1344,7 @@ async def handle_plan_review_response( # Enter the normal coordination loop ctx2 = cast( - WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], context, ) await self._run_inner_loop(ctx2) @@ -1317,9 +1354,7 @@ async def handle_plan_review_response( self._plan_review_round += 1 if self._plan_review_round > self._max_plan_review_rounds: logger.warning("Magentic Orchestrator: Max plan review rounds reached. Proceeding with current plan.") - # Stop any further plan review requests for the rest of this run self._require_plan_signoff = False - # Add a clear note to the conversation so users know review is closed notice = ChatMessage( role=Role.ASSISTANT, text=( @@ -1332,20 +1367,18 @@ async def handle_plan_review_response( await self._emit_orchestrator_message(context, notice, ORCH_MSG_KIND_NOTICE) if self._task_ledger: self._context.chat_history.append(self._task_ledger) - # No further review requests; proceed directly into coordination ctx2 = cast( - WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], context, ) await self._run_inner_loop(ctx2) return - # If the user provided an edited plan, adopt it directly and ask them to confirm once more + # If the user provided an edited plan, adopt it and ask for confirmation if response.edited_plan_text: mgr_ledger2 = getattr(self._manager, "task_ledger", None) if mgr_ledger2 is not None: mgr_ledger2.plan.text = response.edited_plan_text - # Rebuild combined message for preview in the next review request team_text = _team_block(self._participants) combined = self._manager.task_ledger_full_prompt.format( task=self._context.task.text, @@ -1357,19 +1390,70 @@ async def handle_plan_review_response( await self._send_plan_review_request(cast(WorkflowContext, context)) return - # Else pass comments into the chat history and replan with the manager + # Else pass comments into the chat history and replan if response.comments: self._context.chat_history.append( ChatMessage(role=Role.USER, text=f"Human plan feedback: {response.comments}") ) - # Ask the manager to replan; this only adjusts the plan stage, not a full reset self._task_ledger = await self._manager.replan(self._context.clone(deep=True)) await self._send_plan_review_request(cast(WorkflowContext, context)) + async def _handle_stall_intervention_response( + self, + original_request: _MagenticHumanInterventionRequest, + response: _MagenticHumanInterventionReply, + context: WorkflowContext[ + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticHumanInterventionRequest, list[ChatMessage] + ], + ) -> None: + """Handle stall intervention response.""" + if self._context is None: + return + + ctx = self._context + logger.info( + f"Stall intervention response: decision={response.decision.value}, " + f"stall_count was {original_request.stall_count}" + ) + + if response.decision == MagenticHumanInterventionDecision.CONTINUE: + ctx.stall_count = 0 + ctx2 = cast( + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], + context, + ) + await self._run_inner_loop(ctx2) + return + + if response.decision == MagenticHumanInterventionDecision.REPLAN: + ctx2 = cast( + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], + context, + ) + await self._reset_and_replan(ctx2) + return + + if response.decision == MagenticHumanInterventionDecision.GUIDANCE: + ctx.stall_count = 0 + guidance = response.comments or response.response_text + if guidance: + guidance_msg = ChatMessage( + role=Role.USER, + text=f"Human guidance to help with stall: {guidance}", + ) + ctx.chat_history.append(guidance_msg) + await self._emit_orchestrator_message(context, guidance_msg, ORCH_MSG_KIND_NOTICE) + ctx2 = cast( + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], + context, + ) + await self._run_inner_loop(ctx2) + return + async def _run_outer_loop( self, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> None: """Run the outer orchestration loop - planning phase.""" if self._context is None: @@ -1392,7 +1476,7 @@ async def _run_outer_loop( async def _run_inner_loop( self, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> None: """Run the inner orchestration loop. Coordination phase. Serialized with a lock.""" if self._context is None or self._task_ledger is None: @@ -1402,7 +1486,7 @@ async def _run_inner_loop( async def _run_inner_loop_helper( self, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> None: """Run inner loop with exclusive access.""" # Narrow optional context for the remainder of this method @@ -1415,20 +1499,19 @@ async def _run_inner_loop_helper( return ctx.round_count += 1 - logger.info("Magentic Orchestrator: Inner loop - round %s", ctx.round_count) + logger.info(f"Magentic Orchestrator: Inner loop - round {ctx.round_count}") # Create progress ledger using the manager try: current_progress_ledger = await self._manager.create_progress_ledger(ctx.clone(deep=True)) except Exception as ex: - logger.warning("Magentic Orchestrator: Progress ledger creation failed, triggering reset: %s", ex) + logger.warning(f"Magentic Orchestrator: Progress ledger creation failed, triggering reset: {ex}") await self._reset_and_replan(context) return logger.debug( - "Progress evaluation: satisfied=%s, next=%s", - current_progress_ledger.is_request_satisfied.answer, - current_progress_ledger.next_speaker.answer, + f"Progress evaluation: satisfied={current_progress_ledger.is_request_satisfied.answer}, " + f"next={current_progress_ledger.next_speaker.answer}" ) # Check for task completion @@ -1444,7 +1527,34 @@ async def _run_inner_loop_helper( ctx.stall_count = max(0, ctx.stall_count - 1) if ctx.stall_count > self._manager.max_stall_count: - logger.info("Magentic Orchestrator: Stalling detected. Resetting and replanning") + logger.info(f"Magentic Orchestrator: Stalling detected after {ctx.stall_count} rounds") + if self._enable_stall_intervention: + # Request human intervention instead of auto-replan + is_progress = current_progress_ledger.is_progress_being_made.answer + is_loop = current_progress_ledger.is_in_loop.answer + stall_reason = "No progress being made" if not is_progress else "" + if is_loop: + loop_msg = "Agents appear to be in a loop" + stall_reason = f"{stall_reason}; {loop_msg}" if stall_reason else loop_msg + next_speaker_val = current_progress_ledger.next_speaker.answer + last_agent = next_speaker_val if isinstance(next_speaker_val, str) else "" + # Get facts and plan from manager's task ledger + mgr_ledger = getattr(self._manager, "task_ledger", None) + facts_text = mgr_ledger.facts.text if mgr_ledger else "" + plan_text = mgr_ledger.plan.text if mgr_ledger else "" + request = _MagenticHumanInterventionRequest( + kind=MagenticHumanInterventionKind.STALL, + stall_count=ctx.stall_count, + max_stall_count=self._manager.max_stall_count, + task_text=ctx.task.text if ctx.task else "", + facts_text=facts_text, + plan_text=plan_text, + last_agent=last_agent, + stall_reason=stall_reason, + ) + await context.request_info(request, _MagenticHumanInterventionReply) + return + # Default behavior: auto-replan await self._reset_and_replan(context) return @@ -1458,7 +1568,7 @@ async def _run_inner_loop_helper( instruction = current_progress_ledger.instruction_or_question.answer if next_speaker_value not in self._participants: - logger.warning("Invalid next speaker: %s", next_speaker_value) + logger.warning(f"Invalid next speaker: {next_speaker_value}") await self._prepare_final_answer(context) return @@ -1475,7 +1585,7 @@ async def _run_inner_loop_helper( target_executor_id = f"agent_{next_speaker_value}" # Request specific agent to respond - logger.debug("Magentic Orchestrator: Requesting %s to respond", next_speaker_value) + logger.debug(f"Magentic Orchestrator: Requesting {next_speaker_value} to respond") await context.send_message( _MagenticRequestMessage( agent_name=next_speaker_value, @@ -1487,7 +1597,7 @@ async def _run_inner_loop_helper( async def _reset_and_replan( self, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> None: """Reset context and replan.""" if self._context is None: @@ -1513,7 +1623,7 @@ async def _reset_and_replan( async def _prepare_final_answer( self, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> None: """Prepare the final answer using the manager.""" if self._context is None: @@ -1523,12 +1633,11 @@ async def _prepare_final_answer( final_answer = await self._manager.prepare_final_answer(self._context.clone(deep=True)) # Emit a completed event for the workflow - await context.yield_output(final_answer) - await context.add_event(MagenticFinalResultEvent(message=final_answer)) + await context.yield_output([final_answer]) async def _check_within_limits_or_complete( self, - context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]], ) -> bool: """Check if orchestrator is within operational limits.""" if self._context is None: @@ -1540,7 +1649,7 @@ async def _check_within_limits_or_complete( if hit_round_limit or hit_reset_limit: limit_type = "round" if hit_round_limit else "reset" - logger.error("Magentic Orchestrator: Max %s count reached", limit_type) + logger.error(f"Magentic Orchestrator: Max {limit_type} count reached") # Only emit completion once and then mark terminated if not self._terminated: @@ -1555,14 +1664,13 @@ async def _check_within_limits_or_complete( ) # Yield the partial result and signal completion - await context.yield_output(partial_result) - await context.add_event(MagenticFinalResultEvent(message=partial_result)) + await context.yield_output([partial_result]) return False return True async def _send_plan_review_request(self, context: WorkflowContext) -> None: - """Send a PlanReviewRequest.""" + """Send a human intervention request for plan review.""" # If plan sign-off is disabled (e.g., ran out of review rounds), do nothing if not self._require_plan_signoff: return @@ -1571,13 +1679,14 @@ async def _send_plan_review_request(self, context: WorkflowContext) -> None: plan_text = ledger.plan.text if ledger else "" task_text = self._context.task.text if self._context else "" - req = _MagenticPlanReviewRequest( + req = _MagenticHumanInterventionRequest( + kind=MagenticHumanInterventionKind.PLAN_REVIEW, task_text=task_text, facts_text=facts_text, plan_text=plan_text, round_index=self._plan_review_round, ) - await context.request_info(req, _MagenticPlanReviewReply) + await context.request_info(req, _MagenticHumanInterventionReply) # region Magentic Executors @@ -1590,6 +1699,7 @@ class MagenticAgentExecutor(Executor): - Receiving task ledger broadcasts - Responding to specific agent requests - Resetting agent state when needed + - Surfacing tool approval requests (user_input_requests) as HITL events """ def __init__( @@ -1601,6 +1711,9 @@ def __init__( self._agent = agent self._agent_id = agent_id self._chat_history: list[ChatMessage] = [] + self._pending_human_input_request: _MagenticHumanInterventionRequest | None = None + self._pending_tool_request: FunctionApprovalRequestContent | None = None + self._current_request_message: _MagenticRequestMessage | None = None @override async def on_checkpoint_save(self) -> dict[str, Any]: @@ -1629,7 +1742,7 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: try: self._chat_history = decode_chat_messages(history_payload) except Exception as exc: # pragma: no cover - logger.warning("Agent %s: Failed to restore chat history: %s", self._agent_id, exc) + logger.warning(f"Agent {self._agent_id}: Failed to restore chat history: {exc}") self._chat_history = [] else: self._chat_history = [] @@ -1639,12 +1752,12 @@ async def handle_response_message( self, message: _MagenticResponseMessage, context: WorkflowContext[_MagenticResponseMessage] ) -> None: """Handle response message (task ledger broadcast).""" - logger.debug("Agent %s: Received response message", self._agent_id) + logger.debug(f"Agent {self._agent_id}: Received response message") # Check if this message is intended for this agent if message.target_agent is not None and message.target_agent != self._agent_id and not message.broadcast: # Message is targeted to a different agent, ignore it - logger.debug("Agent %s: Ignoring message targeted to %s", self._agent_id, message.target_agent) + logger.debug(f"Agent {self._agent_id}: Ignoring message targeted to {message.target_agent}") return # Add transfer message if needed @@ -1679,7 +1792,10 @@ async def handle_request_message( if message.agent_name != self._agent_id: return - logger.info("Agent %s: Received request to respond", self._agent_id) + logger.info(f"Agent {self._agent_id}: Received request to respond") + + # Store the original request message for potential continuation after human input + self._current_request_message = message # Add persona adoption message with appropriate role persona_role = self._get_persona_adoption_role() @@ -1697,23 +1813,26 @@ async def handle_request_message( from agent_framework import BaseAgent as _AF_AgentBase # local import to avoid cycles if not isinstance(self._agent, _AF_AgentBase): - response = ChatMessage( + response: ChatMessage = ChatMessage( role=Role.ASSISTANT, text=f"{self._agent_id} is a workflow executor and cannot be invoked directly.", author_name=self._agent_id, ) self._chat_history.append(response) await self._emit_agent_message_event(context, response) + await context.send_message(_MagenticResponseMessage(body=response)) else: # Invoke the agent - response = await self._invoke_agent(context) - self._chat_history.append(response) - - # Send response back to orchestrator - await context.send_message(_MagenticResponseMessage(body=response)) + agent_response = await self._invoke_agent(context) + if agent_response is None: + # Agent is waiting for human input - don't send response yet + return + self._chat_history.append(agent_response) + # Send response back to orchestrator + await context.send_message(_MagenticResponseMessage(body=agent_response)) except Exception as e: - logger.warning("Agent %s invoke failed: %s", self._agent_id, e) + logger.warning(f"Agent {self._agent_id} invoke failed: {e}") # Fallback response response = ChatMessage( role=Role.ASSISTANT, @@ -1725,59 +1844,164 @@ async def handle_request_message( def reset(self) -> None: """Reset the internal chat history of the agent (internal operation).""" - logger.debug("Agent %s: Resetting chat history", self._agent_id) + logger.debug(f"Agent {self._agent_id}: Resetting chat history") self._chat_history.clear() + self._pending_human_input_request = None + self._pending_tool_request = None + self._current_request_message = None - async def _emit_agent_delta_event( + @response_handler + async def handle_tool_approval_response( self, - ctx: WorkflowContext[Any, Any], - update: AgentRunResponseUpdate, + original_request: _MagenticHumanInterventionRequest, + response: _MagenticHumanInterventionReply, + context: WorkflowContext[_MagenticResponseMessage, AgentRunResponse], ) -> None: - contents = list(getattr(update, "contents", []) or []) - chunk = getattr(update, "text", None) - if not chunk: - chunk = "".join(getattr(item, "text", "") for item in contents if hasattr(item, "text")) - if chunk: - await ctx.add_event( - MagenticAgentDeltaEvent( - agent_id=self._agent_id, - text=chunk or None, - role=getattr(update, "role", None), + """Handle human response for tool approval and continue agent execution. + + When a human provides input in response to a tool approval request, + this handler processes the response based on the decision type: + + - APPROVE: Execute the tool call with the provided response text + - REJECT: Do not execute the tool, inform the agent of rejection + - GUIDANCE: Execute the tool call with the guidance text as input + + Args: + original_request: The original human intervention request + response: The human's response containing the decision and any text + context: The workflow context + """ + response_text = response.response_text or response.comments or "" + decision = response.decision + logger.info( + f"Agent {original_request.agent_id}: Received tool approval response " + f"(decision={decision.value}): {response_text[:50] if response_text else ''}" + ) + + # Get the pending tool request to extract call_id + pending_tool_request = self._pending_tool_request + self._pending_human_input_request = None + self._pending_tool_request = None + + # Handle REJECT decision - do not execute the tool call + if decision == MagenticHumanInterventionDecision.REJECT: + rejection_reason = response_text or "Tool call rejected by human" + logger.info(f"Agent {self._agent_id}: Tool call rejected: {rejection_reason}") + + if pending_tool_request is not None: + # Create a FunctionResultContent indicating rejection + function_result = FunctionResultContent( + call_id=pending_tool_request.function_call.call_id, + result=f"Tool call was rejected by human reviewer. Reason: {rejection_reason}", ) - ) - for item in contents: - if isinstance(item, FunctionCallContent): - await ctx.add_event( - MagenticAgentDeltaEvent( - agent_id=self._agent_id, - function_call_id=getattr(item, "call_id", None), - function_call_name=getattr(item, "name", None), - function_call_arguments=getattr(item, "arguments", None), - role=getattr(update, "role", None), - ) + result_msg = ChatMessage( + role=Role.USER, + contents=[function_result], ) - elif isinstance(item, FunctionResultContent): - await ctx.add_event( - MagenticAgentDeltaEvent( - agent_id=self._agent_id, - function_result_id=getattr(item, "call_id", None), - function_result=getattr(item, "result", None), - role=getattr(update, "role", None), - ) + self._chat_history.append(result_msg) + else: + # Fallback without pending tool request + rejection_msg = ChatMessage( + role=Role.USER, + text=f"Tool call '{original_request.prompt}' was rejected: {rejection_reason}", + author_name="human", ) + self._chat_history.append(rejection_msg) + + # Re-invoke the agent so it can adapt to the rejection + agent_response = await self._invoke_agent(context) + if agent_response is None: + return + self._chat_history.append(agent_response) + await context.send_message(_MagenticResponseMessage(body=agent_response)) + return + + # Handle APPROVE and GUIDANCE decisions - execute the tool call + if pending_tool_request is not None: + # Create a FunctionResultContent with the human's response + function_result = FunctionResultContent( + call_id=pending_tool_request.function_call.call_id, + result=response_text, + ) + # Add the function result as a message to continue the conversation + result_msg = ChatMessage( + role=Role.USER, + contents=[function_result], + ) + self._chat_history.append(result_msg) + + # Re-invoke the agent to continue execution + agent_response = await self._invoke_agent(context) + if agent_response is None: + # Agent is waiting for more human input + return + self._chat_history.append(agent_response) + await context.send_message(_MagenticResponseMessage(body=agent_response)) + else: + # Fallback: no pending tool request, just add as text message + logger.warning( + f"Agent {original_request.agent_id}: No pending tool request found for response, " + "using fallback text handling", + ) + human_response_msg = ChatMessage( + role=Role.USER, + text=f"Human response to '{original_request.prompt}': {response_text}", + author_name="human", + ) + self._chat_history.append(human_response_msg) + + # Create a response message indicating human input was received + agent_response_msg = ChatMessage( + role=Role.ASSISTANT, + text=f"Received human input for: {original_request.prompt}. Continuing with the task.", + author_name=original_request.agent_id, + ) + self._chat_history.append(agent_response_msg) + await context.send_message(_MagenticResponseMessage(body=agent_response_msg)) + + async def _emit_agent_delta_event( + self, + ctx: WorkflowContext[Any, Any], + update: AgentRunResponseUpdate, + ) -> None: + # Add metadata to identify this as an agent streaming update + props = update.additional_properties + if props is None: + props = {} + update.additional_properties = props + props["magentic_event_type"] = MAGENTIC_EVENT_TYPE_AGENT_DELTA + props["agent_id"] = self._agent_id + + # Emit AgentRunUpdateEvent with the agent response update + await ctx.add_event(AgentRunUpdateEvent(executor_id=self._agent_id, data=update)) async def _emit_agent_message_event( self, ctx: WorkflowContext[Any, Any], message: ChatMessage, ) -> None: - await ctx.add_event(MagenticAgentMessageEvent(agent_id=self._agent_id, message=message)) + # Agent message completion is already communicated via streaming updates + # No additional event needed + pass async def _invoke_agent( self, ctx: WorkflowContext[_MagenticResponseMessage, AgentRunResponse], - ) -> ChatMessage: - """Invoke the wrapped agent and return a response.""" + ) -> ChatMessage | None: + """Invoke the wrapped agent and return a response. + + This method streams the agent's response updates, collects them into an + AgentRunResponse, and handles any human input requests (tool approvals). + + Note: + If multiple user input requests are present in the agent's response, + only the first one is processed. A warning is logged and subsequent + requests are ignored. This is a current limitation of the single-request + pending state architecture. + + Returns: + ChatMessage with the agent's response, or None if waiting for human input. + """ logger.debug(f"Agent {self._agent_id}: Running with {len(self._chat_history)} messages") updates: list[AgentRunResponseUpdate] = [] @@ -1789,6 +2013,46 @@ async def _invoke_agent( run_result: AgentRunResponse = AgentRunResponse.from_agent_run_response_updates(updates) + # Handle human input requests (tool approval) - process one at a time + if run_result.user_input_requests: + if len(run_result.user_input_requests) > 1: + logger.warning( + f"Agent {self._agent_id}: Multiple user input requests received " + f"({len(run_result.user_input_requests)}), processing only the first one" + ) + + user_input_request = run_result.user_input_requests[0] + + # Build a prompt from the request based on its type + prompt: str + context_text: str | None = None + + if isinstance(user_input_request, FunctionApprovalRequestContent): + fn_call = user_input_request.function_call + prompt = f"Approve function call: {fn_call.name}" + if fn_call.arguments: + context_text = f"Arguments: {fn_call.arguments}" + else: + # Fallback for unknown request types + request_type = type(user_input_request).__name__ + prompt = f"Agent {self._agent_id} requires human input ({request_type})" + logger.warning(f"Agent {self._agent_id}: Unrecognized user input request type: {request_type}") + + # Store the original FunctionApprovalRequestContent for later use + self._pending_tool_request = user_input_request + + # Create and send the human intervention request for tool approval + request = _MagenticHumanInterventionRequest( + kind=MagenticHumanInterventionKind.TOOL_APPROVAL, + agent_id=self._agent_id, + prompt=prompt, + context=context_text, + conversation_snapshot=list(self._chat_history[-5:]), + ) + self._pending_human_input_request = request + await ctx.request_info(request, _MagenticHumanInterventionReply) + return None # Signal that we're waiting for human input + messages: list[ChatMessage] | None = None with contextlib.suppress(Exception): messages = list(run_result.messages) # type: ignore[assignment] @@ -1796,7 +2060,7 @@ async def _invoke_agent( last: ChatMessage = messages[-1] author = last.author_name or self._agent_id role: Role = last.role if last.role else Role.ASSISTANT - text = last.text or str(last) + text = last.text or "" msg = ChatMessage(role=role, text=text, author_name=author) await self._emit_agent_message_event(ctx, msg) return msg @@ -1876,6 +2140,7 @@ def __init__(self) -> None: self._manager: MagenticManagerBase | None = None self._enable_plan_review: bool = False self._checkpoint_storage: CheckpointStorage | None = None + self._enable_stall_intervention: bool = False def participants(self, **participants: AgentProtocol | Executor) -> Self: """Add participant agents or executors to the Magentic workflow. @@ -1902,7 +2167,7 @@ def participants(self, **participants: AgentProtocol | Executor) -> Self: .participants( researcher=research_agent, writer=writing_agent, coder=coding_agent, reviewer=review_agent ) - .with_standard_manager(chat_client=client) + .with_standard_manager(agent=manager_agent) .build() ) @@ -1918,9 +2183,9 @@ def with_plan_review(self, enable: bool = True) -> "MagenticBuilder": """Enable or disable human-in-the-loop plan review before task execution. When enabled, the workflow will pause after the manager generates the initial - plan and emit a _MagenticPlanReviewRequest event. A human reviewer can then - approve, request revisions, or reject the plan. The workflow continues only - after approval. + plan and emit a MagenticHumanInterventionRequest event with kind=PLAN_REVIEW. + A human reviewer can then approve, request revisions, or reject the plan. + The workflow continues only after approval. This is useful for: - High-stakes tasks requiring human oversight @@ -1941,26 +2206,90 @@ def with_plan_review(self, enable: bool = True) -> "MagenticBuilder": workflow = ( MagenticBuilder() .participants(agent1=agent1) - .with_standard_manager(chat_client=client) + .with_standard_manager(agent=manager_agent) .with_plan_review(enable=True) .build() ) # During execution, handle plan review async for event in workflow.run_stream("task"): - if isinstance(event, _MagenticPlanReviewRequest): - # Review plan and respond - reply = _MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) - await workflow.send(reply) + if isinstance(event, RequestInfoEvent): + request = event.data + if isinstance(request, MagenticHumanInterventionRequest): + if request.kind == MagenticHumanInterventionKind.PLAN_REVIEW: + # Review plan and respond + reply = MagenticHumanInterventionReply(decision=MagenticHumanInterventionDecision.APPROVE) + await workflow.send_responses({event.request_id: reply}) See Also: - - :class:`_MagenticPlanReviewRequest`: Event emitted for review - - :class:`_MagenticPlanReviewReply`: Response to send back - - :class:`MagenticPlanReviewDecision`: Approve/Revise/Reject options + - :class:`MagenticHumanInterventionRequest`: Event emitted for review + - :class:`MagenticHumanInterventionReply`: Response to send back + - :class:`MagenticHumanInterventionDecision`: APPROVE/REVISE options """ self._enable_plan_review = enable return self + def with_human_input_on_stall(self, enable: bool = True) -> "MagenticBuilder": + """Enable human intervention when the workflow detects a stall. + + When enabled, instead of automatically replanning when the workflow detects + that agents are not making progress or are stuck in a loop, the workflow will + pause and emit a MagenticStallInterventionRequest event. A human can then + decide to continue, trigger replanning, or provide guidance. + + This is useful for: + - Workflows where automatic replanning may not resolve the issue + - Scenarios requiring human judgment about workflow direction + - Debugging stuck workflows with human insight + - Complex tasks where human guidance can help agents get back on track + + When stall detection triggers (based on max_stall_count), instead of calling + _reset_and_replan automatically, the workflow will: + 1. Emit a MagenticHumanInterventionRequest with kind=STALL + 2. Wait for human response via send_responses_streaming + 3. Take action based on the human's decision (continue, replan, or guidance) + + Args: + enable: Whether to enable stall intervention (default True) + + Returns: + Self for method chaining + + Usage: + + .. code-block:: python + + workflow = ( + MagenticBuilder() + .participants(agent1=agent1) + .with_standard_manager(agent=manager_agent, max_stall_count=3) + .with_human_input_on_stall(enable=True) + .build() + ) + + # During execution, handle human intervention requests + async for event in workflow.run_stream("task"): + if isinstance(event, RequestInfoEvent): + if event.request_type is MagenticHumanInterventionRequest: + request = event.data + if request.kind == MagenticHumanInterventionKind.STALL: + print(f"Workflow stalled: {request.stall_reason}") + reply = MagenticHumanInterventionReply( + decision=MagenticHumanInterventionDecision.GUIDANCE, + comments="Focus on completing the current step first", + ) + responses = {event.request_id: reply} + async for ev in workflow.send_responses_streaming(responses): + ... + + See Also: + - :class:`MagenticHumanInterventionRequest`: Unified request type + - :class:`MagenticHumanInterventionDecision`: Decision options + - :meth:`with_standard_manager`: Configure max_stall_count for stall detection + """ + self._enable_stall_intervention = enable + return self + def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "MagenticBuilder": """Enable workflow state persistence using the provided checkpoint storage. @@ -1985,7 +2314,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Magentic workflow = ( MagenticBuilder() .participants(agent1=agent1) - .with_standard_manager(chat_client=client) + .with_standard_manager(agent=manager_agent) .with_checkpointing(storage) .build() ) @@ -2012,9 +2341,8 @@ def with_standard_manager( manager: MagenticManagerBase | None = None, *, # Constructor args for StandardMagenticManager when manager is not provided - chat_client: ChatClientProtocol | None = None, + agent: AgentProtocol | None = None, task_ledger: _MagenticTaskLedger | None = None, - instructions: str | None = None, # Prompt overrides task_ledger_facts_prompt: str | None = None, task_ledger_plan_prompt: str | None = None, @@ -2035,18 +2363,18 @@ def with_standard_manager( 1. **Provide existing manager**: Pass a pre-configured manager instance (custom or standard) for full control over behavior - 2. **Auto-create standard manager**: Pass chat_client and options to automatically - create a StandardMagenticManager with specified configuration + 2. **Auto-create with agent**: Pass an agent to automatically create a + StandardMagenticManager that uses the agent's configured instructions and + options (temperature, seed, etc.) Args: manager: Pre-configured manager instance (StandardMagenticManager or custom MagenticManagerBase subclass). If provided, all other arguments are ignored. - chat_client: LLM chat client for generating plans and decisions. Required if - manager is not provided. + agent: Agent instance for generating plans and decisions. The agent's + configured instructions and options (temperature, seed, etc.) will be + applied. task_ledger: Optional custom task ledger implementation for specialized prompting or structured output requirements - instructions: System instructions prepended to all manager prompts to guide - behavior and set expectations task_ledger_facts_prompt: Custom prompt template for extracting facts from task description task_ledger_plan_prompt: Custom prompt template for generating initial plan @@ -2071,25 +2399,30 @@ def with_standard_manager( Self for method chaining Raises: - ValueError: If manager is None and chat_client is also None + ValueError: If manager is None and agent is not provided. - Usage with auto-created manager: + Usage with agent (recommended): .. code-block:: python - from azure.ai.projects.aio import AIProjectClient + from agent_framework import ChatAgent, ChatOptions + from agent_framework.openai import OpenAIChatClient - project_client = AIProjectClient.from_connection_string(...) - chat_client = project_client.inference.get_chat_completions_client() + # Configure manager agent with specific options and instructions + manager_agent = ChatAgent( + name="Coordinator", + chat_client=OpenAIChatClient(model_id="gpt-4o"), + chat_options=ChatOptions(temperature=0.3, seed=42), + instructions="Be concise and focus on accuracy", + ) workflow = ( MagenticBuilder() .participants(agent1=agent1, agent2=agent2) .with_standard_manager( - chat_client=chat_client, + agent=manager_agent, max_round_count=20, max_stall_count=3, - instructions="Be concise and focus on accuracy", ) .build() ) @@ -2115,7 +2448,7 @@ async def plan(self, context: MagenticContext) -> ChatMessage: MagenticBuilder() .participants(coder=coder_agent, reviewer=reviewer_agent) .with_standard_manager( - chat_client=chat_client, + agent=manager_agent, task_ledger_plan_prompt="Create a detailed step-by-step plan...", progress_ledger_prompt="Assess progress and decide next action...", max_stall_count=2, @@ -2128,20 +2461,18 @@ async def plan(self, context: MagenticContext) -> ChatMessage: - Custom managers can implement alternative selection strategies - Prompt templates support Jinja2-style variable substitution - Stall detection helps prevent infinite loops in stuck scenarios + - The agent's instructions are used as system instructions for all manager prompts """ if manager is not None: self._manager = manager return self - if chat_client is None: - raise ValueError( - "chat_client is required when manager is not provided: with_standard_manager(chat_client=...)" - ) + if agent is None: + raise ValueError("agent is required when manager is not provided: with_standard_manager(agent=...)") self._manager = StandardMagenticManager( - chat_client=chat_client, + agent=agent, task_ledger=task_ledger, - instructions=instructions, task_ledger_facts_prompt=task_ledger_facts_prompt, task_ledger_plan_prompt=task_ledger_plan_prompt, task_ledger_full_prompt=task_ledger_full_prompt, @@ -2163,7 +2494,7 @@ def build(self) -> Workflow: if self._manager is None: raise ValueError("No manager configured. Call with_standard_manager(...) before build().") - logger.info("Building Magentic workflow with %d participants", len(self._participants)) + logger.info(f"Building Magentic workflow with {len(self._participants)} participants") # Create participant descriptions participant_descriptions: dict[str, str] = {} @@ -2173,12 +2504,14 @@ def build(self) -> Workflow: # Type narrowing: we already checked self._manager is not None above manager: MagenticManagerBase = self._manager # type: ignore[assignment] + enable_stall_intervention = self._enable_stall_intervention def _orchestrator_factory(wiring: _GroupChatConfig) -> Executor: return MagenticOrchestratorExecutor( manager=manager, participants=participant_descriptions, require_plan_signoff=self._enable_plan_review, + enable_stall_intervention=enable_stall_intervention, executor_id="magentic_orchestrator", ) @@ -2352,21 +2685,22 @@ async def _validate_checkpoint_participants( return # At this point, checkpoint is guaranteed to be WorkflowCheckpoint - executor_states: dict[str, Any] = checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}) + executor_states = cast(dict[str, Any], checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {})) orchestrator_id = getattr(orchestrator, "id", "") - orchestrator_state = executor_states.get(orchestrator_id) + orchestrator_state = cast(Any, executor_states.get(orchestrator_id)) if orchestrator_state is None: - orchestrator_state = executor_states.get("magentic_orchestrator") + orchestrator_state = cast(Any, executor_states.get("magentic_orchestrator")) if not isinstance(orchestrator_state, dict): return - context_payload = orchestrator_state.get("magentic_context") + orchestrator_state_dict = cast(dict[str, Any], orchestrator_state) + context_payload = cast(Any, orchestrator_state_dict.get("magentic_context")) if not isinstance(context_payload, dict): return context_dict = cast(dict[str, Any], context_payload) - restored_participants = context_dict.get("participant_descriptions") + restored_participants = cast(Any, context_dict.get("participant_descriptions")) if not isinstance(restored_participants, dict): return @@ -2435,6 +2769,13 @@ def __getattr__(self, name: str) -> Any: # endregion Magentic Workflow -# Public aliases for types needed by users implementing custom plan review handlers -MagenticPlanReviewRequest = _MagenticPlanReviewRequest -MagenticPlanReviewReply = _MagenticPlanReviewReply +# Public aliases for unified human intervention types +MagenticHumanInterventionRequest = _MagenticHumanInterventionRequest +MagenticHumanInterventionReply = _MagenticHumanInterventionReply + +# Backward compatibility aliases (deprecated) +# Old aliases - point to unified types for compatibility +MagenticHumanInputRequest = _MagenticHumanInterventionRequest # type: ignore +MagenticStallInterventionRequest = _MagenticHumanInterventionRequest # type: ignore +MagenticStallInterventionReply = _MagenticHumanInterventionReply # type: ignore +MagenticStallInterventionDecision = MagenticHumanInterventionDecision # type: ignore diff --git a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py index 4b17dda4143..9da726faf47 100644 --- a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py +++ b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py @@ -186,6 +186,10 @@ def is_registered(self, name: str) -> bool: """Check if a participant is registered.""" return name in self._participant_entry_ids + def is_participant_registered(self, name: str) -> bool: + """Check if a participant is registered (alias for is_registered for compatibility).""" + return self.is_registered(name) + def all_participants(self) -> set[str]: """Get all registered participant names.""" return set(self._participant_entry_ids.keys()) diff --git a/python/packages/core/agent_framework/_workflows/_validation.py b/python/packages/core/agent_framework/_workflows/_validation.py index d6a246a3ebb..88e37a121aa 100644 --- a/python/packages/core/agent_framework/_workflows/_validation.py +++ b/python/packages/core/agent_framework/_workflows/_validation.py @@ -149,9 +149,9 @@ def validate_workflow( # check only when there is at least one edge group defined. if self._edges: # Only evaluate when the workflow defines edges edge_executor_ids: set[str] = set() - for _e in self._edges: - edge_executor_ids.add(_e.source_id) - edge_executor_ids.add(_e.target_id) + for e in self._edges: + edge_executor_ids.add(e.source_id) + edge_executor_ids.add(e.target_id) if start_executor_id not in edge_executor_ids: raise GraphConnectivityError( f"Start executor '{start_executor_id}' is not present in the workflow graph" diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index a14542b2a6f..eb22d7c330b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -370,6 +370,10 @@ async def _run_workflow_with_tracing( span.add_event(OtelAttr.WORKFLOW_COMPLETED) except Exception as exc: + # Drain any pending events (for example, ExecutorFailedEvent) before yielding WorkflowFailedEvent + for event in await self._runner.context.drain_events(): + yield event + # Surface structured failure details before propagating exception details = WorkflowErrorDetails.from_exception(exc) with _framework_event_origin(): diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index dcf6715d62e..9719ce164aa 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -287,6 +287,9 @@ def __init__( self._runner_context = runner_context self._shared_state = shared_state + # Track messages sent via send_message() for ExecutorCompletedEvent + self._sent_messages: list[Any] = [] + # Store trace contexts and source span IDs for linking (supporting multiple sources) self._trace_contexts = trace_contexts or [] self._source_span_ids = source_span_ids or [] @@ -313,6 +316,9 @@ async def send_message(self, message: T_Out, target_id: str | None = None) -> No # Create Message wrapper msg = Message(data=message, source_id=self._executor_id, target_id=target_id) + # Track sent message for ExecutorCompletedEvent + self._sent_messages.append(message) + # Inject current trace context if tracing enabled if OBSERVABILITY_SETTINGS.ENABLED and span and span.is_recording(): # type: ignore[name-defined] trace_context: dict[str, str] = {} @@ -410,6 +416,14 @@ def shared_state(self) -> SharedState: """Get the shared state.""" return self._shared_state + def get_sent_messages(self) -> list[Any]: + """Get all messages sent via send_message() during this handler execution. + + Returns: + A list of messages that were sent to downstream executors. + """ + return self._sent_messages.copy() + @deprecated( "Override `on_checkpoint_save()` methods instead. " "For cross-executor state sharing, use set_shared_state() instead. " @@ -448,7 +462,7 @@ async def get_executor_state(self) -> dict[str, Any] | None: if not isinstance(existing_states, dict): raise ValueError("Existing executor states in shared state is not a dictionary.") - return existing_states.get(self._executor_id) + return existing_states.get(self._executor_id) # type: ignore def is_streaming(self) -> bool: """Check if the workflow is running in streaming mode. diff --git a/python/packages/core/agent_framework/a2a/__init__.py b/python/packages/core/agent_framework/a2a/__init__.py index 84a857ce589..f06ee08a0b1 100644 --- a/python/packages/core/agent_framework/a2a/__init__.py +++ b/python/packages/core/agent_framework/a2a/__init__.py @@ -3,20 +3,20 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_a2a" -PACKAGE_EXTRA = "a2a" +IMPORT_PATH = "agent_framework_a2a" +PACKAGE_NAME = "agent-framework-a2a" _IMPORTS = ["__version__", "A2AAgent"] def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/a2a/__init__.pyi b/python/packages/core/agent_framework/a2a/__init__.pyi index 09e3c94b573..8121fc0eb73 100644 --- a/python/packages/core/agent_framework/a2a/__init__.pyi +++ b/python/packages/core/agent_framework/a2a/__init__.pyi @@ -1,5 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_a2a import A2AAgent, __version__ +from agent_framework_a2a import ( + A2AAgent, + __version__, +) -__all__ = ["A2AAgent", "__version__"] +__all__ = [ + "A2AAgent", + "__version__", +] diff --git a/python/packages/core/agent_framework/ag_ui/__init__.py b/python/packages/core/agent_framework/ag_ui/__init__.py index c5569ed7a92..941a586d30a 100644 --- a/python/packages/core/agent_framework/ag_ui/__init__.py +++ b/python/packages/core/agent_framework/ag_ui/__init__.py @@ -3,8 +3,8 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_ag_ui" -PACKAGE_EXTRA = "ag-ui" +IMPORT_PATH = "agent_framework_ag_ui" +PACKAGE_NAME = "agent-framework-ag-ui" _IMPORTS = [ "__version__", "AgentFrameworkAgent", @@ -23,12 +23,12 @@ def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/anthropic/__init__.py b/python/packages/core/agent_framework/anthropic/__init__.py index bff9c278e5f..2f4decc1eb8 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.py +++ b/python/packages/core/agent_framework/anthropic/__init__.py @@ -3,20 +3,20 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_anthropic" -PACKAGE_EXTRA = "anthropic" +IMPORT_PATH = "agent_framework_anthropic" +PACKAGE_NAME = "agent-framework-anthropic" _IMPORTS = ["__version__", "AnthropicClient"] def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/anthropic/__init__.pyi b/python/packages/core/agent_framework/anthropic/__init__.pyi index dead0816f92..a86586b98fb 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.pyi +++ b/python/packages/core/agent_framework/anthropic/__init__.pyi @@ -1,5 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_anthropic import AnthropicClient, __version__ +from agent_framework_anthropic import ( + AnthropicClient, + __version__, +) -__all__ = ["AnthropicClient", "__version__"] +__all__ = [ + "AnthropicClient", + "__version__", +] diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 09670188ee9..7990361c97a 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -1,36 +1,35 @@ # Copyright (c) Microsoft. All rights reserved. - import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { - "AgentCallbackContext": ("agent_framework_azurefunctions", "azurefunctions"), - "AgentFunctionApp": ("agent_framework_azurefunctions", "azurefunctions"), - "AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"), - "AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"), - "AzureAIClient": ("agent_framework_azure_ai", "azure-ai"), - "AzureAISearchContextProvider": ("agent_framework_aisearch", "aisearch"), - "AzureAISearchSettings": ("agent_framework_aisearch", "aisearch"), - "AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"), - "AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "core"), - "AzureAISettings": ("agent_framework_azure_ai", "azure-ai"), - "AzureOpenAISettings": ("agent_framework.azure._shared", "core"), - "AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "core"), - "DurableAIAgent": ("agent_framework_azurefunctions", "azurefunctions"), - "get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "core"), + "AgentCallbackContext": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"), + "AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"), + "AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"), + "AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureAIClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), + "AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), + "AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "agent-framework-core"), + "AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "agent-framework-core"), + "AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "agent-framework-core"), + "AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"), + "DurableAIAgent": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"), + "get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"), } def __getattr__(name: str) -> Any: if name in _IMPORTS: - package_name, package_extra = _IMPORTS[name] + import_path, package_name = _IMPORTS[name] try: - return getattr(importlib.import_module(package_name), name) + return getattr(importlib.import_module(import_path), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"please use `pip install agent-framework-{package_extra}`, " - "or update your requirements.txt or pyproject.toml file." + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." ) from exc raise AttributeError(f"Module `azure` has no attribute {name}.") diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index aba582b5b5e..add9ea11304 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework_azure_ai import AzureAIAgentClient, AzureAIClient, AzureAISettings +from agent_framework_azure_ai_search import AzureAISearchContextProvider, AzureAISearchSettings from agent_framework_azurefunctions import ( AgentCallbackContext, AgentFunctionApp, @@ -20,6 +21,8 @@ __all__ = [ "AgentResponseCallbackProtocol", "AzureAIAgentClient", "AzureAIClient", + "AzureAISearchContextProvider", + "AzureAISearchSettings", "AzureAISettings", "AzureOpenAIAssistantsClient", "AzureOpenAIChatClient", diff --git a/python/packages/core/agent_framework/azure/_assistants_client.py b/python/packages/core/agent_framework/azure/_assistants_client.py index f0b70066dfb..58d2dbe3093 100644 --- a/python/packages/core/agent_framework/azure/_assistants_client.py +++ b/python/packages/core/agent_framework/azure/_assistants_client.py @@ -27,6 +27,7 @@ def __init__( deployment_name: str | None = None, assistant_id: str | None = None, assistant_name: str | None = None, + assistant_description: str | None = None, thread_id: str | None = None, api_key: str | None = None, endpoint: str | None = None, @@ -49,6 +50,7 @@ def __init__( assistant_id: The ID of an Azure OpenAI assistant to use. If not provided, a new assistant will be created (and deleted after the request). assistant_name: The name to use when creating new assistants. + assistant_description: The description to use when creating new assistants. thread_id: Default thread ID to use for conversations. Can be overridden by conversation_id property when making a request. If not provided, a new thread will be created (and deleted after the request). @@ -155,6 +157,7 @@ def __init__( model_id=azure_openai_settings.chat_deployment_name, assistant_id=assistant_id, assistant_name=assistant_name, + assistant_description=assistant_description, thread_id=thread_id, async_client=async_client, # type: ignore[reportArgumentType] default_headers=default_headers, diff --git a/python/packages/core/agent_framework/chatkit/__init__.py b/python/packages/core/agent_framework/chatkit/__init__.py index 163e6b412d3..024454be5cb 100644 --- a/python/packages/core/agent_framework/chatkit/__init__.py +++ b/python/packages/core/agent_framework/chatkit/__init__.py @@ -3,20 +3,20 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_chatkit" -PACKAGE_EXTRA = "chatkit" +IMPORT_PATH = "agent_framework_chatkit" +PACKAGE_NAME = "agent-framework-chatkit" _IMPORTS = ["__version__", "ThreadItemConverter", "simple_to_agent_input", "stream_agent_response"] def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/chatkit/__init__.pyi b/python/packages/core/agent_framework/chatkit/__init__.pyi index 9bd90e638d4..2fba862a1b3 100644 --- a/python/packages/core/agent_framework/chatkit/__init__.pyi +++ b/python/packages/core/agent_framework/chatkit/__init__.pyi @@ -7,4 +7,9 @@ from agent_framework_chatkit import ( stream_agent_response, ) -__all__ = ["ThreadItemConverter", "__version__", "simple_to_agent_input", "stream_agent_response"] +__all__ = [ + "ThreadItemConverter", + "__version__", + "simple_to_agent_input", + "stream_agent_response", +] diff --git a/python/packages/core/agent_framework/devui/__init__.py b/python/packages/core/agent_framework/devui/__init__.py index b34a19f50f6..3e3312f10c8 100644 --- a/python/packages/core/agent_framework/devui/__init__.py +++ b/python/packages/core/agent_framework/devui/__init__.py @@ -3,8 +3,8 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_devui" -PACKAGE_EXTRA = "devui" +IMPORT_PATH = "agent_framework_devui" +PACKAGE_NAME = "agent-framework-devui" _IMPORTS = [ "AgentFrameworkRequest", "DevServer", @@ -22,12 +22,12 @@ def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/devui/__init__.pyi b/python/packages/core/agent_framework/devui/__init__.pyi index 6bf73053496..3c1cac827fa 100644 --- a/python/packages/core/agent_framework/devui/__init__.pyi +++ b/python/packages/core/agent_framework/devui/__init__.pyi @@ -1,4 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. + from agent_framework_devui import ( AgentFrameworkRequest, DevServer, diff --git a/python/packages/core/agent_framework/mem0/__init__.py b/python/packages/core/agent_framework/mem0/__init__.py index c90316389ae..dd28c5459b6 100644 --- a/python/packages/core/agent_framework/mem0/__init__.py +++ b/python/packages/core/agent_framework/mem0/__init__.py @@ -3,20 +3,20 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_mem0" -PACKAGE_EXTRA = "mem0" +IMPORT_PATH = "agent_framework_mem0" +PACKAGE_NAME = "agent-framework-mem0" _IMPORTS = ["__version__", "Mem0Provider"] def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/mem0/__init__.pyi b/python/packages/core/agent_framework/mem0/__init__.pyi index 53fb1720f22..29250a02adc 100644 --- a/python/packages/core/agent_framework/mem0/__init__.pyi +++ b/python/packages/core/agent_framework/mem0/__init__.pyi @@ -1,5 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_mem0 import Mem0Provider, __version__ +from agent_framework_mem0 import ( + Mem0Provider, + __version__, +) -__all__ = ["Mem0Provider", "__version__"] +__all__ = [ + "Mem0Provider", + "__version__", +] diff --git a/python/packages/core/agent_framework/microsoft/__init__.py b/python/packages/core/agent_framework/microsoft/__init__.py index 106facf54c5..689faf6fd7c 100644 --- a/python/packages/core/agent_framework/microsoft/__init__.py +++ b/python/packages/core/agent_framework/microsoft/__init__.py @@ -3,35 +3,33 @@ import importlib from typing import Any -_IMPORTS: dict[str, tuple[str, list[str]]] = { - "CopilotStudioAgent": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]), - "__version__": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]), - "acquire_token": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]), - # Purview (Graph Data Security & Governance) integration exports - "PurviewPolicyMiddleware": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewChatPolicyMiddleware": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewSettings": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewAppLocation": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewLocationType": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewAuthenticationError": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewPaymentRequiredError": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewRateLimitError": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewRequestError": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "PurviewServiceError": ("agent_framework_purview", ["microsoft-purview", "purview"]), - "CacheProvider": ("agent_framework_purview", ["microsoft-purview", "purview"]), +_IMPORTS: dict[str, tuple[str, str]] = { + "CopilotStudioAgent": ("agent_framework_copilotstudio", "agent-framework-copilotstudio"), + "__version__": ("agent_framework_copilotstudio", "agent-framework-copilotstudio"), + "acquire_token": ("agent_framework_copilotstudio", "agent-framework-copilotstudio"), + "PurviewPolicyMiddleware": ("agent_framework_purview", "agent-framework-purview"), + "PurviewChatPolicyMiddleware": ("agent_framework_purview", "agent-framework-purview"), + "PurviewSettings": ("agent_framework_purview", "agent-framework-purview"), + "PurviewAppLocation": ("agent_framework_purview", "agent-framework-purview"), + "PurviewLocationType": ("agent_framework_purview", "agent-framework-purview"), + "PurviewAuthenticationError": ("agent_framework_purview", "agent-framework-purview"), + "PurviewPaymentRequiredError": ("agent_framework_purview", "agent-framework-purview"), + "PurviewRateLimitError": ("agent_framework_purview", "agent-framework-purview"), + "PurviewRequestError": ("agent_framework_purview", "agent-framework-purview"), + "PurviewServiceError": ("agent_framework_purview", "agent-framework-purview"), + "CacheProvider": ("agent_framework_purview", "agent-framework-purview"), } def __getattr__(name: str) -> Any: if name in _IMPORTS: - package_name, package_extra = _IMPORTS[name] + import_path, package_name = _IMPORTS[name] try: - return getattr(importlib.import_module(package_name), name) + return getattr(importlib.import_module(import_path), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The {' or '.join(package_extra)} extra is not installed, " - f"please use `pip install agent-framework-{package_extra[0]}`, " - "or update your requirements.txt or pyproject.toml file." + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." ) from exc raise AttributeError(f"Module `microsoft` has no attribute {name}.") diff --git a/python/packages/core/agent_framework/microsoft/__init__.pyi b/python/packages/core/agent_framework/microsoft/__init__.pyi index f3c5c27a0e5..2d2ec42d4d6 100644 --- a/python/packages/core/agent_framework/microsoft/__init__.pyi +++ b/python/packages/core/agent_framework/microsoft/__init__.pyi @@ -1,6 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_copilotstudio import CopilotStudioAgent, __version__, acquire_token +from agent_framework_copilotstudio import ( + CopilotStudioAgent, + __version__, + acquire_token, +) from agent_framework_purview import ( CacheProvider, PurviewAppLocation, diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 1543b532513..7ec778b8d21 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: # pragma: no cover from azure.core.credentials import TokenCredential - from opentelemetry.sdk._logs._internal.export import LogExporter + from opentelemetry.sdk._logs.export import LogRecordExporter from opentelemetry.sdk.metrics.export import MetricExporter from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace.export import SpanExporter @@ -259,13 +259,13 @@ def __str__(self) -> str: # region Telemetry utils -def _get_otlp_exporters(endpoints: list[str]) -> list["LogExporter | SpanExporter | MetricExporter"]: +def _get_otlp_exporters(endpoints: list[str]) -> list["LogRecordExporter | SpanExporter | MetricExporter"]: """Create standard OTLP Exporters for the supplied endpoints.""" from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - exporters: list["LogExporter | SpanExporter | MetricExporter"] = [] + exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] = [] for endpoint in endpoints: exporters.append(OTLPLogExporter(endpoint=endpoint)) @@ -277,7 +277,7 @@ def _get_otlp_exporters(endpoints: list[str]) -> list["LogExporter | SpanExporte def _get_azure_monitor_exporters( connection_strings: list[str], credential: "TokenCredential | None" = None, -) -> list["LogExporter | SpanExporter | MetricExporter"]: +) -> list["LogRecordExporter | SpanExporter | MetricExporter"]: """Create Azure Monitor Exporters, based on the connection strings and optionally the credential.""" try: from azure.monitor.opentelemetry.exporter import ( @@ -291,7 +291,7 @@ def _get_azure_monitor_exporters( "Install it with: pip install azure-monitor-opentelemetry-exporter>=1.0.0b41" ) from e - exporters: list["LogExporter | SpanExporter | MetricExporter"] = [] + exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] = [] for conn_string in connection_strings: exporters.append(AzureMonitorLogExporter(connection_string=conn_string, credential=credential)) exporters.append(AzureMonitorTraceExporter(connection_string=conn_string, credential=credential)) @@ -303,7 +303,7 @@ def get_exporters( otlp_endpoints: list[str] | None = None, connection_strings: list[str] | None = None, credential: "TokenCredential | None" = None, -) -> list["LogExporter | SpanExporter | MetricExporter"]: +) -> list["LogRecordExporter | SpanExporter | MetricExporter"]: """Add additional exporters to the existing configuration. If you supply exporters, those will be added to the relevant providers directly. @@ -319,7 +319,7 @@ def get_exporters( connection_strings: A list of Azure Monitor connection strings. Default is None. credential: The credential to use for Azure Monitor Entra ID authentication. Default is None. """ - new_exporters: list["LogExporter | SpanExporter | MetricExporter"] = [] + new_exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] = [] if otlp_endpoints: new_exporters.extend(_get_otlp_exporters(endpoints=otlp_endpoints)) @@ -429,7 +429,7 @@ def resource(self, value: "Resource") -> None: def _configure( self, credential: "TokenCredential | None" = None, - additional_exporters: list["LogExporter | SpanExporter | MetricExporter"] | None = None, + additional_exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] | None = None, ) -> None: """Configure application-wide observability based on the settings. @@ -444,7 +444,7 @@ def _configure( if not self.ENABLED or self._executed_setup: return - exporters: list["LogExporter | SpanExporter | MetricExporter"] = additional_exporters or [] + exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] = additional_exporters or [] if self.otlp_endpoint: exporters.extend( _get_otlp_exporters( @@ -489,12 +489,11 @@ def check_connection_string_already_configured(self, connection_string: str) -> else [self.applicationinsights_connection_string] ) - def _configure_providers(self, exporters: list["LogExporter | MetricExporter | SpanExporter"]) -> None: + def _configure_providers(self, exporters: list["LogRecordExporter | MetricExporter | SpanExporter"]) -> None: """Configure tracing, logging, events and metrics with the provided exporters.""" from opentelemetry._logs import set_logger_provider from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler - from opentelemetry.sdk._logs._internal.export import LogExporter - from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader from opentelemetry.sdk.metrics.view import DropAggregation, View @@ -518,11 +517,11 @@ def _configure_providers(self, exporters: list["LogExporter | MetricExporter | S logger_provider = LoggerProvider(resource=self.resource) should_add_console_exporter = True for exporter in exporters: - if isinstance(exporter, LogExporter): + if isinstance(exporter, LogRecordExporter): logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) should_add_console_exporter = False if should_add_console_exporter: - from opentelemetry.sdk._logs._internal.export import ConsoleLogExporter + from opentelemetry.sdk._logs.export import ConsoleLogExporter logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) @@ -667,7 +666,7 @@ def setup_observability( otlp_endpoint: str | list[str] | None = None, applicationinsights_connection_string: str | list[str] | None = None, credential: "TokenCredential | None" = None, - exporters: list["LogExporter | SpanExporter | MetricExporter"] | None = None, + exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] | None = None, vs_code_extension_port: int | None = None, ) -> None: """Setup observability for the application with OpenTelemetry. @@ -749,7 +748,7 @@ def setup_observability( OBSERVABILITY_SETTINGS.vs_code_extension_port = vs_code_extension_port # Create exporters, after checking if they are already configured through the env. - new_exporters: list["LogExporter | SpanExporter | MetricExporter"] = exporters or [] + new_exporters: list["LogRecordExporter | SpanExporter | MetricExporter"] = exporters or [] if otlp_endpoint: if isinstance(otlp_endpoint, str): otlp_endpoint = [otlp_endpoint] @@ -1626,7 +1625,7 @@ def create_processing_span( links.append(trace.Link(span_context)) return workflow_tracer().start_as_current_span( - OtelAttr.EXECUTOR_PROCESS_SPAN, + f"{OtelAttr.EXECUTOR_PROCESS_SPAN} {executor_id}", kind=trace.SpanKind.INTERNAL, attributes={ OtelAttr.EXECUTOR_ID: executor_id, @@ -1699,7 +1698,7 @@ def create_edge_group_processing_span( pass return workflow_tracer().start_as_current_span( - OtelAttr.EDGE_GROUP_PROCESS_SPAN, + f"{OtelAttr.EDGE_GROUP_PROCESS_SPAN} {edge_group_type}", kind=trace.SpanKind.INTERNAL, attributes=attributes, links=links, diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 6255a6b8db7..0f3bb3de634 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -64,6 +64,7 @@ def __init__( model_id: str | None = None, assistant_id: str | None = None, assistant_name: str | None = None, + assistant_description: str | None = None, thread_id: str | None = None, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, @@ -82,6 +83,7 @@ def __init__( assistant_id: The ID of an OpenAI assistant to use. If not provided, a new assistant will be created (and deleted after the request). assistant_name: The name to use when creating new assistants. + assistant_description: The description to use when creating new assistants. thread_id: Default thread ID to use for conversations. Can be overridden by conversation_id property when making a request. If not provided, a new thread will be created (and deleted after the request). @@ -147,6 +149,7 @@ def __init__( ) self.assistant_id: str | None = assistant_id self.assistant_name: str | None = assistant_name + self.assistant_description: str | None = assistant_description self.thread_id: str | None = thread_id self._should_delete_assistant: bool = False @@ -220,7 +223,11 @@ async def _get_assistant_id_or_create(self) -> str: raise ServiceInitializationError("Parameter 'model_id' is required for assistant creation.") client = await self.ensure_client() - created_assistant = await client.beta.assistants.create(name=self.assistant_name, model=self.model_id) + created_assistant = await client.beta.assistants.create( + model=self.model_id, + description=self.assistant_description, + name=self.assistant_name, + ) self.assistant_id = created_assistant.id self._should_delete_assistant = True @@ -516,13 +523,16 @@ def _convert_function_results_to_tool_output( return run_id, tool_outputs - def _update_agent_name(self, agent_name: str | None) -> None: + def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: """Update the agent name in the chat client. Args: agent_name: The new name for the agent. + description: The new description for the agent. """ # This is a no-op in the base class, but can be overridden by subclasses # to update the agent name in the client. if agent_name and not self.assistant_name: - object.__setattr__(self, "assistant_name", agent_name) + self.assistant_name = agent_name + if description and not self.assistant_description: + self.assistant_description = description diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 02e0743e1b2..73605fadef6 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -3,7 +3,7 @@ import json import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence -from datetime import datetime +from datetime import datetime, timezone from itertools import chain from typing import Any, TypeVar @@ -214,7 +214,7 @@ def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOpti messages.append(ChatMessage(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, - created_at=datetime.fromtimestamp(response.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + created_at=datetime.fromtimestamp(response.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), usage_details=self._usage_details_from_openai(response.usage) if response.usage else None, messages=messages, model_id=response.model, @@ -249,7 +249,7 @@ def _create_chat_response_update( if text_content := self._parse_text_from_choice(choice): contents.append(text_content) return ChatResponseUpdate( - created_at=datetime.fromtimestamp(chunk.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, role=Role.ASSISTANT, model_id=chunk.model, @@ -369,8 +369,6 @@ def _openai_chat_message_parser(self, message: ChatMessage) -> list[dict[str, An args: dict[str, Any] = { "role": message.role.value if isinstance(message.role, Role) else message.role, } - if message.additional_properties: - args["metadata"] = message.additional_properties match content: case FunctionCallContent(): if all_messages and "tool_calls" in all_messages[-1]: diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 447333447ab..d1857fb4fe9 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence -from datetime import datetime +from datetime import datetime, timezone from itertools import chain from typing import Any, TypeVar @@ -90,19 +90,22 @@ async def _inner_get_response( **kwargs: Any, ) -> ChatResponse: client = await self.ensure_client() - run_options = await self.prepare_options(messages, chat_options) + run_options = await self.prepare_options(messages, chat_options, **kwargs) + response_format = run_options.pop("response_format", None) + text_config = run_options.pop("text", None) + text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config) + if text_config: + run_options["text"] = text_config try: - response_format = run_options.pop("response_format", None) - if not response_format: + if not text_format: response = await client.responses.create( stream=False, **run_options, ) chat_options.conversation_id = self.get_conversation_id(response, chat_options.store) return self._create_response_content(response, chat_options=chat_options) - # create call does not support response_format, so we need to handle it via parse call parsed_response: ParsedResponse[BaseModel] = await client.responses.parse( - text_format=response_format, + text_format=text_format, stream=False, **run_options, ) @@ -132,11 +135,15 @@ async def _inner_get_streaming_response( **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: client = await self.ensure_client() - run_options = await self.prepare_options(messages, chat_options) + run_options = await self.prepare_options(messages, chat_options, **kwargs) function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name) + response_format = run_options.pop("response_format", None) + text_config = run_options.pop("text", None) + text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config) + if text_config: + run_options["text"] = text_config try: - response_format = run_options.pop("response_format", None) - if not response_format: + if not text_format: response = await client.responses.create( stream=True, **run_options, @@ -147,9 +154,8 @@ async def _inner_get_streaming_response( ) yield update return - # create call does not support response_format, so we need to handle it via stream call async with client.responses.stream( - text_format=response_format, + text_format=text_format, **run_options, ) as response: async for chunk in response: @@ -173,11 +179,76 @@ async def _inner_get_streaming_response( inner_exception=ex, ) from ex + def _prepare_text_config( + self, + *, + response_format: Any, + text_config: MutableMapping[str, Any] | None, + ) -> tuple[type[BaseModel] | None, dict[str, Any] | None]: + """Normalize response_format into Responses text configuration and parse target.""" + prepared_text = dict(text_config) if isinstance(text_config, MutableMapping) else None + if text_config is not None and not isinstance(text_config, MutableMapping): + raise ServiceInvalidRequestError("text must be a mapping when provided.") + + if response_format is None: + return None, prepared_text + + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + if prepared_text and "format" in prepared_text: + raise ServiceInvalidRequestError("response_format cannot be combined with explicit text.format.") + return response_format, prepared_text + + if isinstance(response_format, Mapping): + format_config = self._convert_response_format(response_format) + if prepared_text is None: + prepared_text = {} + elif "format" in prepared_text and prepared_text["format"] != format_config: + raise ServiceInvalidRequestError("Conflicting response_format definitions detected.") + prepared_text["format"] = format_config + return None, prepared_text + + raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.") + + def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[str, Any]: + """Convert Chat style response_format into Responses text format config.""" + if "format" in response_format and isinstance(response_format["format"], Mapping): + return dict(response_format["format"]) + + format_type = response_format.get("type") + if format_type == "json_schema": + schema_section = response_format.get("json_schema", response_format) + if not isinstance(schema_section, Mapping): + raise ServiceInvalidRequestError("json_schema response_format must be a mapping.") + schema = schema_section.get("schema") + if schema is None: + raise ServiceInvalidRequestError("json_schema response_format requires a schema.") + name = ( + schema_section.get("name") + or schema_section.get("title") + or (schema.get("title") if isinstance(schema, Mapping) else None) + or "response" + ) + format_config: dict[str, Any] = { + "type": "json_schema", + "name": name, + "schema": schema, + } + if "strict" in schema_section: + format_config["strict"] = schema_section["strict"] + if "description" in schema_section and schema_section["description"] is not None: + format_config["description"] = schema_section["description"] + return format_config + + if format_type in {"json_object", "text"}: + return {"type": format_type} + + raise ServiceInvalidRequestError("Unsupported response_format provided for Responses client.") + def get_conversation_id( self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None ) -> str | None: """Get the conversation ID from the response if store is True.""" - return response.id if store else None + return None if store is False else response.id # region Prep methods @@ -315,9 +386,17 @@ def get_mcp_tool(self, tool: HostedMCPTool) -> Any: return mcp async def prepare_options( - self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + self, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, ) -> dict[str, Any]: """Take ChatOptions and create the specific options for Responses API.""" + conversation_id = kwargs.pop("conversation_id", None) + + if conversation_id: + chat_options.conversation_id = conversation_id + run_options: dict[str, Any] = chat_options.to_dict( exclude={ "type", @@ -366,8 +445,6 @@ async def prepare_options( for key, value in additional_properties.items(): if value is not None: run_options[key] = value - if "store" not in run_options: - run_options["store"] = False if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1: run_options["tool_choice"] = tool_choice["mode"] return run_options @@ -412,8 +489,6 @@ def _openai_chat_message_parser( args: dict[str, Any] = { "role": message.role.value if isinstance(message.role, Role) else message.role, } - if message.additional_properties: - args["metadata"] = message.additional_properties for content in message.contents: match content: case TextReasoningContent(): @@ -520,9 +595,8 @@ def _openai_content_parser( args: dict[str, Any] = { "call_id": content.call_id, "type": "function_call_output", + "output": prepare_function_call_results(content.result), } - if content.result: - args["output"] = prepare_function_call_results(content.result) return args case FunctionApprovalRequestContent(): return { @@ -741,14 +815,19 @@ def _create_response_content( response_message = ChatMessage(role="assistant", contents=contents) args: dict[str, Any] = { "response_id": response.id, - "created_at": datetime.fromtimestamp(response.created_at).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "created_at": datetime.fromtimestamp(response.created_at, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ), "messages": response_message, "model_id": response.model, "additional_properties": metadata, "raw_representation": response, } - if chat_options.store: - args["conversation_id"] = self.get_conversation_id(response, chat_options.store) + + conversation_id = self.get_conversation_id(response, chat_options.store) + + if conversation_id: + args["conversation_id"] = conversation_id if response.usage and (usage_details := self._usage_details_from_openai(response.usage)): args["usage_details"] = usage_details if structured_response: diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/core/agent_framework/openai/_shared.py index 20c719e09eb..511c1f3379d 100644 --- a/python/packages/core/agent_framework/openai/_shared.py +++ b/python/packages/core/agent_framework/openai/_shared.py @@ -46,9 +46,7 @@ OPTION_TYPE = Union[ChatOptions, dict[str, Any]] -__all__ = [ - "OpenAISettings", -] +__all__ = ["OpenAISettings"] def _check_openai_version_for_callable_api_key() -> None: diff --git a/python/packages/core/agent_framework/redis/__init__.py b/python/packages/core/agent_framework/redis/__init__.py index 25ccf4e8821..85594715cbe 100644 --- a/python/packages/core/agent_framework/redis/__init__.py +++ b/python/packages/core/agent_framework/redis/__init__.py @@ -3,20 +3,20 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_redis" -PACKAGE_EXTRA = "redis" +IMPORT_PATH = "agent_framework_redis" +PACKAGE_NAME = "agent-framework-redis" _IMPORTS = ["__version__", "RedisProvider", "RedisChatMessageStore"] def __getattr__(name: str) -> Any: if name in _IMPORTS: try: - return getattr(importlib.import_module(PACKAGE_NAME), name) + return getattr(importlib.import_module(IMPORT_PATH), name) except ModuleNotFoundError as exc: raise ModuleNotFoundError( - f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" ) from exc - raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/redis/__init__.pyi b/python/packages/core/agent_framework/redis/__init__.pyi index ea9ea935041..6cce35db762 100644 --- a/python/packages/core/agent_framework/redis/__init__.pyi +++ b/python/packages/core/agent_framework/redis/__init__.pyi @@ -1,5 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_redis import RedisChatMessageStore, RedisProvider, __version__ +from agent_framework_redis import ( + RedisChatMessageStore, + RedisProvider, + __version__, +) -__all__ = ["RedisChatMessageStore", "RedisProvider", "__version__"] +__all__ = [ + "RedisChatMessageStore", + "RedisProvider", + "__version__", +] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 0b6b7c16fbc..55028cea5aa 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -28,9 +28,9 @@ dependencies = [ "pydantic>=2,<3", "pydantic-settings>=2,<3", # telemetry - "opentelemetry-api>=1.24", - "opentelemetry-sdk>=1.24", - "opentelemetry-exporter-otlp-proto-grpc>=1.36.0", + "opentelemetry-api>=1.39.0", + "opentelemetry-sdk>=1.39.0", + "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", "opentelemetry-semantic-conventions-ai>=0.4.13", # connectors and functions "openai>=1.99.0", @@ -43,7 +43,7 @@ dependencies = [ all = [ "agent-framework-a2a", "agent-framework-ag-ui", - "agent-framework-aisearch", + "agent-framework-azure-ai-search", "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-azurefunctions", diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 865e2ef484b..264ff1929bc 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -88,10 +88,128 @@ def test_mcp_call_tool_result_to_ai_contents(): assert ai_contents[1].media_type == "image/png" +def test_mcp_call_tool_result_with_meta_error(): + """Test conversion from MCP tool result with _meta field containing isError=True.""" + # Create a mock CallToolResult with _meta field containing error information + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Error occurred")], + _meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"}, + ) + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + assert isinstance(ai_contents[0], TextContent) + assert ai_contents[0].text == "Error occurred" + + # Check that _meta data is merged into additional_properties + assert ai_contents[0].additional_properties is not None + assert ai_contents[0].additional_properties["isError"] is True + assert ai_contents[0].additional_properties["errorCode"] == "TOOL_ERROR" + assert ai_contents[0].additional_properties["errorMessage"] == "Tool execution failed" + + +def test_mcp_call_tool_result_with_meta_arbitrary_data(): + """Test conversion from MCP tool result with _meta field containing arbitrary metadata. + + Note: The _meta field is optional and can contain any structure that a specific + MCP server chooses to provide. This test uses example metadata to verify that + whatever is provided gets preserved in additional_properties. + """ + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Success result")], + _meta={ + "serverVersion": "2.1.0", + "executionId": "exec_abc123", + "metrics": {"responseTime": 1.25, "memoryUsed": "64MB"}, + "source": "example-mcp-server", + "customField": "arbitrary_value", + }, + ) + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + assert isinstance(ai_contents[0], TextContent) + assert ai_contents[0].text == "Success result" + + # Check that _meta data is preserved in additional_properties + props = ai_contents[0].additional_properties + assert props is not None + assert props["serverVersion"] == "2.1.0" + assert props["executionId"] == "exec_abc123" + assert props["metrics"] == {"responseTime": 1.25, "memoryUsed": "64MB"} + assert props["source"] == "example-mcp-server" + assert props["customField"] == "arbitrary_value" + + +def test_mcp_call_tool_result_with_meta_merging_existing_properties(): + """Test that _meta data merges correctly with existing additional_properties.""" + # Create content with existing additional_properties + text_content = types.TextContent(type="text", text="Test content") + mcp_result = types.CallToolResult(content=[text_content], _meta={"newField": "newValue", "isError": False}) + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + content = ai_contents[0] + + # Check that _meta data is present in additional_properties + assert content.additional_properties is not None + assert content.additional_properties["newField"] == "newValue" + assert content.additional_properties["isError"] is False + + +def test_mcp_call_tool_result_with_meta_none(): + """Test that missing _meta field is handled gracefully.""" + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="No meta test")]) + # No _meta field set + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + assert len(ai_contents) == 1 + assert isinstance(ai_contents[0], TextContent) + assert ai_contents[0].text == "No meta test" + + # Should handle gracefully when no _meta field exists + # additional_properties may be None or empty dict + props = ai_contents[0].additional_properties + assert props is None or props == {} + + +def test_mcp_call_tool_result_regression_successful_workflow(): + """Regression test to ensure existing successful workflows remain unchanged.""" + # Test the original successful workflow still works + mcp_result = types.CallToolResult( + content=[ + types.TextContent(type="text", text="Success message"), + types.ImageContent(type="image", data="data:image/jpeg;base64,abc123", mimeType="image/jpeg"), + ] + ) + + ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result) + + # Verify basic conversion still works correctly + assert len(ai_contents) == 2 + + text_content = ai_contents[0] + assert isinstance(text_content, TextContent) + assert text_content.text == "Success message" + + image_content = ai_contents[1] + assert isinstance(image_content, DataContent) + assert image_content.uri == "data:image/jpeg;base64,abc123" + assert image_content.media_type == "image/jpeg" + + # Should have no additional_properties when no _meta field + assert text_content.additional_properties is None or text_content.additional_properties == {} + assert image_content.additional_properties is None or image_content.additional_properties == {} + + def test_mcp_content_types_to_ai_content_text(): """Test conversion of MCP text content to AI content.""" mcp_content = types.TextContent(type="text", text="Sample text") - ai_content = _mcp_type_to_ai_content(mcp_content) + ai_content = _mcp_type_to_ai_content(mcp_content)[0] assert isinstance(ai_content, TextContent) assert ai_content.text == "Sample text" @@ -101,7 +219,7 @@ def test_mcp_content_types_to_ai_content_text(): def test_mcp_content_types_to_ai_content_image(): """Test conversion of MCP image content to AI content.""" mcp_content = types.ImageContent(type="image", data="data:image/jpeg;base64,abc", mimeType="image/jpeg") - ai_content = _mcp_type_to_ai_content(mcp_content) + ai_content = _mcp_type_to_ai_content(mcp_content)[0] assert isinstance(ai_content, DataContent) assert ai_content.uri == "data:image/jpeg;base64,abc" @@ -112,7 +230,7 @@ def test_mcp_content_types_to_ai_content_image(): def test_mcp_content_types_to_ai_content_audio(): """Test conversion of MCP audio content to AI content.""" mcp_content = types.AudioContent(type="audio", data="data:audio/wav;base64,def", mimeType="audio/wav") - ai_content = _mcp_type_to_ai_content(mcp_content) + ai_content = _mcp_type_to_ai_content(mcp_content)[0] assert isinstance(ai_content, DataContent) assert ai_content.uri == "data:audio/wav;base64,def" @@ -128,7 +246,7 @@ def test_mcp_content_types_to_ai_content_resource_link(): name="test_resource", mimeType="application/json", ) - ai_content = _mcp_type_to_ai_content(mcp_content) + ai_content = _mcp_type_to_ai_content(mcp_content)[0] assert isinstance(ai_content, UriContent) assert ai_content.uri == "https://example.com/resource" @@ -144,7 +262,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text(): text="Embedded text content", ) mcp_content = types.EmbeddedResource(type="resource", resource=text_resource) - ai_content = _mcp_type_to_ai_content(mcp_content) + ai_content = _mcp_type_to_ai_content(mcp_content)[0] assert isinstance(ai_content, TextContent) assert ai_content.text == "Embedded text content" @@ -160,7 +278,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob(): blob="data:application/octet-stream;base64,dGVzdCBkYXRh", ) mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource) - ai_content = _mcp_type_to_ai_content(mcp_content) + ai_content = _mcp_type_to_ai_content(mcp_content)[0] assert isinstance(ai_content, DataContent) assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh" @@ -327,6 +445,36 @@ def test_get_input_model_from_mcp_tool_with_ref_schema(): assert dumped == {"params": {"customer_id": 251}} +def test_get_input_model_from_mcp_tool_with_simple_array(): + """Test array with simple items schema (items schema should be preserved in json_schema_extra).""" + tool = types.Tool( + name="simple_array_tool", + description="Tool with simple array", + inputSchema={ + "type": "object", + "properties": { + "tags": { + "type": "array", + "description": "List of tags", + "items": {"type": "string"}, # Simple string array + } + }, + "required": ["tags"], + }, + ) + model = _get_input_model_from_mcp_tool(tool) + + # Create an instance + instance = model(tags=["tag1", "tag2", "tag3"]) + assert instance.tags == ["tag1", "tag2", "tag3"] + + # Verify JSON schema still preserves items for simple types + json_schema = model.model_json_schema() + tags_property = json_schema["properties"]["tags"] + assert "items" in tags_property + assert tags_property["items"]["type"] == "string" + + def test_get_input_model_from_mcp_prompt(): """Test creation of input model from MCP prompt.""" prompt = types.Prompt( @@ -440,6 +588,58 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: assert server.functions[0].name == "test_prompt" +async def test_mcp_tool_call_tool_with_meta_integration(): + """Test that call_tool method properly integrates with enhanced metadata extraction.""" + + class TestServer(MCPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="test_tool", + description="Test tool", + inputSchema={ + "type": "object", + "properties": {"param": {"type": "string"}}, + "required": ["param"], + }, + ) + ] + ) + ) + + # Create a CallToolResult with _meta field + tool_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Tool executed with metadata")], + _meta={"executionTime": 1.5, "cost": {"usd": 0.002}, "isError": False, "toolVersion": "1.2.3"}, + ) + + self.session.call_tool = AsyncMock(return_value=tool_result) + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return None + + server = TestServer(name="test_server") + async with server: + await server.load_tools() + func = server.functions[0] + result = await func.invoke(param="test_value") + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "Tool executed with metadata" + + # Verify that _meta data is present in additional_properties + props = result[0].additional_properties + assert props is not None + assert props["executionTime"] == 1.5 + assert props["cost"] == {"usd": 0.002} + assert props["isError"] is False + assert props["toolVersion"] == "1.2.3" + + async def test_local_mcp_server_function_execution(): """Test function execution through MCP server.""" diff --git a/python/packages/core/tests/core/test_threads.py b/python/packages/core/tests/core/test_threads.py index 80495017891..492ed115197 100644 --- a/python/packages/core/tests/core/test_threads.py +++ b/python/packages/core/tests/core/test_threads.py @@ -384,6 +384,18 @@ def test_init_empty(self) -> None: assert len(state.messages) == 0 + def test_init_none(self) -> None: + """Test ChatMessageStoreState initialization with None messages.""" + state = ChatMessageStoreState(messages=None) + + assert len(state.messages) == 0 + + def test_init_no_messages_arg(self) -> None: + """Test ChatMessageStoreState initialization without messages argument.""" + state = ChatMessageStoreState() + + assert len(state.messages) == 0 + class TestThreadState: """Test cases for AgentThreadState class.""" @@ -415,3 +427,22 @@ def test_init_defaults(self) -> None: assert state.service_thread_id is None assert state.chat_message_store_state is None + + def test_init_with_chat_message_store_state_no_messages(self) -> None: + """Test AgentThreadState initialization with chat_message_store_state without messages field. + + This tests the scenario where a custom ChatMessageStore (like RedisChatMessageStore) + serializes its state without a 'messages' field, containing only configuration data + like thread_id, redis_url, etc. + """ + store_data: dict[str, Any] = { + "type": "redis_store_state", + "thread_id": "test_thread_123", + "redis_url": "redis://localhost:6379", + "key_prefix": "chat_messages", + } + state = AgentThreadState.from_dict({"chat_message_store_state": store_data}) + + assert state.service_thread_id is None + assert state.chat_message_store_state is not None + assert state.chat_message_store_state.messages == [] diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 38a3fe414ef..81242147d24 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2,6 +2,7 @@ import base64 from collections.abc import AsyncIterable +from datetime import datetime, timezone from typing import Any import pytest @@ -36,6 +37,7 @@ UsageContent, UsageDetails, ai_function, + prepare_function_call_results, ) from agent_framework.exceptions import AdditionItemMismatch, ContentError @@ -935,6 +937,52 @@ def test_agent_run_response_update_str_method(text_content: TextContent) -> None assert str(update) == "Test content" +def test_agent_run_response_update_created_at() -> None: + """Test that AgentRunResponseUpdate properly handles created_at timestamps.""" + # Test with a properly formatted UTC timestamp + utc_timestamp = "2024-12-01T00:31:30.000000Z" + update = AgentRunResponseUpdate( + contents=[TextContent(text="test")], + role=Role.ASSISTANT, + created_at=utc_timestamp, + ) + assert update.created_at == utc_timestamp + assert update.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Verify that we can generate a proper UTC timestamp + now_utc = datetime.now(tz=timezone.utc) + formatted_utc = now_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + update_with_now = AgentRunResponseUpdate( + contents=[TextContent(text="test")], + role=Role.ASSISTANT, + created_at=formatted_utc, + ) + assert update_with_now.created_at == formatted_utc + assert update_with_now.created_at.endswith("Z") + + +def test_agent_run_response_created_at() -> None: + """Test that AgentRunResponse properly handles created_at timestamps.""" + # Test with a properly formatted UTC timestamp + utc_timestamp = "2024-12-01T00:31:30.000000Z" + response = AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text="Hello")], + created_at=utc_timestamp, + ) + assert response.created_at == utc_timestamp + assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Verify that we can generate a proper UTC timestamp + now_utc = datetime.now(tz=timezone.utc) + formatted_utc = now_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + response_with_now = AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text="Hello")], + created_at=formatted_utc, + ) + assert response_with_now.created_at == formatted_utc + assert response_with_now.created_at.endswith("Z") + + # region ErrorContent @@ -1965,3 +2013,75 @@ def test_text_content_with_multiple_annotations_serialization(): assert reconstructed.annotations[0].title == "Citation 1" assert reconstructed.annotations[1].title == "Citation 2" assert all(isinstance(ann.annotated_regions[0], TextSpanRegion) for ann in reconstructed.annotations) + + +# region prepare_function_call_results with Pydantic models + + +class WeatherResult(BaseModel): + """A Pydantic model for testing.""" + + temperature: float + condition: str + + +class NestedModel(BaseModel): + """A Pydantic model with nested structure.""" + + name: str + weather: WeatherResult + + +def test_prepare_function_call_results_pydantic_model(): + """Test that Pydantic BaseModel subclasses are properly serialized using model_dump().""" + result = WeatherResult(temperature=22.5, condition="sunny") + json_result = prepare_function_call_results(result) + + # The result should be a valid JSON string + assert isinstance(json_result, str) + assert '"temperature": 22.5' in json_result or '"temperature":22.5' in json_result + assert '"condition": "sunny"' in json_result or '"condition":"sunny"' in json_result + + +def test_prepare_function_call_results_pydantic_model_in_list(): + """Test that lists containing Pydantic models are properly serialized.""" + results = [ + WeatherResult(temperature=20.0, condition="cloudy"), + WeatherResult(temperature=25.0, condition="sunny"), + ] + json_result = prepare_function_call_results(results) + + # The result should be a valid JSON string representing a list + assert isinstance(json_result, str) + assert json_result.startswith("[") + assert json_result.endswith("]") + assert "cloudy" in json_result + assert "sunny" in json_result + + +def test_prepare_function_call_results_pydantic_model_in_dict(): + """Test that dicts containing Pydantic models are properly serialized.""" + results = { + "current": WeatherResult(temperature=22.0, condition="partly cloudy"), + "forecast": WeatherResult(temperature=24.0, condition="sunny"), + } + json_result = prepare_function_call_results(results) + + # The result should be a valid JSON string representing a dict + assert isinstance(json_result, str) + assert "current" in json_result + assert "forecast" in json_result + assert "partly cloudy" in json_result + assert "sunny" in json_result + + +def test_prepare_function_call_results_nested_pydantic_model(): + """Test that nested Pydantic models are properly serialized.""" + result = NestedModel(name="Seattle", weather=WeatherResult(temperature=18.0, condition="rainy")) + json_result = prepare_function_call_results(result) + + # The result should be a valid JSON string + assert isinstance(json_result, str) + assert "Seattle" in json_result + assert "rainy" in json_result + assert "18.0" in json_result or "18" in json_result diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 90947dd4371..b9c32b14b57 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -872,36 +872,36 @@ def test_openai_assistants_client_convert_function_results_to_tool_output_mismat assert tool_outputs[0].get("tool_call_id") == "call-456" -def test_openai_assistants_client_update_agent_name(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name method updates assistant_name when not already set.""" +def test_openai_assistants_client_update_agent_name_and_description(mock_async_openai: MagicMock) -> None: + """Test _update_agent_name_and_description method updates assistant_name when not already set.""" # Test updating agent name when assistant_name is None chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) # Call the private method to update agent name - chat_client._update_agent_name("New Assistant Name") # type: ignore + chat_client._update_agent_name_and_description("New Assistant Name") # type: ignore assert chat_client.assistant_name == "New Assistant Name" -def test_openai_assistants_client_update_agent_name_existing(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name method doesn't override existing assistant_name.""" +def test_openai_assistants_client_update_agent_name_and_description_existing(mock_async_openai: MagicMock) -> None: + """Test _update_agent_name_and_description method doesn't override existing assistant_name.""" # Test that existing assistant_name is not overridden chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name="Existing Assistant") # Call the private method to update agent name - chat_client._update_agent_name("New Assistant Name") # type: ignore + chat_client._update_agent_name_and_description("New Assistant Name") # type: ignore # Should keep the existing name assert chat_client.assistant_name == "Existing Assistant" -def test_openai_assistants_client_update_agent_name_none(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name method with None agent_name parameter.""" +def test_openai_assistants_client_update_agent_name_and_description_none(mock_async_openai: MagicMock) -> None: + """Test _update_agent_name_and_description method with None agent_name parameter.""" # Test that None agent_name doesn't change anything chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) # Call the private method with None - chat_client._update_agent_name(None) # type: ignore + chat_client._update_agent_name_and_description(None) # type: ignore # Should remain None assert chat_client.assistant_name is None diff --git a/python/packages/core/tests/openai/test_openai_chat_client_base.py b/python/packages/core/tests/openai/test_openai_chat_client_base.py index 86d41d95953..b146bad6130 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client_base.py +++ b/python/packages/core/tests/openai/test_openai_chat_client_base.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from copy import deepcopy +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -370,3 +371,75 @@ async def test_get_streaming_no_stream( messages=chat_history, ) ] + + +# region UTC Timestamp Tests + + +def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str]): + """Test that ChatResponse.created_at uses UTC timestamp, not local time. + + This is a regression test for the issue where created_at was using local time + but labeling it as UTC (with 'Z' suffix). + """ + from agent_framework import ChatOptions + + # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) + # This ensures we test that the timestamp is actually converted to UTC + utc_timestamp = 1733011890 + + mock_response = ChatCompletion( + id="test_id", + choices=[ + Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop") + ], + created=utc_timestamp, + model="test", + object="chat.completion", + ) + + client = OpenAIChatClient() + response = client._create_chat_response(mock_response, ChatOptions()) + + # Verify that created_at is correctly formatted as UTC + assert response.created_at is not None + assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Parse the timestamp and verify it matches UTC time + expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc) + expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert response.created_at == expected_formatted, ( + f"Expected UTC timestamp {expected_formatted}, got {response.created_at}" + ) + + +def test_chat_response_update_created_at_uses_utc(openai_unit_test_env: dict[str, str]): + """Test that ChatResponseUpdate.created_at uses UTC timestamp, not local time. + + This is a regression test for the issue where created_at was using local time + but labeling it as UTC (with 'Z' suffix). + """ + # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) + utc_timestamp = 1733011890 + + mock_chunk = ChatCompletionChunk( + id="test_id", + choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")], + created=utc_timestamp, + model="test", + object="chat.completion.chunk", + ) + + client = OpenAIChatClient() + response_update = client._create_chat_response_update(mock_chunk) + + # Verify that created_at is correctly formatted as UTC + assert response_update.created_at is not None + assert response_update.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Parse the timestamp and verify it matches UTC time + expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc) + expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert response_update.created_at == expected_formatted, ( + f"Expected UTC timestamp {expected_formatted}, got {response_update.created_at}" + ) diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 47009504395..f2f9004d552 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -3,6 +3,7 @@ import asyncio import base64 import os +from datetime import datetime, timezone from typing import Annotated from unittest.mock import MagicMock, patch @@ -684,6 +685,51 @@ def test_create_response_content_with_mcp_approval_request() -> None: assert req.function_call.additional_properties["server_label"] == "My_MCP" +def test_responses_client_created_at_uses_utc(openai_unit_test_env: dict[str, str]) -> None: + """Test that ChatResponse from responses client uses UTC timestamp. + + This is a regression test for the issue where created_at was using local time + but labeling it as UTC (with 'Z' suffix). + """ + client = OpenAIResponsesClient() + + # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) + utc_timestamp = 1733011890 + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = utc_timestamp + + mock_message_content = MagicMock() + mock_message_content.type = "output_text" + mock_message_content.text = "Test response" + mock_message_content.annotations = None + + mock_message_item = MagicMock() + mock_message_item.type = "message" + mock_message_item.content = [mock_message_content] + + mock_response.output = [mock_message_item] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + + # Verify that created_at is correctly formatted as UTC + assert response.created_at is not None + assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC" + + # Parse the timestamp and verify it matches UTC time + expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc) + expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert response.created_at == expected_formatted, ( + f"Expected UTC timestamp {expected_formatted}, got {response.created_at}" + ) + + def test_tools_to_response_tools_with_raw_image_generation() -> None: """Test that raw image_generation tool dict is handled correctly with parameter mapping.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") @@ -1423,12 +1469,12 @@ async def test_prepare_options_store_parameter_handling() -> None: chat_options = ChatOptions(store=None, conversation_id=None) options = await client.prepare_options(messages, chat_options) - assert options["store"] is False + assert "store" not in options assert "previous_response_id" not in options chat_options = ChatOptions() options = await client.prepare_options(messages, chat_options) - assert options["store"] is False + assert "store" not in options assert "previous_response_id" not in options diff --git a/python/packages/core/tests/workflow/test_agent_run_event_typing.py b/python/packages/core/tests/workflow/test_agent_run_event_typing.py new file mode 100644 index 00000000000..a89aa817a3e --- /dev/null +++ b/python/packages/core/tests/workflow/test_agent_run_event_typing.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for AgentRunEvent and AgentRunUpdateEvent type annotations.""" + +from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role +from agent_framework._workflows._events import AgentRunEvent, AgentRunUpdateEvent + + +def test_agent_run_event_data_type() -> None: + """Verify AgentRunEvent.data is typed as AgentRunResponse | None.""" + response = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Hello")]) + event = AgentRunEvent(executor_id="test", data=response) + + # This assignment should pass type checking without a cast + data: AgentRunResponse | None = event.data + assert data is not None + assert data.text == "Hello" + + +def test_agent_run_event_data_none() -> None: + """Verify AgentRunEvent.data can be None.""" + event = AgentRunEvent(executor_id="test") + + data: AgentRunResponse | None = event.data + assert data is None + + +def test_agent_run_update_event_data_type() -> None: + """Verify AgentRunUpdateEvent.data is typed as AgentRunResponseUpdate | None.""" + update = AgentRunResponseUpdate() + event = AgentRunUpdateEvent(executor_id="test", data=update) + + # This assignment should pass type checking without a cast + data: AgentRunResponseUpdate | None = event.data + assert data is not None + + +def test_agent_run_update_event_data_none() -> None: + """Verify AgentRunUpdateEvent.data can be None.""" + event = AgentRunUpdateEvent(executor_id="test") + + data: AgentRunResponseUpdate | None = event.data + assert data is None diff --git a/python/packages/core/tests/workflow/test_edge.py b/python/packages/core/tests/workflow/test_edge.py index 38d73484409..316cae7a393 100644 --- a/python/packages/core/tests/workflow/test_edge.py +++ b/python/packages/core/tests/workflow/test_edge.py @@ -321,12 +321,13 @@ async def test_single_edge_group_tracing_success(span_exporter) -> None: assert success is True spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process SingleEdgeGroup" assert span.attributes.get("edge_group.type") == "SingleEdgeGroup" assert span.attributes.get("edge_group.delivered") is True assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DELIVERED.value @@ -365,12 +366,13 @@ async def test_single_edge_group_tracing_condition_failure(span_exporter) -> Non assert success is True # Returns True but condition failed spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process SingleEdgeGroup" assert span.attributes.get("edge_group.type") == "SingleEdgeGroup" assert span.attributes.get("edge_group.delivered") is False assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE.value @@ -399,12 +401,13 @@ async def test_single_edge_group_tracing_type_mismatch(span_exporter) -> None: assert success is False spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process SingleEdgeGroup" assert span.attributes.get("edge_group.type") == "SingleEdgeGroup" assert span.attributes.get("edge_group.delivered") is False assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value @@ -432,12 +435,13 @@ async def test_single_edge_group_tracing_target_mismatch(span_exporter) -> None: assert success is False spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process SingleEdgeGroup" assert span.attributes.get("edge_group.type") == "SingleEdgeGroup" assert span.attributes.get("edge_group.delivered") is False assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value @@ -790,12 +794,13 @@ async def test_fan_out_edge_group_tracing_success(span_exporter) -> None: assert success is True spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process FanOutEdgeGroup" assert span.attributes.get("edge_group.type") == "FanOutEdgeGroup" assert span.attributes.get("edge_group.delivered") is True assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DELIVERED.value @@ -845,12 +850,13 @@ async def test_fan_out_edge_group_tracing_with_target(span_exporter) -> None: assert success is True spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process FanOutEdgeGroup" assert span.attributes.get("edge_group.type") == "FanOutEdgeGroup" assert span.attributes.get("edge_group.delivered") is True assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DELIVERED.value @@ -1012,12 +1018,13 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None: assert success is True spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process FanInEdgeGroup" assert span.attributes.get("edge_group.type") == "FanInEdgeGroup" assert span.attributes.get("edge_group.delivered") is True assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.BUFFERED.value @@ -1043,12 +1050,13 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None: assert success is True spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process FanInEdgeGroup" assert span.attributes.get("edge_group.type") == "FanInEdgeGroup" assert span.attributes.get("edge_group.delivered") is True assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DELIVERED.value @@ -1088,12 +1096,13 @@ async def test_fan_in_edge_group_tracing_type_mismatch(span_exporter) -> None: assert success is False spans = span_exporter.get_finished_spans() - edge_group_spans = [s for s in spans if s.name == "edge_group.process"] + edge_group_spans = [s for s in spans if s.attributes and s.attributes.get("edge_group.type") is not None] assert len(edge_group_spans) == 1 span = edge_group_spans[0] assert span.attributes is not None + assert span.name == "edge_group.process FanInEdgeGroup" assert span.attributes.get("edge_group.type") == "FanInEdgeGroup" assert span.attributes.get("edge_group.delivered") is False assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value diff --git a/python/packages/core/tests/workflow/test_executor.py b/python/packages/core/tests/workflow/test_executor.py index 952d6bab60e..3c5558ac305 100644 --- a/python/packages/core/tests/workflow/test_executor.py +++ b/python/packages/core/tests/workflow/test_executor.py @@ -2,7 +2,15 @@ import pytest -from agent_framework import Executor, Message, WorkflowContext, handler +from agent_framework import ( + Executor, + ExecutorCompletedEvent, + ExecutorInvokedEvent, + Message, + WorkflowBuilder, + WorkflowContext, + handler, +) def test_executor_without_id(): @@ -101,3 +109,155 @@ async def handle_integer(self, number: int, ctx: WorkflowContext[int]) -> None: assert int_handler._handler_spec["name"] == "handle_integer" # type: ignore assert int_handler._handler_spec["message_type"] is int # type: ignore assert int_handler._handler_spec["output_types"] == [int] # type: ignore + + +async def test_executor_invoked_event_contains_input_data(): + """Test that ExecutorInvokedEvent contains the input message data.""" + + class UpperCaseExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(text.upper()) + + class CollectorExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext) -> None: + pass + + upper = UpperCaseExecutor(id="upper") + collector = CollectorExecutor(id="collector") + + workflow = WorkflowBuilder().add_edge(upper, collector).set_start_executor(upper).build() + + events = await workflow.run("hello world") + invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)] + + assert len(invoked_events) == 2 + + # First invoked event should be for 'upper' executor with input "hello world" + upper_invoked = next(e for e in invoked_events if e.executor_id == "upper") + assert upper_invoked.data == "hello world" + + # Second invoked event should be for 'collector' executor with input "HELLO WORLD" + collector_invoked = next(e for e in invoked_events if e.executor_id == "collector") + assert collector_invoked.data == "HELLO WORLD" + + +async def test_executor_completed_event_contains_sent_messages(): + """Test that ExecutorCompletedEvent contains the messages sent via ctx.send_message().""" + + class MultiSenderExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{text}-first") + await ctx.send_message(f"{text}-second") + + class CollectorExecutor(Executor): + def __init__(self, id: str) -> None: + super().__init__(id=id) + self.received: list[str] = [] + + @handler + async def handle(self, text: str, ctx: WorkflowContext) -> None: + self.received.append(text) + + sender = MultiSenderExecutor(id="sender") + collector = CollectorExecutor(id="collector") + + workflow = WorkflowBuilder().add_edge(sender, collector).set_start_executor(sender).build() + + events = await workflow.run("hello") + completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)] + + # Sender should have completed with the sent messages + sender_completed = next(e for e in completed_events if e.executor_id == "sender") + assert sender_completed.data is not None + assert sender_completed.data == ["hello-first", "hello-second"] + + # Collector should have completed with no sent messages (None) + collector_completed_events = [e for e in completed_events if e.executor_id == "collector"] + # Collector is called twice (once per message from sender) + assert len(collector_completed_events) == 2 + for collector_completed in collector_completed_events: + assert collector_completed.data is None + + +async def test_executor_completed_event_none_when_no_messages_sent(): + """Test that ExecutorCompletedEvent.data is None when no messages are sent.""" + from typing_extensions import Never + + from agent_framework import WorkflowOutputEvent + + class YieldOnlyExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(text.upper()) + + executor = YieldOnlyExecutor(id="yielder") + workflow = WorkflowBuilder().set_start_executor(executor).build() + + events = await workflow.run("test") + completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)] + + assert len(completed_events) == 1 + assert completed_events[0].executor_id == "yielder" + assert completed_events[0].data is None + + # Verify the output was still yielded correctly + output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)] + assert len(output_events) == 1 + assert output_events[0].data == "TEST" + + +async def test_executor_events_with_complex_message_types(): + """Test that executor events correctly capture complex message types.""" + from dataclasses import dataclass + + @dataclass + class Request: + query: str + limit: int + + @dataclass + class Response: + results: list[str] + + class ProcessorExecutor(Executor): + @handler + async def handle(self, request: Request, ctx: WorkflowContext[Response]) -> None: + response = Response(results=[request.query.upper()] * request.limit) + await ctx.send_message(response) + + class CollectorExecutor(Executor): + @handler + async def handle(self, response: Response, ctx: WorkflowContext) -> None: + pass + + processor = ProcessorExecutor(id="processor") + collector = CollectorExecutor(id="collector") + + workflow = WorkflowBuilder().add_edge(processor, collector).set_start_executor(processor).build() + + input_request = Request(query="hello", limit=3) + events = await workflow.run(input_request) + + invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)] + completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)] + + # Check processor invoked event has the Request object + processor_invoked = next(e for e in invoked_events if e.executor_id == "processor") + assert isinstance(processor_invoked.data, Request) + assert processor_invoked.data.query == "hello" + assert processor_invoked.data.limit == 3 + + # Check processor completed event has the Response object + processor_completed = next(e for e in completed_events if e.executor_id == "processor") + assert processor_completed.data is not None + assert len(processor_completed.data) == 1 + assert isinstance(processor_completed.data[0], Response) + assert processor_completed.data[0].results == ["HELLO", "HELLO", "HELLO"] + + # Check collector invoked event has the Response object + collector_invoked = next(e for e in invoked_events if e.executor_id == "collector") + assert isinstance(collector_invoked.data, Response) + assert collector_invoked.data.results == ["HELLO", "HELLO", "HELLO"] diff --git a/python/packages/core/tests/workflow/test_group_chat.py b/python/packages/core/tests/workflow/test_group_chat.py index ab920d0663b..5d11e64c790 100644 --- a/python/packages/core/tests/workflow/test_group_chat.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -1,42 +1,51 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Callable -from typing import Any +from typing import Any, cast import pytest +from pydantic import BaseModel from agent_framework import ( + MAGENTIC_EVENT_TYPE_AGENT_DELTA, + MAGENTIC_EVENT_TYPE_ORCHESTRATOR, AgentRunResponse, AgentRunResponseUpdate, + AgentRunUpdateEvent, AgentThread, BaseAgent, ChatMessage, + Executor, GroupChatBuilder, GroupChatDirective, GroupChatStateSnapshot, - MagenticAgentMessageEvent, MagenticBuilder, MagenticContext, MagenticManagerBase, - MagenticOrchestratorMessageEvent, Role, TextContent, Workflow, + WorkflowContext, WorkflowOutputEvent, + handler, ) from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage from agent_framework._workflows._group_chat import ( GroupChatOrchestratorExecutor, + ManagerSelectionResponse, _default_orchestrator_factory, # type: ignore + _default_participant_factory, # type: ignore _GroupChatConfig, # type: ignore - _PromptBasedGroupChatManager, # type: ignore _SpeakerSelectorAdapter, # type: ignore + assemble_group_chat_workflow, ) from agent_framework._workflows._magentic import ( _MagenticProgressLedger, # type: ignore _MagenticProgressLedgerItem, # type: ignore _MagenticStartMessage, # type: ignore ) +from agent_framework._workflows._participant_utils import GroupChatParticipantSpec +from agent_framework._workflows._workflow_builder import WorkflowBuilder class StubAgent(BaseAgent): @@ -69,6 +78,73 @@ async def _stream() -> AsyncIterable[AgentRunResponseUpdate]: return _stream() +class StubManagerAgent(BaseAgent): + def __init__(self) -> None: + super().__init__(name="manager_agent", description="Stub manager") + self._call_count = 0 + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: # type: ignore[override] + if self._call_count == 0: + self._call_count += 1 + payload = {"selected_participant": "agent", "finish": False, "final_message": None} + return AgentRunResponse( + messages=[ + ChatMessage( + role=Role.ASSISTANT, + text='{"selected_participant": "agent", "finish": false}', + author_name=self.name, + ) + ], + value=payload, + ) + + payload = {"selected_participant": None, "finish": True, "final_message": "agent manager final"} + return AgentRunResponse( + messages=[ + ChatMessage( + role=Role.ASSISTANT, + text='{"finish": true, "final_message": "agent manager final"}', + author_name=self.name, + ) + ], + value=payload, + ) + + def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: # type: ignore[override] + if self._call_count == 0: + self._call_count += 1 + + async def _stream_initial() -> AsyncIterable[AgentRunResponseUpdate]: + yield AgentRunResponseUpdate( + contents=[TextContent(text='{"selected_participant": "agent", "finish": false}')], + role=Role.ASSISTANT, + author_name=self.name, + ) + + return _stream_initial() + + async def _stream_final() -> AsyncIterable[AgentRunResponseUpdate]: + yield AgentRunResponseUpdate( + contents=[TextContent(text='{"finish": true, "final_message": "agent manager final"}')], + role=Role.ASSISTANT, + author_name=self.name, + ) + + return _stream_final() + + def make_sequence_selector() -> Callable[[GroupChatStateSnapshot], Any]: state_counter = {"value": 0} @@ -122,6 +198,22 @@ async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatM return ChatMessage(role=Role.ASSISTANT, text="final", author_name="magentic_manager") +class PassthroughExecutor(Executor): + @handler + async def forward(self, message: Any, ctx: WorkflowContext[Any]) -> None: + await ctx.send_message(message) + + +class CountingWorkflowBuilder(WorkflowBuilder): + def __init__(self) -> None: + super().__init__() + self.start_calls = 0 + + def set_start_executor(self, executor: Any) -> "CountingWorkflowBuilder": + self.start_calls += 1 + return cast("CountingWorkflowBuilder", super().set_start_executor(executor)) + + async def test_group_chat_builder_basic_flow() -> None: selector = make_sequence_selector() alpha = StubAgent("alpha", "ack from alpha") @@ -129,21 +221,23 @@ async def test_group_chat_builder_basic_flow() -> None: workflow = ( GroupChatBuilder() - .select_speakers(selector, display_name="manager", final_message="done") + .set_select_speakers_func(selector, display_name="manager", final_message="done") .participants(alpha=alpha, beta=beta) .build() ) - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream("coordinate task"): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) assert len(outputs) == 1 - assert outputs[0].text == "done" - assert outputs[0].author_name == "manager" + assert len(outputs[0]) >= 1 + # The final message should be "done" from the manager + assert outputs[0][-1].text == "done" + assert outputs[0][-1].author_name == "manager" async def test_magentic_builder_returns_workflow_and_runs() -> None: @@ -155,25 +249,30 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: assert isinstance(workflow, Workflow) outputs: list[ChatMessage] = [] - orchestrator_events: list[MagenticOrchestratorMessageEvent] = [] - agent_events: list[MagenticAgentMessageEvent] = [] + orchestrator_event_count = 0 + agent_event_count = 0 start_message = _MagenticStartMessage.from_string("compose summary") async for event in workflow.run_stream(start_message): - if isinstance(event, MagenticOrchestratorMessageEvent): - orchestrator_events.append(event) - if isinstance(event, MagenticAgentMessageEvent): - agent_events.append(event) + if isinstance(event, AgentRunUpdateEvent): + props = event.data.additional_properties if event.data else None + event_type = props.get("magentic_event_type") if props else None + if event_type == MAGENTIC_EVENT_TYPE_ORCHESTRATOR: + orchestrator_event_count += 1 + elif event_type == MAGENTIC_EVENT_TYPE_AGENT_DELTA: + agent_event_count += 1 if isinstance(event, WorkflowOutputEvent): msg = event.data - if isinstance(msg, ChatMessage): - outputs.append(msg) + if isinstance(msg, list): + outputs.append(cast(list[ChatMessage], msg)) assert outputs, "Expected a final output message" - final = outputs[-1] + conversation = outputs[-1] + assert len(conversation) >= 1 + final = conversation[-1] assert final.text == "final" assert final.author_name == "magentic_manager" - assert orchestrator_events, "Expected orchestrator events to be emitted" - assert agent_events, "Expected agent message events to be emitted" + assert orchestrator_event_count > 0, "Expected orchestrator events to be emitted" + assert agent_event_count > 0, "Expected agent delta events to be emitted" async def test_group_chat_as_agent_accepts_conversation() -> None: @@ -183,7 +282,7 @@ async def test_group_chat_as_agent_accepts_conversation() -> None: workflow = ( GroupChatBuilder() - .select_speakers(selector, display_name="manager", final_message="done") + .set_select_speakers_func(selector, display_name="manager", final_message="done") .participants(alpha=alpha, beta=beta) .build() ) @@ -235,7 +334,7 @@ def test_build_without_participants_raises_error(self) -> None: def selector(state: GroupChatStateSnapshot) -> str | None: return None - builder = GroupChatBuilder().select_speakers(selector) + builder = GroupChatBuilder().set_select_speakers_func(selector) with pytest.raises(ValueError, match="participants must be configured before build"): builder.build() @@ -246,10 +345,10 @@ def test_duplicate_manager_configuration_raises_error(self) -> None: def selector(state: GroupChatStateSnapshot) -> str | None: return None - builder = GroupChatBuilder().select_speakers(selector) + builder = GroupChatBuilder().set_select_speakers_func(selector) with pytest.raises(ValueError, match="already has a manager configured"): - builder.select_speakers(selector) + builder.set_select_speakers_func(selector) def test_empty_participants_raises_error(self) -> None: """Test that empty participants list raises ValueError.""" @@ -257,7 +356,7 @@ def test_empty_participants_raises_error(self) -> None: def selector(state: GroupChatStateSnapshot) -> str | None: return None - builder = GroupChatBuilder().select_speakers(selector) + builder = GroupChatBuilder().set_select_speakers_func(selector) with pytest.raises(ValueError, match="participants cannot be empty"): builder.participants([]) @@ -270,7 +369,7 @@ def test_duplicate_participant_names_raises_error(self) -> None: def selector(state: GroupChatStateSnapshot) -> str | None: return None - builder = GroupChatBuilder().select_speakers(selector) + builder = GroupChatBuilder().set_select_speakers_func(selector) with pytest.raises(ValueError, match="Duplicate participant name 'test'"): builder.participants([agent1, agent2]) @@ -298,7 +397,7 @@ async def _stream() -> AsyncIterable[AgentRunResponseUpdate]: def selector(state: GroupChatStateSnapshot) -> str | None: return None - builder = GroupChatBuilder().select_speakers(selector) + builder = GroupChatBuilder().set_select_speakers_func(selector) with pytest.raises(ValueError, match="must define a non-empty 'name' attribute"): builder.participants([agent]) @@ -310,11 +409,53 @@ def test_empty_participant_name_raises_error(self) -> None: def selector(state: GroupChatStateSnapshot) -> str | None: return None - builder = GroupChatBuilder().select_speakers(selector) + builder = GroupChatBuilder().set_select_speakers_func(selector) with pytest.raises(ValueError, match="participant names must be non-empty strings"): builder.participants({"": agent}) + def test_assemble_group_chat_respects_existing_start_executor(self) -> None: + """Ensure assemble_group_chat_workflow does not override preconfigured start executor.""" + + async def manager(_: GroupChatStateSnapshot) -> GroupChatDirective: + return GroupChatDirective(finish=True) + + builder = CountingWorkflowBuilder() + entry = PassthroughExecutor(id="entry") + builder = builder.set_start_executor(entry) + + participant = PassthroughExecutor(id="participant") + participant_spec = GroupChatParticipantSpec( + name="participant", + participant=participant, + description="participant", + ) + + wiring = _GroupChatConfig( + manager=manager, + manager_participant=None, + manager_name="manager", + participants={"participant": participant_spec}, + max_rounds=None, + termination_condition=None, + participant_aliases={}, + participant_executors={"participant": participant}, + ) + + result = assemble_group_chat_workflow( + wiring=wiring, + participant_factory=_default_participant_factory, + orchestrator_factory=_default_orchestrator_factory, + builder=builder, + return_builder=True, + ) + + assert isinstance(result, tuple) + assembled_builder, _ = result + assert assembled_builder is builder + assert builder.start_calls == 1 + assert assembled_builder._start_executor is entry # type: ignore + class TestGroupChatOrchestrator: """Tests for GroupChatOrchestratorExecutor core functionality.""" @@ -332,25 +473,116 @@ def selector(state: GroupChatStateSnapshot) -> str | None: workflow = ( GroupChatBuilder() - .select_speakers(selector) + .set_select_speakers_func(selector) .participants([agent]) .with_max_rounds(2) # Limit to 2 rounds .build() ) - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream("test task"): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) # Should have terminated due to max_rounds, expect at least one output assert len(outputs) >= 1 - # The final message should be about round limit - final_output = outputs[-1] + # The final message in the conversation should be about round limit + conversation = outputs[-1] + assert len(conversation) >= 1 + final_output = conversation[-1] assert "round limit" in final_output.text.lower() + async def test_termination_condition_halts_conversation(self) -> None: + """Test that a custom termination condition stops the workflow.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return "agent" + + def termination_condition(conversation: list[ChatMessage]) -> bool: + replies = [msg for msg in conversation if msg.role == Role.ASSISTANT and msg.author_name == "agent"] + return len(replies) >= 2 + + agent = StubAgent("agent", "response") + + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(selector) + .participants([agent]) + .with_termination_condition(termination_condition) + .build() + ) + + outputs: list[list[ChatMessage]] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) + + assert outputs, "Expected termination to yield output" + conversation = outputs[-1] + agent_replies = [msg for msg in conversation if msg.author_name == "agent" and msg.role == Role.ASSISTANT] + assert len(agent_replies) == 2 + final_output = conversation[-1] + assert final_output.author_name == "manager" + assert "termination condition" in final_output.text.lower() + + async def test_termination_condition_uses_manager_final_message(self) -> None: + """Test that manager-provided final message is used on termination.""" + + async def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + agent = StubAgent("agent", "response") + final_text = "manager summary on termination" + + workflow = ( + GroupChatBuilder() + .set_select_speakers_func(selector, final_message=final_text) + .participants([agent]) + .with_termination_condition(lambda _: True) + .build() + ) + + outputs: list[list[ChatMessage]] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) + + assert outputs, "Expected termination to yield output" + conversation = outputs[-1] + assert conversation[-1].text == final_text + assert conversation[-1].author_name == "manager" + + async def test_termination_condition_agent_manager_finalizes(self) -> None: + """Test that agent-based manager can provide final message on termination.""" + manager = StubManagerAgent() + worker = StubAgent("agent", "response") + + workflow = ( + GroupChatBuilder() + .set_manager(manager, display_name="Manager") + .participants([worker]) + .with_termination_condition(lambda conv: any(msg.author_name == "agent" for msg in conv)) + .build() + ) + + outputs: list[list[ChatMessage]] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) + + assert outputs, "Expected termination to yield output" + conversation = outputs[-1] + assert conversation[-1].text == "agent manager final" + assert conversation[-1].author_name == "Manager" + async def test_unknown_participant_error(self) -> None: """Test that _apply_directive raises error for unknown participants.""" @@ -359,7 +591,7 @@ def selector(state: GroupChatStateSnapshot) -> str | None: agent = StubAgent("agent", "response") - workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build() with pytest.raises(ValueError, match="Manager selected unknown participant 'unknown_agent'"): async for _ in workflow.run_stream("test task"): @@ -375,7 +607,7 @@ def bad_selector(state: GroupChatStateSnapshot) -> GroupChatDirective: agent = StubAgent("agent", "response") # The _SpeakerSelectorAdapter will catch this and raise TypeError - workflow = GroupChatBuilder().select_speakers(bad_selector).participants([agent]).build() # type: ignore + workflow = GroupChatBuilder().set_select_speakers_func(bad_selector).participants([agent]).build() # type: ignore # This should raise a TypeError because selector doesn't return str or None with pytest.raises(TypeError, match="must return a participant name \\(str\\) or None"): @@ -390,7 +622,7 @@ def selector(state: GroupChatStateSnapshot) -> str | None: agent = StubAgent("agent", "response") - workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build() with pytest.raises(ValueError, match="requires at least one chat message"): async for _ in workflow.run_stream([]): @@ -525,69 +757,76 @@ def selector(state: GroupChatStateSnapshot) -> str | None: storage = InMemoryCheckpointStorage() workflow = ( - GroupChatBuilder().select_speakers(selector).participants([agent]).with_checkpointing(storage).build() + GroupChatBuilder() + .set_select_speakers_func(selector) + .participants([agent]) + .with_checkpointing(storage) + .build() ) - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream("test task"): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) assert len(outputs) == 1 # Should complete normally -class TestPromptBasedManager: - """Tests for _PromptBasedGroupChatManager.""" +class TestAgentManagerConfiguration: + """Tests for agent-based manager configuration.""" - async def test_manager_with_missing_next_agent_raises_error(self) -> None: - """Test that manager directive without next_agent raises RuntimeError.""" + async def test_set_manager_configures_response_format(self) -> None: + """Ensure ChatAgent managers receive default ManagerSelectionResponse formatting.""" + from unittest.mock import MagicMock - class MockChatClient: - async def get_response(self, messages: Any, response_format: Any = None) -> Any: - # Return response that has finish=False but no next_agent - class MockResponse: - def __init__(self) -> None: - self.value = {"finish": False, "next_agent": None} - self.messages: list[Any] = [] + from agent_framework import ChatAgent - return MockResponse() + chat_client = MagicMock() + manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator") + assert manager_agent.chat_options.response_format is None - manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore + worker = StubAgent("worker", "response") - state = { - "participants": {"agent": "desc"}, - "task": ChatMessage(role=Role.USER, text="test"), - "conversation": (), - } + builder = GroupChatBuilder().set_manager(manager_agent).participants([worker]) - with pytest.raises(RuntimeError, match="missing next_agent while finish is False"): - await manager(state) + assert manager_agent.chat_options.response_format is ManagerSelectionResponse + assert builder._manager_participant is manager_agent # type: ignore[attr-defined] - async def test_manager_with_unknown_participant_raises_error(self) -> None: - """Test that manager selecting unknown participant raises RuntimeError.""" + async def test_set_manager_accepts_agent_manager(self) -> None: + """Verify agent-based manager can be set and workflow builds.""" + from unittest.mock import MagicMock - class MockChatClient: - async def get_response(self, messages: Any, response_format: Any = None) -> Any: - # Return response selecting unknown participant - class MockResponse: - def __init__(self) -> None: - self.value = {"finish": False, "next_agent": "unknown"} - self.messages: list[Any] = [] + from agent_framework import ChatAgent - return MockResponse() + chat_client = MagicMock() + manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator") + worker = StubAgent("worker", "response") - manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore + builder = GroupChatBuilder().set_manager(manager_agent, display_name="Orchestrator") + builder = builder.participants([worker]).with_max_rounds(1) - state = { - "participants": {"agent": "desc"}, - "task": ChatMessage(role=Role.USER, text="test"), - "conversation": (), - } + assert builder._manager_participant is manager_agent # type: ignore[attr-defined] + assert "worker" in builder._participants # type: ignore[attr-defined] + + async def test_set_manager_rejects_custom_response_format(self) -> None: + """Reject custom response_format on ChatAgent managers.""" + from unittest.mock import MagicMock + + from agent_framework import ChatAgent + + class CustomResponse(BaseModel): + value: str - with pytest.raises(RuntimeError, match="Manager selected unknown participant 'unknown'"): - await manager(state) + chat_client = MagicMock() + manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator", response_format=CustomResponse) + worker = StubAgent("worker", "response") + + with pytest.raises(ValueError, match="response_format must be ManagerSelectionResponse"): + GroupChatBuilder().set_manager(manager_agent).participants([worker]) + + assert manager_agent.chat_options.response_format is CustomResponse class TestFactoryFunctions: @@ -595,9 +834,9 @@ class TestFactoryFunctions: def test_default_orchestrator_factory_without_manager_raises_error(self) -> None: """Test that default factory requires manager to be set.""" - config = _GroupChatConfig(manager=None, manager_name="test", participants={}) + config = _GroupChatConfig(manager=None, manager_participant=None, manager_name="test", participants={}) - with pytest.raises(RuntimeError, match="requires a manager to be set"): + with pytest.raises(RuntimeError, match="requires a manager to be configured"): _default_orchestrator_factory(config) @@ -615,14 +854,14 @@ def selector(state: GroupChatStateSnapshot) -> str | None: agent = StubAgent("agent", "response") - workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build() - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream("test string"): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) assert len(outputs) == 1 @@ -637,14 +876,14 @@ def selector(state: GroupChatStateSnapshot) -> str | None: agent = StubAgent("agent", "response") - workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build() - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream(task_message): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) assert len(outputs) == 1 @@ -663,14 +902,14 @@ def selector(state: GroupChatStateSnapshot) -> str | None: agent = StubAgent("agent", "response") - workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build() - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream(conversation): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) assert len(outputs) == 1 @@ -691,23 +930,25 @@ def selector(state: GroupChatStateSnapshot) -> str | None: workflow = ( GroupChatBuilder() - .select_speakers(selector) + .set_select_speakers_func(selector) .participants([agent]) .with_max_rounds(1) # Very low limit .build() ) - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream("test"): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) # Should have at least one output (the round limit message) assert len(outputs) >= 1 - # The last message should be about round limit - final_output = outputs[-1] + # The last message in the conversation should be about round limit + conversation = outputs[-1] + assert len(conversation) >= 1 + final_output = conversation[-1] assert "round limit" in final_output.text.lower() async def test_round_limit_in_ingest_participant_message(self) -> None: @@ -724,23 +965,25 @@ def selector(state: GroupChatStateSnapshot) -> str | None: workflow = ( GroupChatBuilder() - .select_speakers(selector) + .set_select_speakers_func(selector) .participants([agent]) .with_max_rounds(1) # Hit limit after first response .build() ) - outputs: list[ChatMessage] = [] + outputs: list[list[ChatMessage]] = [] async for event in workflow.run_stream("test"): if isinstance(event, WorkflowOutputEvent): data = event.data - if isinstance(data, ChatMessage): - outputs.append(data) + if isinstance(data, list): + outputs.append(cast(list[ChatMessage], data)) # Should have at least one output (the round limit message) assert len(outputs) >= 1 - # The last message should be about round limit - final_output = outputs[-1] + # The last message in the conversation should be about round limit + conversation = outputs[-1] + assert len(conversation) >= 1 + final_output = conversation[-1] assert "round limit" in final_output.text.lower() @@ -754,12 +997,12 @@ async def test_group_chat_checkpoint_runtime_only() -> None: agent_b = StubAgent("agentB", "Reply from B") selector = make_sequence_selector() - wf = GroupChatBuilder().participants([agent_a, agent_b]).select_speakers(selector).build() + wf = GroupChatBuilder().participants([agent_a, agent_b]).set_select_speakers_func(selector).build() baseline_output: list[ChatMessage] | None = None async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage): if isinstance(ev, WorkflowOutputEvent): - baseline_output = ev.data # type: ignore[assignment] + baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None if isinstance(ev, WorkflowStatusEvent) and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, @@ -790,7 +1033,7 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: wf = ( GroupChatBuilder() .participants([agent_a, agent_b]) - .select_speakers(selector) + .set_select_speakers_func(selector) .with_checkpointing(buildtime_storage) .build() ) @@ -798,7 +1041,7 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: baseline_output: list[ChatMessage] | None = None async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage): if isinstance(ev, WorkflowOutputEvent): - baseline_output = ev.data # type: ignore[assignment] + baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None if isinstance(ev, WorkflowStatusEvent) and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, @@ -812,3 +1055,30 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" + + +class _StubExecutor(Executor): + """Minimal executor used to satisfy workflow wiring in tests.""" + + def __init__(self, id: str) -> None: + super().__init__(id=id) + + @handler + async def handle(self, message: object, ctx: WorkflowContext[ChatMessage]) -> None: + await ctx.yield_output(message) + + +def test_set_manager_builds_with_agent_manager() -> None: + """GroupChatBuilder should build when using an agent-based manager.""" + + manager = _StubExecutor("manager_executor") + participant = _StubExecutor("participant_executor") + + workflow = ( + GroupChatBuilder().set_manager(manager, display_name="Moderator").participants({"worker": participant}).build() + ) + + orchestrator = workflow.get_start_executor() + + assert isinstance(orchestrator, GroupChatOrchestratorExecutor) + assert orchestrator._is_manager_agent() diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 5dfd7522df6..1f37a33525b 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -23,7 +23,23 @@ WorkflowOutputEvent, ) from agent_framework._mcp import MCPTool +from agent_framework._workflows import AgentRunEvent +from agent_framework._workflows import _handoff as handoff_module # type: ignore from agent_framework._workflows._handoff import _clone_chat_agent # type: ignore[reportPrivateUsage] +from agent_framework._workflows._workflow_builder import WorkflowBuilder + + +class _CountingWorkflowBuilder(WorkflowBuilder): + created: list["_CountingWorkflowBuilder"] = [] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.start_calls = 0 + _CountingWorkflowBuilder.created.append(self) + + def set_start_executor(self, executor: Any) -> "_CountingWorkflowBuilder": # type: ignore[override] + self.start_calls += 1 + return cast("_CountingWorkflowBuilder", super().set_start_executor(executor)) @dataclass @@ -209,12 +225,12 @@ async def test_handoff_preserves_complex_additional_properties(complex_metadata: # Initial run should preserve complex metadata in the triage response events = await _drain(workflow.run_stream("Need help with a return")) - agent_events = [ev for ev in events if hasattr(ev, "data") and hasattr(ev.data, "messages")] + agent_events = [ev for ev in events if isinstance(ev, AgentRunEvent)] if agent_events: first_agent_event = agent_events[0] first_agent_event_data = first_agent_event.data - if first_agent_event_data and hasattr(first_agent_event_data, "messages"): - first_agent_message = first_agent_event_data.messages[0] # type: ignore[attr-defined] + if first_agent_event_data and first_agent_event_data.messages: + first_agent_message = first_agent_event_data.messages[0] assert "complex" in first_agent_message.additional_properties, "Agent event lost complex metadata" requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] assert requests, "Workflow should request additional user input" @@ -478,6 +494,27 @@ async def test_return_to_previous_enabled(): assert len(specialist_a.calls) == 2, "Specialist A should handle follow-up with return_to_previous enabled" +def test_handoff_builder_sets_start_executor_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure HandoffBuilder.build sets the start executor only once when assembling the workflow.""" + _CountingWorkflowBuilder.created.clear() + monkeypatch.setattr(handoff_module, "WorkflowBuilder", _CountingWorkflowBuilder) + + coordinator = _RecordingAgent(name="coordinator") + specialist = _RecordingAgent(name="specialist") + + workflow = ( + HandoffBuilder(participants=[coordinator, specialist]) + .set_coordinator("coordinator") + .with_termination_condition(lambda conv: len(conv) > 0) + .build() + ) + + assert workflow is not None + assert _CountingWorkflowBuilder.created, "Expected CountingWorkflowBuilder to be instantiated" + builder = _CountingWorkflowBuilder.created[-1] + assert builder.start_calls == 1, "set_start_executor should be invoked exactly once" + + async def test_tool_choice_preserved_from_agent_config(): """Verify that agent-level tool_choice configuration is preserved and not overridden.""" from unittest.mock import AsyncMock diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index cc1e8ad1324..e9f5dcf70d6 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -10,18 +10,15 @@ from agent_framework import ( AgentRunResponse, AgentRunResponseUpdate, + AgentRunUpdateEvent, BaseAgent, - ChatClientProtocol, ChatMessage, - ChatResponse, - ChatResponseUpdate, Executor, - MagenticAgentMessageEvent, MagenticBuilder, + MagenticHumanInterventionDecision, + MagenticHumanInterventionReply, + MagenticHumanInterventionRequest, MagenticManagerBase, - MagenticPlanReviewDecision, - MagenticPlanReviewReply, - MagenticPlanReviewRequest, RequestInfoEvent, Role, TextContent, @@ -33,6 +30,7 @@ WorkflowStatusEvent, handler, ) +from agent_framework._workflows import _group_chat as group_chat_module # type: ignore from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage from agent_framework._workflows._magentic import ( # type: ignore[reportPrivateUsage] MagenticAgentExecutor, @@ -42,6 +40,7 @@ _MagenticProgressLedgerItem, # type: ignore _MagenticStartMessage, # type: ignore ) +from agent_framework._workflows._workflow_builder import WorkflowBuilder if sys.version_info >= (3, 12): from typing import override @@ -57,21 +56,25 @@ def test_magentic_start_message_from_string(): assert msg.task.text == "Do the thing" -def test_plan_review_request_defaults_and_reply_variants(): - req = MagenticPlanReviewRequest() # defaults provided by dataclass +def test_human_intervention_request_defaults_and_reply_variants(): + from agent_framework._workflows._magentic import MagenticHumanInterventionKind + + req = MagenticHumanInterventionRequest(kind=MagenticHumanInterventionKind.PLAN_REVIEW) assert hasattr(req, "request_id") assert req.task_text == "" and req.facts_text == "" and req.plan_text == "" assert isinstance(req.round_index, int) and req.round_index == 0 # Replies: approve, revise with comments, revise with edited text - approve = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) - revise_comments = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.REVISE, comments="Tighten scope") - revise_text = MagenticPlanReviewReply( - decision=MagenticPlanReviewDecision.REVISE, + approve = MagenticHumanInterventionReply(decision=MagenticHumanInterventionDecision.APPROVE) + revise_comments = MagenticHumanInterventionReply( + decision=MagenticHumanInterventionDecision.REVISE, comments="Tighten scope" + ) + revise_text = MagenticHumanInterventionReply( + decision=MagenticHumanInterventionDecision.REVISE, edited_plan_text="- Step 1\n- Step 2", ) - assert approve.decision == MagenticPlanReviewDecision.APPROVE + assert approve.decision == MagenticHumanInterventionDecision.APPROVE assert revise_comments.comments == "Tighten scope" assert revise_text.edited_plan_text is not None and revise_text.edited_plan_text.startswith("- Step 1") @@ -162,6 +165,19 @@ async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatM return ChatMessage(role=Role.ASSISTANT, text="FINAL", author_name="magentic_manager") +class _CountingWorkflowBuilder(WorkflowBuilder): + created: list["_CountingWorkflowBuilder"] = [] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.start_calls = 0 + _CountingWorkflowBuilder.created.append(self) + + def set_start_executor(self, executor: Any) -> "_CountingWorkflowBuilder": # type: ignore[override] + self.start_calls += 1 + return cast("_CountingWorkflowBuilder", super().set_start_executor(executor)) + + async def test_standard_manager_plan_and_replan_combined_ledger(): manager = FakeManager(max_round_count=10, max_stall_count=3, max_reset_count=2) ctx = MagenticContext( @@ -205,15 +221,14 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): req_event: RequestInfoEvent | None = None async for ev in wf.run_stream("do work"): - if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest: + if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticHumanInterventionRequest: req_event = ev assert req_event is not None completed = False - output: ChatMessage | None = None - async for ev in wf.send_responses_streaming( - responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)} - ): + output: list[ChatMessage] | None = None + reply = MagenticHumanInterventionReply(decision=MagenticHumanInterventionDecision.APPROVE) + async for ev in wf.send_responses_streaming(responses={req_event.request_id: reply}): if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: completed = True elif isinstance(ev, WorkflowOutputEvent): @@ -222,7 +237,8 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): break assert completed assert output is not None - assert isinstance(output, ChatMessage) + assert isinstance(output, list) + assert all(isinstance(msg, ChatMessage) for msg in output) async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds(): @@ -249,7 +265,7 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: # typ # Wait for the initial plan review request req_event: RequestInfoEvent | None = None async for ev in wf.run_stream("do work"): - if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest: + if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticHumanInterventionRequest: req_event = ev assert req_event is not None @@ -258,13 +274,13 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: # typ completed = False async for ev in wf.send_responses_streaming( responses={ - req_event.request_id: MagenticPlanReviewReply( - decision=MagenticPlanReviewDecision.APPROVE, + req_event.request_id: MagenticHumanInterventionReply( + decision=MagenticHumanInterventionDecision.APPROVE, comments="Looks good; consider Z", ) } ): - if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest: + if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticHumanInterventionRequest: saw_second_review = True if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE: completed = True @@ -300,8 +316,10 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result(): output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None) assert output_event is not None data = output_event.data - assert isinstance(data, ChatMessage) - assert data.role == Role.ASSISTANT + assert isinstance(data, list) + assert all(isinstance(msg, ChatMessage) for msg in data) + assert len(data) > 0 + assert data[-1].role == Role.ASSISTANT async def test_magentic_checkpoint_resume_round_trip(): @@ -320,7 +338,7 @@ async def test_magentic_checkpoint_resume_round_trip(): task_text = "checkpoint task" req_event: RequestInfoEvent | None = None async for ev in wf.run_stream(task_text): - if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest: + if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticHumanInterventionRequest: req_event = ev assert req_event is not None @@ -341,13 +359,13 @@ async def test_magentic_checkpoint_resume_round_trip(): orchestrator = next(exec for exec in wf_resume.executors.values() if isinstance(exec, MagenticOrchestratorExecutor)) - reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) + reply = MagenticHumanInterventionReply(decision=MagenticHumanInterventionDecision.APPROVE) completed: WorkflowOutputEvent | None = None req_event = None async for event in wf_resume.run_stream( resume_checkpoint.checkpoint_id, ): - if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest: + if isinstance(event, RequestInfoEvent) and event.request_type is MagenticHumanInterventionRequest: req_event = event assert req_event is not None @@ -374,6 +392,23 @@ async def _noop(self, message: object, ctx: WorkflowContext[object]) -> None: # pass +def test_magentic_builder_sets_start_executor_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure MagenticBuilder wiring sets the start executor only once.""" + _CountingWorkflowBuilder.created.clear() + monkeypatch.setattr(group_chat_module, "WorkflowBuilder", _CountingWorkflowBuilder) + + manager = FakeManager() + + workflow = ( + MagenticBuilder().participants(agentA=_DummyExec("agentA")).with_standard_manager(manager=manager).build() + ) + + assert workflow is not None + assert _CountingWorkflowBuilder.created, "Expected CountingWorkflowBuilder to be instantiated" + builder = _CountingWorkflowBuilder.created[-1] + assert builder.start_calls == 1, "set_start_executor should be called exactly once" + + async def test_magentic_agent_executor_on_checkpoint_save_and_restore_roundtrip(): backing_executor = _DummyExec("backing") agent_exec = MagenticAgentExecutor(backing_executor, "agentA") @@ -395,25 +430,33 @@ async def test_magentic_agent_executor_on_checkpoint_save_and_restore_roundtrip( from agent_framework import StandardMagenticManager # noqa: E402 -class _StubChatClient(ChatClientProtocol): - @property - def additional_properties(self) -> dict[str, Any]: - """Get additional properties associated with the client.""" - return {} - - async def get_response(self, messages, **kwargs): # type: ignore[override] - return ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="ok")]) - - def get_streaming_response(self, messages, **kwargs) -> AsyncIterable[ChatResponseUpdate]: # type: ignore[override] - async def _gen(): - if False: - yield ChatResponseUpdate() # pragma: no cover +class _StubManagerAgent(BaseAgent): + """Stub agent for testing StandardMagenticManager.""" + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: Any = None, + **kwargs: Any, + ) -> AgentRunResponse: + return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="ok")]) + + def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: Any = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + async def _gen() -> AsyncIterable[AgentRunResponseUpdate]: + yield AgentRunResponseUpdate(message_deltas=[ChatMessage(role=Role.ASSISTANT, text="ok")]) return _gen() async def test_standard_manager_plan_and_replan_via_complete_monkeypatch(): - mgr = StandardMagenticManager(chat_client=_StubChatClient()) + mgr = StandardMagenticManager(agent=_StubManagerAgent()) async def fake_complete_plan(messages: list[ChatMessage], **kwargs: Any) -> ChatMessage: # Return a different response depending on call order length @@ -446,7 +489,7 @@ async def fake_complete_replan(messages: list[ChatMessage], **kwargs: Any) -> Ch async def test_standard_manager_progress_ledger_success_and_error(): - mgr = StandardMagenticManager(chat_client=_StubChatClient()) + mgr = StandardMagenticManager(agent=_StubManagerAgent()) ctx = MagenticContext( task=ChatMessage(role=Role.USER, text="task"), participant_descriptions={"alice": "desc"}, @@ -561,8 +604,12 @@ async def _collect_agent_responses_setup(participant_obj: object): events.append(ev) if isinstance(ev, WorkflowOutputEvent): break - if isinstance(ev, MagenticAgentMessageEvent) and ev.message is not None: - captured.append(ev.message) + if isinstance(ev, AgentRunUpdateEvent) and ev.data is not None: + captured.append( + ChatMessage( + role=ev.data.role or Role.ASSISTANT, text=ev.data.text or "", author_name=ev.data.author_name + ) + ) if len(events) > 50: break @@ -679,7 +726,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames(): req_event: RequestInfoEvent | None = None async for event in workflow.run_stream("task"): - if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest: + if isinstance(event, RequestInfoEvent) and event.request_type is MagenticHumanInterventionRequest: req_event = event assert req_event is not None @@ -742,9 +789,11 @@ async def test_magentic_stall_and_reset_successfully(): assert idle_status is not None output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None) assert output_event is not None - assert isinstance(output_event.data, ChatMessage) - assert output_event.data.text is not None - assert output_event.data.text == "re-ledger" + assert isinstance(output_event.data, list) + assert all(isinstance(msg, ChatMessage) for msg in output_event.data) + assert len(output_event.data) > 0 + assert output_event.data[-1].text is not None + assert output_event.data[-1].text == "re-ledger" async def test_magentic_checkpoint_runtime_only() -> None: diff --git a/python/packages/core/tests/workflow/test_workflow_observability.py b/python/packages/core/tests/workflow/test_workflow_observability.py index 5856a80035c..1760361f1a3 100644 --- a/python/packages/core/tests/workflow/test_workflow_observability.py +++ b/python/packages/core/tests/workflow/test_workflow_observability.py @@ -151,8 +151,8 @@ async def test_span_creation_and_attributes(span_exporter: InMemorySpanExporter) event_names = [event.name for event in workflow_span.events] assert "workflow.started" in event_names - # Check processing span - processing_span = next(s for s in spans if s.name == "executor.process") + # Check processing span - span name uses format "executor.process {executor_id}" + processing_span = next(s for s in spans if s.name == "executor.process executor-456") assert processing_span.kind == trace.SpanKind.INTERNAL assert processing_span.attributes is not None assert processing_span.attributes.get("executor.id") == "executor-456" @@ -210,7 +210,8 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No # Check that spans were created with proper attributes spans = span_exporter.get_finished_spans() - processing_spans = [s for s in spans if s.name == "executor.process"] + # Processing spans now use executor_id as the span name + processing_spans = [s for s in spans if s.attributes and s.attributes.get("executor.id") == "test-executor"] sending_spans = [s for s in spans if s.name == "message.send"] assert len(processing_spans) >= 1 @@ -218,6 +219,9 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No # Verify processing span attributes processing_span = processing_spans[0] + assert ( + processing_span.name == "executor.process test-executor" + ) # Span name uses format "executor.process {executor_id}" assert processing_span.attributes is not None assert processing_span.attributes.get("executor.id") == "test-executor" assert processing_span.attributes.get("executor.type") == "MockExecutor" @@ -329,8 +333,9 @@ async def test_end_to_end_workflow_tracing(span_exporter: InMemorySpanExporter) spans = span_exporter.get_finished_spans() # Should have workflow span, processing spans, and sending spans + # Processing spans now use executor_id as the span name, filter by executor.id attribute workflow_spans = [s for s in spans if s.name == "workflow.run"] - processing_spans = [s for s in spans if s.name == "executor.process"] + processing_spans = [s for s in spans if s.attributes and s.attributes.get("executor.id") is not None] sending_spans = [s for s in spans if s.name == "message.send"] build_spans_after_run = [s for s in spans if s.name == "workflow.build"] diff --git a/python/packages/core/tests/workflow/test_workflow_states.py b/python/packages/core/tests/workflow/test_workflow_states.py index 4e88ed26cba..53baf863831 100644 --- a/python/packages/core/tests/workflow/test_workflow_states.py +++ b/python/packages/core/tests/workflow/test_workflow_states.py @@ -39,6 +39,12 @@ async def test_executor_failed_and_workflow_failed_events_streaming(): async for ev in wf.run_stream(0): events.append(ev) + # ExecutorFailedEvent should be emitted before WorkflowFailedEvent + executor_failed_events = [e for e in events if isinstance(e, ExecutorFailedEvent)] + assert executor_failed_events, "ExecutorFailedEvent should be emitted when start executor fails" + assert executor_failed_events[0].executor_id == "f" + assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK + # Workflow-level failure and FAILED status should be surfaced failed_events = [e for e in events if isinstance(e, WorkflowFailedEvent)] assert failed_events @@ -47,6 +53,11 @@ async def test_executor_failed_and_workflow_failed_events_streaming(): assert status and status[-1].state == WorkflowRunState.FAILED assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in status) + # Verify ExecutorFailedEvent comes before WorkflowFailedEvent + executor_failed_idx = events.index(executor_failed_events[0]) + workflow_failed_idx = events.index(failed_events[0]) + assert executor_failed_idx < workflow_failed_idx, "ExecutorFailedEvent should be emitted before WorkflowFailedEvent" + async def test_executor_failed_event_emitted_on_direct_execute(): failing = FailingExecutor(id="f") @@ -65,6 +76,42 @@ async def test_executor_failed_event_emitted_on_direct_execute(): assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed) +class PassthroughExecutor(Executor): + """Executor that passes message to the next executor.""" + + @handler + async def passthrough(self, msg: int, ctx: WorkflowContext[int]) -> None: + await ctx.send_message(msg) + + +async def test_executor_failed_event_from_second_executor_in_chain(): + """Test that ExecutorFailedEvent is emitted when a non-start executor fails.""" + passthrough = PassthroughExecutor(id="passthrough") + failing = FailingExecutor(id="failing") + wf: Workflow = WorkflowBuilder().set_start_executor(passthrough).add_edge(passthrough, failing).build() + + events: list[object] = [] + with pytest.raises(RuntimeError, match="boom"): + async for ev in wf.run_stream(0): + events.append(ev) + + # ExecutorFailedEvent should be emitted for the failing executor + executor_failed_events = [e for e in events if isinstance(e, ExecutorFailedEvent)] + assert executor_failed_events, "ExecutorFailedEvent should be emitted when second executor fails" + assert executor_failed_events[0].executor_id == "failing" + assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK + + # Workflow-level failure should also be surfaced + failed_events = [e for e in events if isinstance(e, WorkflowFailedEvent)] + assert failed_events + assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed_events) + + # Verify ExecutorFailedEvent comes before WorkflowFailedEvent + executor_failed_idx = events.index(executor_failed_events[0]) + workflow_failed_idx = events.index(failed_events[0]) + assert executor_failed_idx < workflow_failed_idx, "ExecutorFailedEvent should be emitted before WorkflowFailedEvent" + + class SimpleExecutor(Executor): """Executor that does nothing, for testing.""" diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 9ddab17d87b..aaba468bdf1 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -253,7 +253,7 @@ def from_dict( # We're being called on a subclass, use the normal from_dict return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] - kind = value.get("kind", "") + kind = value.get("kind", "").lower() if kind == "reference": return SerializationMixin.from_dict.__func__( # type: ignore[misc] ReferenceConnection, value, dependencies=dependencies @@ -262,7 +262,7 @@ def from_dict( return SerializationMixin.from_dict.__func__( # type: ignore[misc] RemoteConnection, value, dependencies=dependencies ) - if kind == "key": + if kind in ("key", "apikey"): return SerializationMixin.from_dict.__func__( # type: ignore[misc] ApiKeyConnection, value, dependencies=dependencies ) diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 9a7d7bc050c..de9b7adff50 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251120" +version = "1.0.0b251204" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/devui/agent_framework_devui/_conversations.py b/python/packages/devui/agent_framework_devui/_conversations.py index 9762b55d0e9..512b92f6472 100644 --- a/python/packages/devui/agent_framework_devui/_conversations.py +++ b/python/packages/devui/agent_framework_devui/_conversations.py @@ -134,9 +134,12 @@ async def list_items( pass @abstractmethod - def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None: + async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None: """Get a specific conversation item by ID. + Supports checkpoint items - will load full checkpoint state from storage. + For checkpoints, the full state is included in metadata.full_checkpoint. + Args: conversation_id: Conversation ID item_id: Item ID @@ -162,7 +165,7 @@ def get_thread(self, conversation_id: str) -> AgentThread | None: pass @abstractmethod - def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]: + async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]: """Filter conversations by metadata (e.g., agent_id). Args: @@ -444,7 +447,15 @@ async def list_items( # Get all checkpoints for this conversation checkpoints = await checkpoint_storage.list_checkpoints() for checkpoint in checkpoints: - # Create a conversation item for each checkpoint + # Create a conversation item for each checkpoint with summary metadata + # Full checkpoint state is NOT included here (too large for list view) + # Use get_item() to retrieve full checkpoint details + # Calculate approximate size of checkpoint + import json + + checkpoint_json = json.dumps(checkpoint.to_dict()) + checkpoint_size = len(checkpoint_json.encode("utf-8")) + checkpoint_item = { "id": f"checkpoint_{checkpoint.checkpoint_id}", "type": "checkpoint", @@ -452,6 +463,15 @@ async def list_items( "workflow_id": checkpoint.workflow_id, "timestamp": checkpoint.timestamp, "status": "completed", + "metadata": { + # Summary metrics for list view + "iteration_count": checkpoint.iteration_count, + "pending_hil_count": len(checkpoint.pending_request_info_events), + "has_pending_hil": len(checkpoint.pending_request_info_events) > 0, + "message_count": sum(len(msgs) for msgs in checkpoint.messages.values()), + "size_bytes": checkpoint_size, + "version": checkpoint.version, + }, } items.append(cast(ConversationItem, checkpoint_item)) @@ -472,24 +492,91 @@ async def list_items( return paginated_items, has_more - def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None: - """Get a specific conversation item by ID.""" - # Use the item index for O(1) lookup + async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None: + """Get a specific conversation item by ID. + + Supports checkpoint items - will load full checkpoint state from storage. + For checkpoints, the full state is included in metadata.full_checkpoint. + """ + # First check item index for messages, function calls, etc. (O(1) lookup) conv_items = self._item_index.get(conversation_id, {}) - return conv_items.get(item_id) + item = conv_items.get(item_id) + if item: + return item + + # If not found and ID is a checkpoint, load from checkpoint storage + if item_id.startswith("checkpoint_"): + checkpoint_id = item_id[len("checkpoint_") :] # Remove "checkpoint_" prefix + conv_data = self._conversations.get(conversation_id) + if not conv_data: + return None + + checkpoint_storage = conv_data.get("checkpoint_storage") + if not checkpoint_storage: + return None + + # Load full checkpoint from storage + checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id) + if not checkpoint: + return None + + # Calculate size of checkpoint + import json + + checkpoint_json = json.dumps(checkpoint.to_dict()) + checkpoint_size = len(checkpoint_json.encode("utf-8")) + + # Build checkpoint item with FULL state in metadata + checkpoint_item = { + "id": item_id, + "type": "checkpoint", + "checkpoint_id": checkpoint.checkpoint_id, + "workflow_id": checkpoint.workflow_id, + "timestamp": checkpoint.timestamp, + "status": "completed", + "metadata": { + # Summary metrics (same as list view) + "iteration_count": checkpoint.iteration_count, + "pending_hil_count": len(checkpoint.pending_request_info_events), + "has_pending_hil": len(checkpoint.pending_request_info_events) > 0, + "message_count": sum(len(msgs) for msgs in checkpoint.messages.values()), + "size_bytes": checkpoint_size, + "version": checkpoint.version, + # 🔥 FULL checkpoint state (lazy loaded) + "full_checkpoint": checkpoint.to_dict(), + }, + } + + return cast(ConversationItem, checkpoint_item) + + return None def get_thread(self, conversation_id: str) -> AgentThread | None: """Get AgentThread for execution - CRITICAL for agent.run_stream().""" conv_data = self._conversations.get(conversation_id) return conv_data["thread"] if conv_data else None - def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]: + async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]: """Filter conversations by metadata (e.g., agent_id).""" results = [] for conv_data in self._conversations.values(): - conv_meta = conv_data.get("metadata", {}) + conv_meta = conv_data.get("metadata", {}).copy() # Copy to avoid mutating original + # Check if all filter items match if all(conv_meta.get(k) == v for k, v in metadata_filter.items()): + # Enrich workflow sessions with checkpoint summary + if conv_meta.get("type") == "workflow_session": + checkpoint_storage = conv_data.get("checkpoint_storage") + if checkpoint_storage: + checkpoints = await checkpoint_storage.list_checkpoints() + latest = checkpoints[0] if checkpoints else None + conv_meta["checkpoint_summary"] = { + "count": len(checkpoints), + "latest_iteration": latest.iteration_count if latest else 0, + "has_pending_hil": len(latest.pending_request_info_events) > 0 if latest else False, + "pending_hil_count": len(latest.pending_request_info_events) if latest else 0, + } + results.append( Conversation( id=conv_data["id"], @@ -498,6 +585,10 @@ def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> lis metadata=conv_meta, ) ) + + # Sort by created_at descending (most recent first) + results.sort(key=lambda c: c.created_at, reverse=True) + return results diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index d7af6159fd8..8539549852c 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -229,6 +229,15 @@ def invalidate_entity(self, entity_id: str) -> None: Args: entity_id: Entity identifier to invalidate """ + # Check if entity is in-memory - these cannot be invalidated + entity_info = self._entities.get(entity_id) + if entity_info and entity_info.source == "in_memory": + logger.warning( + f"Attempted to invalidate in-memory entity {entity_id} - ignoring " + f"(in-memory entities cannot be reloaded)" + ) + return + # Remove from loaded objects cache if entity_id in self._loaded_objects: del self._loaded_objects[entity_id] @@ -366,6 +375,7 @@ async def create_entity_info_from_object( description=description, type=entity_type, framework="agent_framework", + source=source, # IMPORTANT: Pass the source parameter tools=[str(tool) for tool in (tools_list or [])], instructions=instructions, model_id=model, diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index 3ce0bbe41ee..99379f6bf9c 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -464,8 +464,11 @@ async def _execute_workflow( except Exception as e: logger.warning(f"Could not convert HIL responses to proper types: {e}") - # Step 2: Now send responses to the in-memory workflow async for event in workflow.send_responses_streaming(hil_responses): + # Enrich new RequestInfoEvents that may come from subsequent HIL requests + if isinstance(event, RequestInfoEvent): + self._enrich_request_info_event_with_response_schema(event, workflow) + for trace_event in trace_collector.get_pending_events(): yield trace_event yield event @@ -719,6 +722,20 @@ def _extract_user_message_fallback(self, input_data: Any) -> str: return json.dumps(input_data) return str(input_data) + def _is_openai_multimodal_format(self, input_data: Any) -> bool: + """Check if input is OpenAI ResponseInputParam format (list with message items). + + Args: + input_data: Input data to check + + Returns: + True if input is OpenAI multimodal format + """ + if not isinstance(input_data, list) or not input_data: + return False + first_item = input_data[0] + return isinstance(first_item, dict) and first_item.get("type") == "message" + async def _parse_workflow_input(self, workflow: Any, raw_input: Any) -> Any: """Parse input based on workflow's expected input type. @@ -730,9 +747,26 @@ async def _parse_workflow_input(self, workflow: Any, raw_input: Any) -> Any: Parsed input appropriate for the workflow """ try: - # Handle structured input + # Handle JSON string input (from frontend api.ts JSON.stringify) + if isinstance(raw_input, str): + try: + parsed = json.loads(raw_input) + raw_input = parsed + except (json.JSONDecodeError, TypeError): + # Plain text string, continue with string handling + pass + + # Check for OpenAI multimodal format (list with type: "message") + # This handles ChatMessage inputs with images, files, etc. + if self._is_openai_multimodal_format(raw_input): + logger.debug("Detected OpenAI multimodal format, converting to ChatMessage") + return self._convert_input_to_chat_message(raw_input) + + # Handle structured input (dict) if isinstance(raw_input, dict): return self._parse_structured_workflow_input(workflow, raw_input) + + # Handle string input return self._parse_raw_workflow_input(workflow, str(raw_input)) except Exception as e: diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index b8d5bf45268..9d9e0e5ccd5 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -29,7 +29,6 @@ InputTokensDetails, OpenAIResponse, OutputTokensDetails, - ResponseCompletedEvent, ResponseErrorEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionResultComplete, @@ -186,6 +185,8 @@ async def convert_event(self, raw_event: Any, request: AgentFrameworkRequest) -> if isinstance(raw_event, AgentRunUpdateEvent): # Extract the AgentRunResponseUpdate from the event's data attribute if raw_event.data and isinstance(raw_event.data, AgentRunResponseUpdate): + # Preserve executor_id in context for proper output routing + context["current_executor_id"] = raw_event.executor_id return await self._convert_agent_update(raw_event.data, context) # If no data, treat as generic workflow event return await self._convert_workflow_event(raw_event, context) @@ -502,8 +503,17 @@ async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> S # Check if we're streaming text content has_text_content = any(content.__class__.__name__ == "TextContent" for content in update.contents) - # If we have text content and haven't created a message yet, create one - if has_text_content and "current_message_id" not in context: + # Check if we're in an executor context with an existing item + executor_id = context.get("current_executor_id") + executor_item_key = f"exec_item_{executor_id}" if executor_id else None + + # If we have an executor item, use it for deltas instead of creating a message + if has_text_content and executor_item_key and executor_item_key in context: + # Use the executor's item ID for this agent's output + context["current_message_id"] = context[executor_item_key] + # Note: We don't create a new message item here since the executor item already exists + # Otherwise, create a message item if we haven't yet (for non-executor contexts) + elif has_text_content and "current_message_id" not in context: message_id = f"msg_{uuid4().hex[:8]}" context["current_message_id"] = message_id context["output_index"] = context.get("output_index", -1) + 1 @@ -671,25 +681,9 @@ async def _convert_agent_lifecycle_event(self, event: Any, context: dict[str, An ] if isinstance(event, AgentCompletedEvent): - execution_id = context.get("execution_id", f"agent_{uuid4().hex[:12]}") - - response_obj = Response( - id=f"resp_{execution_id}", - object="response", - created_at=float(time.time()), - model=model_name, - output=[], - status="completed", - parallel_tool_calls=False, - tool_choice="none", - tools=[], - ) - - return [ - ResponseCompletedEvent( - type="response.completed", sequence_number=self._next_sequence(context), response=response_obj - ) - ] + # Don't emit response.completed here - the server will emit a proper one + # with usage data after aggregating all events + return [] if isinstance(event, AgentFailedEvent): execution_id = context.get("execution_id", f"agent_{uuid4().hex[:12]}") @@ -839,35 +833,10 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> ) ] - # Handle WorkflowCompletedEvent - emit response.completed + # Handle WorkflowCompletedEvent - Don't emit response.completed here + # The server will emit a proper one with usage data after aggregating all events if event_class == "WorkflowCompletedEvent": - workflow_id = context.get("workflow_id", str(uuid4())) - - # Import Response type for proper construction - from openai.types.responses import Response - - # Get model name from request or use 'devui' as default - request_obj = context.get("request") - model_name = request_obj.model if request_obj and request_obj.model else "devui" - - # Create a full Response object for completed state - response_obj = Response( - id=f"resp_{workflow_id}", - object="response", - created_at=float(time.time()), - model=model_name, - output=[], # Output items already sent via output_item.added events - status="completed", - parallel_tool_calls=False, - tool_choice="none", - tools=[], - ) - - return [ - ResponseCompletedEvent( - type="response.completed", sequence_number=self._next_sequence(context), response=response_obj - ) - ] + return [] if event_class == "WorkflowFailedEvent": workflow_id = context.get("workflow_id", str(uuid4())) @@ -916,6 +885,10 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> context[f"exec_item_{executor_id}"] = item_id context["output_index"] = context.get("output_index", -1) + 1 + # Track current executor for routing Magentic agent events + # This allows MagenticAgentDeltaEvent to route to the executor's item + context["current_executor_id"] = executor_id + # Create ExecutorActionItem with proper type executor_item = ExecutorActionItem( type="executor_action", @@ -939,6 +912,10 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> executor_id = getattr(event, "executor_id", "unknown") item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown") + # Clear current executor tracking when executor completes + if context.get("current_executor_id") == executor_id: + context.pop("current_executor_id", None) + # Create ExecutorActionItem with completed status # ExecutorCompletedEvent uses 'data' field, not 'result' executor_item = ExecutorActionItem( @@ -1090,6 +1067,30 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> text = getattr(event, "text", None) if text: + # Check if we're inside an executor - route to executor's item + # This prevents duplicate timeline entries (executor + inner agent) + current_executor_id = context.get("current_executor_id") + executor_item_key = f"exec_item_{current_executor_id}" if current_executor_id else None + + if executor_item_key and executor_item_key in context: + # Route delta to the executor's item instead of creating a new message item + item_id = context[executor_item_key] + + # Emit text delta event routed to the executor's item + return [ + ResponseTextDeltaEvent( + type="response.output_text.delta", + output_index=context.get("output_index", 0), + content_index=0, + item_id=item_id, + delta=text, + logprobs=[], + sequence_number=self._next_sequence(context), + ) + ] + + # Fallback: No executor context - create separate message item (original behavior) + # This handles cases where MagenticAgentDeltaEvent is emitted outside an executor events = [] # Track Magentic agent messages separately from regular messages @@ -1103,7 +1104,7 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> context[magentic_key] = message_id context["output_index"] = context.get("output_index", -1) + 1 - # Import required types + # Import required types for creating message containers from openai.types.responses import ResponseOutputMessage, ResponseOutputText from openai.types.responses.response_content_part_added_event import ( ResponseContentPartAddedEvent, @@ -1212,7 +1213,21 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> agent_id = getattr(event, "agent_id", "unknown_agent") message = getattr(event, "message", None) - # Track Magentic agent messages + # Check if we're inside an executor - if so, deltas were already routed there + # We don't need to emit a separate message completion event + current_executor_id = context.get("current_executor_id") + executor_item_key = f"exec_item_{current_executor_id}" if current_executor_id else None + + if executor_item_key and executor_item_key in context: + # Deltas were routed to executor item - no separate message item to complete + # The executor's output_item.done will mark completion + logger.debug( + f"MagenticAgentMessageEvent from {agent_id} - " + f"deltas routed to executor {current_executor_id}, skipping" + ) + return [] + + # Fallback: Handle case where we created a separate message item (no executor context) magentic_key = f"magentic_message_{agent_id}" # Check if we were streaming for this agent @@ -1305,7 +1320,7 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> "trace_type": "magentic_orchestrator", "orchestrator_id": orchestrator_id, "kind": kind, - "text": text or str(message), + "text": text or "", "timestamp": datetime.now().isoformat(), }, span_id=f"magentic_orch_{uuid4().hex[:8]}", diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index d8f01d95273..284164cefbf 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -2,14 +2,17 @@ """FastAPI server implementation.""" +import asyncio +import importlib.metadata import inspect import json import logging import os import secrets +import uuid from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager -from typing import Any +from typing import Any, cast from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware @@ -26,6 +29,12 @@ logger = logging.getLogger(__name__) +# Get package version +try: + __version__ = importlib.metadata.version("agent-framework-devui") +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + # No AuthMiddleware class needed - we'll use the decorator pattern instead @@ -70,6 +79,7 @@ def __init__( self.deployment_manager = DeploymentManager() self._app: FastAPI | None = None self._pending_entities: list[Any] | None = None + self._running_tasks: dict[str, asyncio.Task[Any]] = {} # Track running response tasks for cancellation def _is_dev_mode(self) -> bool: """Check if running in developer mode. @@ -142,7 +152,7 @@ async def _ensure_executor(self) -> AgentFrameworkExecutor: discovery = self.executor.entity_discovery for entity in self._pending_entities: try: - entity_info = await discovery.create_entity_info_from_object(entity, source="in-memory") + entity_info = await discovery.create_entity_info_from_object(entity, source="in_memory") discovery.register_entity(entity_info.id, entity_info, entity) logger.info(f"Registered in-memory entity: {entity_info.id}") except Exception as e: @@ -293,7 +303,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI( title="Agent Framework Server", description="OpenAI-compatible API server for Agent Framework and other AI frameworks", - version="1.0.0", + version=__version__, lifespan=lifespan, ) @@ -388,8 +398,6 @@ async def get_meta() -> MetaResponse: """Get server metadata and configuration.""" import os - from . import __version__ - # Ensure executors are initialized to check capabilities openai_executor = await self._ensure_openai_executor() @@ -552,6 +560,14 @@ async def reload_entity(entity_id: str) -> dict[str, Any]: if not entity_info: raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") + # Check if entity is in-memory (cannot be reloaded) + if entity_info.source == "in_memory": + raise HTTPException( + status_code=400, + detail="In-memory entities cannot be reloaded. " + "They only exist in memory and have no source files to reload from.", + ) + # Invalidate cache executor.entity_discovery.invalidate_entity(entity_id) @@ -723,13 +739,18 @@ async def create_response(request: AgentFrameworkRequest, raw_request: Request) # Execute request if request.stream: + # Generate response ID for tracking + response_id = f"resp_{uuid.uuid4().hex[:8]}" + logger.info(f"[CANCELLATION] Creating response {response_id} for entity {entity_id}") + return StreamingResponse( - self._stream_execution(executor, request), + self._stream_with_cancellation(executor, request, response_id), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", + "X-Response-ID": response_id, # Include ID for debugging/tracking }, ) return await executor.execute_sync(request) @@ -739,6 +760,30 @@ async def create_response(request: AgentFrameworkRequest, raw_request: Request) error = OpenAIError.create(error_msg) return JSONResponse(status_code=500, content=error.to_dict()) + @app.post("/v1/responses/{response_id}/cancel") + async def cancel_response(response_id: str) -> dict[str, Any]: + """Cancel a running response execution. + + This endpoint allows explicit cancellation of a running stream. + Note: Cancellation also happens automatically when the client disconnects. + """ + logger.info(f"[CANCELLATION] Cancel request received for {response_id}") + + if task := self._running_tasks.get(response_id): + if not task.done(): + logger.info(f"[CANCELLATION] Cancelling task for {response_id}") + task.cancel() + # Wait briefly for cancellation to propagate + try: # noqa: SIM105 + await asyncio.wait_for(task, timeout=0.5) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + return {"status": "cancelled", "response_id": response_id} + logger.warning(f"[CANCELLATION] Task already completed for {response_id}") + return {"status": "already_completed", "response_id": response_id} + logger.warning(f"[CANCELLATION] No task found for {response_id}") + return {"status": "not_found", "response_id": response_id} + # ======================================== # OpenAI Conversations API (Standard) # ======================================== @@ -854,7 +899,7 @@ async def list_conversations( filters["type"] = type # Apply filters - conversations = executor.conversation_store.list_conversations_by_metadata(filters) + conversations = await executor.conversation_store.list_conversations_by_metadata(filters) return { "object": "list", @@ -965,13 +1010,19 @@ async def list_conversation_items( @app.get("/v1/conversations/{conversation_id}/items/{item_id}") async def retrieve_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]: - """Get specific conversation item - OpenAI standard.""" + """Get specific conversation item - OpenAI standard. + + Supports checkpoint items - returns full checkpoint state in metadata.full_checkpoint. + """ try: executor = await self._ensure_executor() - item = executor.conversation_store.get_item(conversation_id, item_id) + item = await executor.conversation_store.get_item(conversation_id, item_id) if not item: raise HTTPException(status_code=404, detail="Item not found") - result: dict[str, Any] = item.model_dump() + # Handle both Pydantic models and dicts + result: dict[str, Any] = ( + item.model_dump() if hasattr(item, "model_dump") else cast(dict[str, Any], item) + ) return result except HTTPException: raise @@ -1049,10 +1100,20 @@ async def _stream_execution( from .models import ResponseCompletedEvent final_response = await executor.message_mapper.aggregate_to_response(events, request) + + # The sequence number for response.completed should be the next number after all events + # The last event in the list should have the highest sequence number so far + # We need to increment from that + last_seq = 0 + for event in reversed(events): + if hasattr(event, "sequence_number") and event.sequence_number is not None: + last_seq = event.sequence_number + break + completed_event = ResponseCompletedEvent( type="response.completed", response=final_response, - sequence_number=len(events), + sequence_number=last_seq + 1, ) yield f"data: {completed_event.model_dump_json()}\n\n" @@ -1121,6 +1182,100 @@ async def _stream_openai_execution( } yield f"data: {json.dumps(error_event)}\n\n" + async def _stream_with_cancellation( + self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest, response_id: str + ) -> AsyncGenerator[str, None]: + """Stream execution with automatic cancellation on client disconnect. + + This wrapper adds cancellation support to the execution stream: + 1. Tracks the execution as an asyncio Task + 2. Detects client disconnection via GeneratorExit + 3. Cancels the task when client disconnects + 4. Propagates CancelledError through the execution chain + + Args: + executor: Agent Framework executor instance + request: Request to execute + response_id: Unique ID for this response/execution + + Yields: + SSE-formatted event strings from the original stream + """ + task = None + + async def execution_wrapper() -> AsyncGenerator[str, None]: + """Inner wrapper to handle the actual execution.""" + try: + logger.debug(f"[CANCELLATION] Starting execution for {response_id}") + + async for chunk in self._stream_execution(executor, request): + # Check if we're being cancelled + current_task = asyncio.current_task() + if current_task and current_task.cancelled(): + logger.info(f"[CANCELLATION] Detected cancellation, breaking stream for {response_id}") + break + yield chunk + + except asyncio.CancelledError: + logger.info(f"[CANCELLATION] Execution cancelled via CancelledError for {response_id}") + # Emit cancellation event to client (if still connected) + cancelled_event = { + "type": "response.cancelled", + "response_id": response_id, + "message": "Execution cancelled by user", + } + yield f"data: {json.dumps(cancelled_event)}\n\n" + raise + except Exception as e: + logger.error(f"[CANCELLATION] Error in cancellable execution for {response_id}: {e}") + raise + + try: + # Get or create the current task and track it + task = asyncio.current_task() + if task: + self._running_tasks[response_id] = task + logger.debug(f"[CANCELLATION] Tracking task {task.get_name()} for response {response_id}") + else: + logger.warning(f"[CANCELLATION] No current task found to track for {response_id}") + + # Stream the execution + async for chunk in execution_wrapper(): + yield chunk + + logger.debug(f"[CANCELLATION] Stream completed normally for {response_id}") + + except GeneratorExit: + # Client disconnected - this is raised when the generator is closed + logger.info(f"[CANCELLATION] Client disconnected, initiating cancellation for {response_id}") + + if task and not task.done(): + logger.info(f"[CANCELLATION] Cancelling task for disconnected client {response_id}") + task.cancel() + # Give it a moment to cancel gracefully + # Note: We should NOT use asyncio.shield here as it prevents cancellation + try: + await asyncio.wait_for(task, timeout=1.0) + except (asyncio.CancelledError, asyncio.TimeoutError): + logger.debug(f"[CANCELLATION] Task cancelled successfully for {response_id}") + except Exception as e: + logger.warning(f"[CANCELLATION] Error during task cancellation for {response_id}: {e}") + raise # Re-raise GeneratorExit to properly close the generator + + except asyncio.CancelledError: + logger.info(f"[CANCELLATION] Stream cancelled for {response_id}") + raise + + except Exception as e: + logger.error(f"[CANCELLATION] Unexpected error in stream for {response_id}: {e}") + raise + + finally: + # Clean up tracking + if response_id in self._running_tasks: + self._running_tasks.pop(response_id) + logger.debug(f"[CANCELLATION] Cleaned up task tracking for {response_id}") + def _mount_ui(self, app: FastAPI) -> None: """Mount the UI as static files.""" from pathlib import Path diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.css b/python/packages/devui/agent_framework_devui/ui/assets/index.css index d44bb61519e..52fcb2ff6e3 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.css +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.css @@ -1 +1 @@ -/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.top-1{top:calc(var(--spacing)*1)}.top-2{top:calc(var(--spacing)*2)}.top-4{top:calc(var(--spacing)*4)}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-24{bottom:calc(var(--spacing)*24)}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-1\.5{margin-left:calc(var(--spacing)*1.5)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.\!h-2{height:calc(var(--spacing)*2)!important}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-32{height:calc(var(--spacing)*32)}.h-96{height:calc(var(--spacing)*96)}.h-\[1\.2rem\]{height:1.2rem}.h-\[1px\]{height:1px}.h-\[500px\]{height:500px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-20{max-height:calc(var(--spacing)*20)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:calc(var(--spacing)*0)!important}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing)*2)!important}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-80{width:calc(var(--spacing)*80)}.w-96{width:calc(var(--spacing)*96)}.w-\[1\.2rem\]{width:1.2rem}.w-\[1px\]{width:1px}.w-\[200px\]{width:200px}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.\!min-w-0{min-width:calc(var(--spacing)*0)!important}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-0{rotate:none}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[auto_auto_1fr_auto\]{grid-template-columns:auto auto 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing)*0)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/20{border-color:#643fb233}.border-\[\#643FB2\]\/30{border-color:#643fb24d}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/10{border-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500)20%,transparent)}}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/20{border-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.border-green-500\/20{border-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-500{border-color:var(--color-orange-500)}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary)20%,transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/10{background-color:#643fb21a}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/80{background-color:color-mix(in oklab,var(--color-blue-50)80%,transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500)10%,transparent)}}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab,red,red)){.bg-gray-900\/90{background-color:color-mix(in oklab,var(--color-gray-900)90%,transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/5{background-color:color-mix(in oklab,var(--color-green-500)5%,transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted,.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary)30%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary)40%,transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-8{padding-top:calc(var(--spacing)*8)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive,.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive)70%,transparent)}}.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive)90%,transparent)}}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-muted-foreground,.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground)80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-800{color:var(--color-orange-800)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab,red,red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab,oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha),transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-green-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-orange-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)25%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-red-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.paused{animation-play-state:paused}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)20%,transparent)var(--tw-shadow-alpha),transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-destructive\/80:hover{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:text-destructive\/80:hover{color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:gap-6{gap:calc(var(--spacing)*6)}.md\:gap-8{gap:calc(var(--spacing)*8)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:none}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/20:is(.dark *){border-color:#8b5cf633}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:#8b5cf64d}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-400:is(.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:is(.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-gray-500:is(.dark *){border-color:var(--color-gray-500)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab,red,red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:#8b5cf61a}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)20%,transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)50%,transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-900\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)20%,transparent)}}.dark\:bg-blue-950\/30:is(.dark *){background-color:#1624564d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)30%,transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)40%,transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)50%,transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-500\/10:is(.dark *){background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)20%,transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)50%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)50%,transparent)}}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-red-950)20%,transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:is(.dark *){color:var(--color-blue-100)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-100:is(.dark *){color:var(--color-green-100)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-300:is(.dark *){color:var(--color-red-300)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:border-gray-600:is(.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-amber-950)30%,transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing)*4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing)*4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing)*7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))} +/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.top-1{top:calc(var(--spacing)*1)}.top-2{top:calc(var(--spacing)*2)}.top-4{top:calc(var(--spacing)*4)}.top-\[30px\]{top:30px}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-24{bottom:calc(var(--spacing)*24)}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.left-\[18px\]{left:18px}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.m-2{margin:calc(var(--spacing)*2)}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-0{margin-left:calc(var(--spacing)*0)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-6{margin-left:calc(var(--spacing)*6)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.\!h-2{height:calc(var(--spacing)*2)!important}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-32{height:calc(var(--spacing)*32)}.h-96{height:calc(var(--spacing)*96)}.h-\[1\.2rem\]{height:1.2rem}.h-\[1px\]{height:1px}.h-\[85vh\]{height:85vh}.h-\[500px\]{height:500px}.h-\[calc\(100\%\+8px\)\]{height:calc(100% + 8px)}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-20{max-height:calc(var(--spacing)*20)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[400px\]{max-height:400px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:calc(var(--spacing)*0)!important}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing)*2)!important}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-80{width:calc(var(--spacing)*80)}.w-\[1\.2rem\]{width:1.2rem}.w-\[1px\]{width:1px}.w-\[28rem\]{width:28rem}.w-\[90vw\]{width:90vw}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.\!min-w-0{min-width:calc(var(--spacing)*0)!important}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[800px\]{min-width:800px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-0{rotate:none}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[auto_auto_1fr_auto\]{grid-template-columns:auto auto 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing)*0)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/20{border-color:#643fb233}.border-\[\#643FB2\]\/30{border-color:#643fb24d}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/10{border-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500)20%,transparent)}}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/20{border-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.border-green-500\/20{border-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-green-600\/20{border-color:#00a54433}@supports (color:color-mix(in lab,red,red)){.border-green-600\/20{border-color:color-mix(in oklab,var(--color-green-600)20%,transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-300{border-color:var(--color-orange-300)}.border-orange-500{border-color:var(--color-orange-500)}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.border-orange-500\/40{border-color:#fe6e0066}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/40{border-color:color-mix(in oklab,var(--color-orange-500)40%,transparent)}}.border-orange-600\/20{border-color:#f0510033}@supports (color:color-mix(in lab,red,red)){.border-orange-600\/20{border-color:color-mix(in oklab,var(--color-orange-600)20%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary)20%,transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/10{background-color:#643fb21a}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-background,.bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/50{background-color:color-mix(in oklab,var(--background)50%,transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/80{background-color:color-mix(in oklab,var(--color-blue-50)80%,transparent)}}.bg-blue-50\/95{background-color:#eff6fff2}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/95{background-color:color-mix(in oklab,var(--color-blue-50)95%,transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500)10%,transparent)}}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab,red,red)){.bg-gray-900\/90{background-color:color-mix(in oklab,var(--color-gray-900)90%,transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/5{background-color:color-mix(in oklab,var(--color-green-500)5%,transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/30{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/30{background-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-50\/50{background-color:#fff7ed80}@supports (color:color-mix(in lab,red,red)){.bg-orange-50\/50{background-color:color-mix(in oklab,var(--color-orange-50)50%,transparent)}}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-100\/50{background-color:#ffedd580}@supports (color:color-mix(in lab,red,red)){.bg-orange-100\/50{background-color:color-mix(in oklab,var(--color-orange-100)50%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/5{background-color:#fe6e000d}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/5{background-color:color-mix(in oklab,var(--color-orange-500)5%,transparent)}}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary)30%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary)40%,transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab,red,red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-8{padding-top:calc(var(--spacing)*8)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-500\/80{color:#3080ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-500\/80{color:color-mix(in oklab,var(--color-blue-500)80%,transparent)}}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive,.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive)70%,transparent)}}.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive)90%,transparent)}}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-muted-foreground,.text-muted-foreground\/60{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/60{color:color-mix(in oklab,var(--muted-foreground)60%,transparent)}}.text-muted-foreground\/70{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/70{color:color-mix(in oklab,var(--muted-foreground)70%,transparent)}}.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground)80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-orange-900{color:var(--color-orange-900)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab,red,red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab,oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha),transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-green-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-orange-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)25%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-red-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)20%,transparent)var(--tw-shadow-alpha),transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-destructive\/80:hover{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:text-destructive\/80:hover{color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-orange-900:hover{color:var(--color-orange-900)}.hover\:text-primary:hover{color:var(--primary)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:gap-8{gap:calc(var(--spacing)*8)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:none}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/20:is(.dark *){border-color:#8b5cf633}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:#8b5cf64d}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-400:is(.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:is(.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-gray-500:is(.dark *){border-color:var(--color-gray-500)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-700:is(.dark *){border-color:var(--color-orange-700)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab,red,red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:#8b5cf61a}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)20%,transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)50%,transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-900\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)20%,transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)40%,transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)50%,transparent)}}.dark\:bg-blue-950\/95:is(.dark *){background-color:#162456f2}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/95:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)95%,transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/30:is(.dark *){background-color:#1018284d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-900\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-900)30%,transparent)}}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-500\/10:is(.dark *){background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)20%,transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)50%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-500\/10:is(.dark *){background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/20:is(.dark *){background-color:#44130633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)20%,transparent)}}.dark\:bg-orange-950\/30:is(.dark *){background-color:#4413064d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)30%,transparent)}}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)50%,transparent)}}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-red-950)20%,transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:is(.dark *){color:var(--color-blue-100)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-400\/70:is(.dark *){color:#54a2ffb3}@supports (color:color-mix(in lab,red,red)){.dark\:text-blue-400\/70:is(.dark *){color:color-mix(in oklab,var(--color-blue-400)70%,transparent)}}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-100:is(.dark *){color:var(--color-green-100)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-100:is(.dark *){color:var(--color-orange-100)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:is(.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:border-gray-600:is(.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-amber-950)30%,transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}.dark\:hover\:bg-orange-950\/40:is(.dark *):hover{background-color:#44130666}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-orange-950\/40:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-orange-950)40%,transparent)}}.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}.dark\:hover\:text-orange-200:is(.dark *):hover{color:var(--color-orange-200)}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing)*4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing)*4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing)*7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}.highlight-attention{animation:1s ease-out highlight-flash}@keyframes highlight-flash{0%{background-color:#fb923c4d;transform:scale(1.02)}to{background-color:#0000;transform:scale(1)}}.hil-waiting-glow{animation:2s infinite pulse-glow;box-shadow:0 0 #fb923c66,inset 0 0 0 1px #fb923c33}@keyframes pulse-glow{0%,to{box-shadow:0 0 #fb923c66,inset 0 0 0 1px #fb923c33}50%{box-shadow:0 0 20px 5px #fb923c33,inset 0 0 0 2px #fb923c4d}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))} diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 317e9e73493..203ffd294ed 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -1,4 +1,4 @@ -function gE(e,n){for(var s=0;so[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))o(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function s(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(l){if(l.ep)return;l.ep=!0;const c=s(l);fetch(l.href,c)}})();function dp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var $m={exports:{}},Oi={};/** +function yE(e,n){for(var r=0;ra[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))a(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&a(d)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function a(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function yp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Gm={exports:{}},Pi={};/** * @license React * react-jsx-runtime.production.js * @@ -6,7 +6,7 @@ function gE(e,n){for(var s=0;s>>1,T=A[P];if(0>>1;Pl(Z,$))rel(de,Z)?(A[P]=de,A[re]=$,P=re):(A[P]=Z,A[W]=$,P=W);else if(rel(de,$))A[P]=de,A[re]=$,P=re;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,j=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,_=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,U=-1;function F(){return j?!0:!(e.unstable_now()-UA&&F());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var T=P(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var B=s(p);B!==null&&V(R,B.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof _=="function")G=function(){_(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,L=ne.port2;ne.port1.onmessage=K,G=function(){L.postMessage(null)}}else G=function(){E(K,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125P?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-P))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Vm)),Vm}var Wy;function wE(){return Wy||(Wy=1,Um.exports=bE()),Um.exports}var qm={exports:{}},Yt={};/** + */var ev;function NE(){return ev||(ev=1,(function(e){function n(k,L){var I=k.length;k.push(L);e:for(;0>>1,C=k[H];if(0>>1;H<$;){var Y=2*(H+1)-1,V=k[Y],K=Y+1,fe=k[K];if(0>l(V,I))Kl(fe,V)?(k[H]=fe,k[K]=I,H=K):(k[H]=V,k[Y]=I,H=Y);else if(Kl(fe,I))k[H]=fe,k[K]=I,H=K;else break e}}return L}function l(k,L){var I=k.sortIndex-L.sortIndex;return I!==0?I:k.id-L.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],h=[],g=1,y=null,x=3,b=!1,S=!1,N=!1,j=!1,_=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function T(k){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=k)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function R(k){if(N=!1,T(k),!S)if(r(m)!==null)S=!0,D||(D=!0,G());else{var L=r(h);L!==null&&U(R,L.startTime-k)}}var D=!1,O=-1,P=5,q=-1;function Q(){return j?!0:!(e.unstable_now()-qk&&Q());){var H=y.callback;if(typeof H=="function"){y.callback=null,x=y.priorityLevel;var C=H(y.expirationTime<=k);if(k=e.unstable_now(),typeof C=="function"){y.callback=C,T(k),L=!0;break t}y===r(m)&&a(m),T(k)}else a(m);y=r(m)}if(y!==null)L=!0;else{var $=r(h);$!==null&&U(R,$.startTime-k),L=!1}}break e}finally{y=null,x=I,b=!1}L=void 0}}finally{L?G():D=!1}}}var G;if(typeof E=="function")G=function(){E(ee)};else if(typeof MessageChannel<"u"){var W=new MessageChannel,B=W.port2;W.port1.onmessage=ee,G=function(){B.postMessage(null)}}else G=function(){_(ee,0)};function U(k,L){O=_(function(){k(e.unstable_now())},L)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(k){k.callback=null},e.unstable_forceFrameRate=function(k){0>k||125H?(k.sortIndex=I,n(h,k),r(m)===null&&k===r(h)&&(N?(M(O),O=-1):N=!0,U(R,I-H))):(k.sortIndex=C,n(m,k),S||b||(S=!0,D||(D=!0,G()))),k},e.unstable_shouldYield=Q,e.unstable_wrapCallback=function(k){var L=x;return function(){var I=x;x=L;try{return k.apply(this,arguments)}finally{x=I}}}})(Km)),Km}var tv;function SE(){return tv||(tv=1,Wm.exports=NE()),Wm.exports}var Qm={exports:{}},Wt={};/** * @license React * react-dom.production.js * @@ -30,7 +30,7 @@ function gE(e,n){for(var s=0;s"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),qm.exports=NE(),qm.exports}/** + */var nv;function jE(){if(nv)return Wt;nv=1;var e=pl();function n(m){var h="https://react.dev/errors/"+m;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),Qm.exports=jE(),Qm.exports}/** * @license React * react-dom-client.production.js * @@ -38,414 +38,414 @@ function gE(e,n){for(var s=0;sT||(t.current=P[T],P[T]=null,T--)}function Z(t,r){T++,P[T]=t.current,t.current=r}var re=B(null),de=B(null),ge=B(null),J=B(null);function le(t,r){switch(Z(ge,r),Z(de,t),Z(re,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?vy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=vy(r),t=by(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}W(re),Z(re,t)}function ve(){W(re),W(de),W(ge)}function Ne(t){t.memoizedState!==null&&Z(J,t);var r=re.current,i=by(r,t.type);r!==i&&(Z(de,t),Z(re,i))}function je(t){de.current===t&&(W(re),W(de)),J.current===t&&(W(J),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,te=e.unstable_cancelCallback,Ee=e.unstable_shouldYield,Ve=e.unstable_requestPaint,Qe=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,Zt=e.unstable_ImmediatePriority,ht=e.unstable_UserBlockingPriority,We=e.unstable_NormalPriority,dt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,ae=e.log,ie=e.unstable_setDisableYieldValue,ue=null,me=null;function ye(t){if(typeof ae=="function"&&ie(t),me&&typeof me.setStrictMode=="function")try{me.setStrictMode(ue,t)}catch{}}var ce=Math.clz32?Math.clz32:Ke,Se=Math.log,De=Math.LN2;function Ke(t){return t>>>=0,t===0?32:31-(Se(t)/De|0)|0}var Ut=256,we=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function _e(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function rt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function ft(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Vt(){var t=Ut;return Ut<<=1,(Ut&4194048)===0&&(Ut=256),t}function Fn(){var t=we;return we<<=1,(we&62914560)===0&&(we=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,ee=t.hiddenUpdates;for(i=C&~i;0C||(t.current=H[C],H[C]=null,C--)}function V(t,s){C++,H[C]=t.current,t.current=s}var K=$(null),fe=$(null),ue=$(null),te=$(null);function ie(t,s){switch(V(ue,s),V(fe,t),V(K,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?jy(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=jy(s),t=_y(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}Y(K),V(K,t)}function xe(){Y(K),Y(fe),Y(ue)}function ve(t){t.memoizedState!==null&&V(te,t);var s=K.current,i=_y(s,t.type);s!==i&&(V(fe,t),V(K,i))}function be(t){fe.current===t&&(Y(K),Y(fe)),te.current===t&&(Y(te),zi._currentValue=I)}var ne=Object.prototype.hasOwnProperty,he=e.unstable_scheduleCallback,X=e.unstable_cancelCallback,pe=e.unstable_shouldYield,Ne=e.unstable_requestPaint,ye=e.unstable_now,Oe=e.unstable_getCurrentPriorityLevel,Se=e.unstable_ImmediatePriority,Ie=e.unstable_UserBlockingPriority,Xe=e.unstable_NormalPriority,He=e.unstable_LowPriority,Re=e.unstable_IdlePriority,Ve=e.log,_e=e.unstable_setDisableYieldValue,$e=null,Fe=null;function Nt(t){if(typeof Ve=="function"&&_e(t),Fe&&typeof Fe.setStrictMode=="function")try{Fe.setStrictMode($e,t)}catch{}}var yt=Math.clz32?Math.clz32:ge,hs=Math.log,wo=Math.LN2;function ge(t){return t>>>=0,t===0?32:31-(hs(t)/wo|0)|0}var Me=256,Be=4194304;function Et(t){var s=t&42;if(s!==0)return s;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Rn(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,A=t.pingedLanes;t=t.warmLanes;var z=u&134217727;return z!==0?(u=z&~v,u!==0?p=Et(u):(A&=z,A!==0?p=Et(A):i||(i=z&~t,i!==0&&(p=Et(i))))):(z=u&~v,z!==0?p=Et(z):A!==0?p=Et(A):i||(i=u&~t,i!==0&&(p=Et(i)))),p===0?0:s!==0&&s!==p&&(s&v)===0&&(v=p&-p,i=s&-s,v>=i||v===32&&(i&4194048)!==0)?s:p}function Le(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function we(t,s){switch(t){case 1:case 2:case 4:case 8:case 64:return s+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return s+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lt(){var t=Me;return Me<<=1,(Me&4194048)===0&&(Me=256),t}function at(){var t=Be;return Be<<=1,(Be&62914560)===0&&(Be=4194304),t}function At(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function en(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Dn(t,s,i,u,p,v){var A=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var z=t.entanglements,F=t.expirationTimes,re=t.hiddenUpdates;for(i=A&~i;0)":-1h||q[u]!==ee[h]){var fe=` -`+q[u].replace(" at new "," at ");return t.displayName&&fe.includes("")&&(fe=fe.replace("",t.displayName)),fe}while(1<=u&&0<=h);break}}}finally{Ha=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?hr(i):""}function Od(t){switch(t.tag){case 26:case 27:case 5:return hr(t.type);case 16:return hr("Lazy");case 13:return hr("Suspense");case 19:return hr("SuspenseList");case 0:case 15:return $a(t.type,!1);case 11:return $a(t.type.render,!1);case 1:return $a(t.type,!0);case 31:return hr("Activity");default:return""}}function Rl(t){try{var r="";do r+=Od(t),t=t.return;while(t);return r}catch(i){return` +`+Va+t+zl}var qa=!1;function Fa(t,s){if(!t||qa)return"";qa=!0;var i=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(s){var me=function(){throw Error()};if(Object.defineProperty(me.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(me,[])}catch(ae){var oe=ae}Reflect.construct(t,[],me)}else{try{me.call()}catch(ae){oe=ae}t.call(me.prototype)}}else{try{throw Error()}catch(ae){oe=ae}(me=t())&&typeof me.catch=="function"&&me.catch(function(){})}}catch(ae){if(ae&&oe&&typeof ae.stack=="string")return[ae.stack,oe.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var p=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");p&&p.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var v=u.DetermineComponentFrameRoot(),A=v[0],z=v[1];if(A&&z){var F=A.split(` +`),re=z.split(` +`);for(p=u=0;up||F[u]!==re[p]){var le=` +`+F[u].replace(" at new "," at ");return t.displayName&&le.includes("")&&(le=le.replace("",t.displayName)),le}while(1<=u&&0<=p);break}}}finally{qa=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?ys(i):""}function Ud(t){switch(t.tag){case 26:case 27:case 5:return ys(t.type);case 16:return ys("Lazy");case 13:return ys("Suspense");case 19:return ys("SuspenseList");case 0:case 15:return Fa(t.type,!1);case 11:return Fa(t.type.render,!1);case 1:return Fa(t.type,!0);case 31:return ys("Activity");default:return""}}function Il(t){try{var s="";do s+=Ud(t),t=t.return;while(t);return s}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function en(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function Dl(t){var r=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(r==="checkbox"||r==="radio")}function zd(t){var r=Dl(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,r),u=""+t[r];if(!t.hasOwnProperty(r)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var h=i.get,x=i.set;return Object.defineProperty(t,r,{configurable:!0,get:function(){return h.call(this)},set:function(C){u=""+C,x.call(this,C)}}),Object.defineProperty(t,r,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(C){u=""+C},stopTracking:function(){t._valueTracker=null,delete t[r]}}}}function vo(t){t._valueTracker||(t._valueTracker=zd(t))}function Ba(t){if(!t)return!1;var r=t._valueTracker;if(!r)return!0;var i=r.getValue(),u="";return t&&(u=Dl(t)?t.checked?"true":"false":t.value),t=u,t!==i?(r.setValue(t),!0):!1}function bo(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Id=/[\n"\\]/g;function tn(t){return t.replace(Id,function(r){return"\\"+r.charCodeAt(0).toString(16)+" "})}function Rs(t,r,i,u,h,x,C,O){t.name="",C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"?t.type=C:t.removeAttribute("type"),r!=null?C==="number"?(r===0&&t.value===""||t.value!=r)&&(t.value=""+en(r)):t.value!==""+en(r)&&(t.value=""+en(r)):C!=="submit"&&C!=="reset"||t.removeAttribute("value"),r!=null?Pa(t,C,en(r)):i!=null?Pa(t,C,en(i)):u!=null&&t.removeAttribute("value"),h==null&&x!=null&&(t.defaultChecked=!!x),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?t.name=""+en(O):t.removeAttribute("name")}function Ol(t,r,i,u,h,x,C,O){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(t.type=x),r!=null||i!=null){if(!(x!=="submit"&&x!=="reset"||r!=null))return;i=i!=null?""+en(i):"",r=r!=null?""+en(r):i,O||r===t.value||(t.value=r),t.defaultValue=r}u=u??h,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=O?t.checked:!!u,t.defaultChecked=!!u,C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"&&(t.name=C)}function Pa(t,r,i){r==="number"&&bo(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function pr(t,r,i,u){if(t=t.options,r){r={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Pd=!1;if(gr)try{var Va={};Object.defineProperty(Va,"passive",{get:function(){Pd=!0}}),window.addEventListener("test",Va,Va),window.removeEventListener("test",Va,Va)}catch{Pd=!1}var Vr=null,Ud=null,Il=null;function Sg(){if(Il)return Il;var t,r=Ud,i=r.length,u,h="value"in Vr?Vr.value:Vr.textContent,x=h.length;for(t=0;t=Ya),Ag=" ",Mg=!1;function Tg(t,r){switch(t){case"keyup":return B_.indexOf(r.keyCode)!==-1;case"keydown":return r.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Rg(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var _o=!1;function U_(t,r){switch(t){case"compositionend":return Rg(r);case"keypress":return r.which!==32?null:(Mg=!0,Ag);case"textInput":return t=r.data,t===Ag&&Mg?null:t;default:return null}}function V_(t,r){if(_o)return t==="compositionend"||!Gd&&Tg(t,r)?(t=Sg(),Il=Ud=Vr=null,_o=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(r.ctrlKey||r.altKey||r.metaKey)||r.ctrlKey&&r.altKey){if(r.char&&1=r)return{node:i,offset:r-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=Bg(i)}}function Ug(t,r){return t&&r?t===r?!0:t&&t.nodeType===3?!1:r&&r.nodeType===3?Ug(t,r.parentNode):"contains"in t?t.contains(r):t.compareDocumentPosition?!!(t.compareDocumentPosition(r)&16):!1:!1}function Vg(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var r=bo(t.document);r instanceof t.HTMLIFrameElement;){try{var i=typeof r.contentWindow.location.href=="string"}catch{i=!1}if(i)t=r.contentWindow;else break;r=bo(t.document)}return r}function Wd(t){var r=t&&t.nodeName&&t.nodeName.toLowerCase();return r&&(r==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||r==="textarea"||t.contentEditable==="true")}var K_=gr&&"documentMode"in document&&11>=document.documentMode,jo=null,Kd=null,Wa=null,Qd=!1;function qg(t,r,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;Qd||jo==null||jo!==bo(u)||(u=jo,"selectionStart"in u&&Wd(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Wa&&Za(Wa,u)||(Wa=u,u=Ec(Kd,"onSelect"),0>=C,h-=C,yr=1<<32-ce(r)+h|i<x?x:8;var C=A.T,O={};A.T=O,Hf(t,!1,r,i);try{var q=h(),ee=A.S;if(ee!==null&&ee(O,q),q!==null&&typeof q=="object"&&typeof q.then=="function"){var fe=aj(q,u);di(t,r,fe,mn(t))}else di(t,r,u,mn(t))}catch(xe){di(t,r,{then:function(){},status:"rejected",reason:xe},mn())}finally{I.p=x,A.T=C}}function dj(){}function If(t,r,i,u){if(t.tag!==5)throw Error(o(476));var h=Fx(t).queue;qx(t,h,r,$,i===null?dj:function(){return Yx(t),i(u)})}function Fx(t){var r=t.memoizedState;if(r!==null)return r;r={memoizedState:$,baseState:$,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:$},next:null};var i={};return r.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:i},next:null},t.memoizedState=r,t=t.alternate,t!==null&&(t.memoizedState=r),r}function Yx(t){var r=Fx(t).next.queue;di(t,r,{},mn())}function Lf(){return Ft(Ai)}function Gx(){return Ct().memoizedState}function Xx(){return Ct().memoizedState}function fj(t){for(var r=t.return;r!==null;){switch(r.tag){case 24:case 3:var i=mn();t=Yr(i);var u=Gr(r,t,i);u!==null&&(hn(u,r,i),oi(u,r,i)),r={cache:mf()},t.payload=r;return}r=r.return}}function mj(t,r,i){var u=mn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},ac(t)?Wx(r,i):(i=nf(t,r,i,u),i!==null&&(hn(i,t,u),Kx(i,r,u)))}function Zx(t,r,i){var u=mn();di(t,r,i,u)}function di(t,r,i,u){var h={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(ac(t))Wx(r,h);else{var x=t.alternate;if(t.lanes===0&&(x===null||x.lanes===0)&&(x=r.lastRenderedReducer,x!==null))try{var C=r.lastRenderedState,O=x(C,i);if(h.hasEagerState=!0,h.eagerState=O,ln(O,C))return Vl(t,r,h,0),pt===null&&Ul(),!1}catch{}finally{}if(i=nf(t,r,h,u),i!==null)return hn(i,t,u),Kx(i,r,u),!0}return!1}function Hf(t,r,i,u){if(u={lane:2,revertLane:gm(),action:u,hasEagerState:!1,eagerState:null,next:null},ac(t)){if(r)throw Error(o(479))}else r=nf(t,i,u,2),r!==null&&hn(r,t,2)}function ac(t){var r=t.alternate;return t===qe||r!==null&&r===qe}function Wx(t,r){zo=ec=!0;var i=t.pending;i===null?r.next=r:(r.next=i.next,i.next=r),t.pending=r}function Kx(t,r,i){if((i&4194048)!==0){var u=r.lanes;u&=t.pendingLanes,i|=u,r.lanes=i,Ta(t,i)}}var ic={readContext:Ft,use:nc,useCallback:St,useContext:St,useEffect:St,useImperativeHandle:St,useLayoutEffect:St,useInsertionEffect:St,useMemo:St,useReducer:St,useRef:St,useState:St,useDebugValue:St,useDeferredValue:St,useTransition:St,useSyncExternalStore:St,useId:St,useHostTransitionStatus:St,useFormState:St,useActionState:St,useOptimistic:St,useMemoCache:St,useCacheRefresh:St},Qx={readContext:Ft,use:nc,useCallback:function(t,r){return rn().memoizedState=[t,r===void 0?null:r],t},useContext:Ft,useEffect:zx,useImperativeHandle:function(t,r,i){i=i!=null?i.concat([t]):null,oc(4194308,4,$x.bind(null,r,t),i)},useLayoutEffect:function(t,r){return oc(4194308,4,t,r)},useInsertionEffect:function(t,r){oc(4,2,t,r)},useMemo:function(t,r){var i=rn();r=r===void 0?null:r;var u=t();if(qs){ye(!0);try{t()}finally{ye(!1)}}return i.memoizedState=[u,r],u},useReducer:function(t,r,i){var u=rn();if(i!==void 0){var h=i(r);if(qs){ye(!0);try{i(r)}finally{ye(!1)}}}else h=r;return u.memoizedState=u.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},u.queue=t,t=t.dispatch=mj.bind(null,qe,t),[u.memoizedState,t]},useRef:function(t){var r=rn();return t={current:t},r.memoizedState=t},useState:function(t){t=Rf(t);var r=t.queue,i=Zx.bind(null,qe,r);return r.dispatch=i,[t.memoizedState,i]},useDebugValue:Of,useDeferredValue:function(t,r){var i=rn();return zf(i,t,r)},useTransition:function(){var t=Rf(!1);return t=qx.bind(null,qe,t.queue,!0,!1),rn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,r,i){var u=qe,h=rn();if(ot){if(i===void 0)throw Error(o(407));i=i()}else{if(i=r(),pt===null)throw Error(o(349));(et&124)!==0||vx(u,r,i)}h.memoizedState=i;var x={value:i,getSnapshot:r};return h.queue=x,zx(wx.bind(null,u,x,t),[t]),u.flags|=2048,Lo(9,sc(),bx.bind(null,u,x,i,r),null),i},useId:function(){var t=rn(),r=pt.identifierPrefix;if(ot){var i=vr,u=yr;i=(u&~(1<<32-ce(u)-1)).toString(32)+i,r="«"+r+"R"+i,i=tc++,0$e?(zt=ze,ze=null):zt=ze.sibling;var st=se(X,ze,Q[$e],he);if(st===null){ze===null&&(ze=zt);break}t&&ze&&st.alternate===null&&r(X,ze),Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st,ze=zt}if($e===Q.length)return i(X,ze),ot&&Hs(X,$e),ke;if(ze===null){for(;$e$e?(zt=ze,ze=null):zt=ze.sibling;var us=se(X,ze,st.value,he);if(us===null){ze===null&&(ze=zt);break}t&&ze&&us.alternate===null&&r(X,ze),Y=x(us,Y,$e),Ye===null?ke=us:Ye.sibling=us,Ye=us,ze=zt}if(st.done)return i(X,ze),ot&&Hs(X,$e),ke;if(ze===null){for(;!st.done;$e++,st=Q.next())st=xe(X,st.value,he),st!==null&&(Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st);return ot&&Hs(X,$e),ke}for(ze=u(ze);!st.done;$e++,st=Q.next())st=oe(ze,X,$e,st.value,he),st!==null&&(t&&st.alternate!==null&&ze.delete(st.key===null?$e:st.key),Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st);return t&&ze.forEach(function(pE){return r(X,pE)}),ot&&Hs(X,$e),ke}function ut(X,Y,Q,he){if(typeof Q=="object"&&Q!==null&&Q.type===S&&Q.key===null&&(Q=Q.props.children),typeof Q=="object"&&Q!==null){switch(Q.$$typeof){case y:e:{for(var ke=Q.key;Y!==null;){if(Y.key===ke){if(ke=Q.type,ke===S){if(Y.tag===7){i(X,Y.sibling),he=h(Y,Q.props.children),he.return=X,X=he;break e}}else if(Y.elementType===ke||typeof ke=="object"&&ke!==null&&ke.$$typeof===H&&e0(ke)===Y.type){i(X,Y.sibling),he=h(Y,Q.props),mi(he,Q),he.return=X,X=he;break e}i(X,Y);break}else r(X,Y);Y=Y.sibling}Q.type===S?(he=Is(Q.props.children,X.mode,he,Q.key),he.return=X,X=he):(he=Fl(Q.type,Q.key,Q.props,null,X.mode,he),mi(he,Q),he.return=X,X=he)}return C(X);case b:e:{for(ke=Q.key;Y!==null;){if(Y.key===ke)if(Y.tag===4&&Y.stateNode.containerInfo===Q.containerInfo&&Y.stateNode.implementation===Q.implementation){i(X,Y.sibling),he=h(Y,Q.children||[]),he.return=X,X=he;break e}else{i(X,Y);break}else r(X,Y);Y=Y.sibling}he=of(Q,X.mode,he),he.return=X,X=he}return C(X);case H:return ke=Q._init,Q=ke(Q._payload),ut(X,Y,Q,he)}if(V(Q))return Be(X,Y,Q,he);if(G(Q)){if(ke=G(Q),typeof ke!="function")throw Error(o(150));return Q=ke.call(Q),Le(X,Y,Q,he)}if(typeof Q.then=="function")return ut(X,Y,lc(Q),he);if(Q.$$typeof===_)return ut(X,Y,Zl(X,Q),he);cc(X,Q)}return typeof Q=="string"&&Q!==""||typeof Q=="number"||typeof Q=="bigint"?(Q=""+Q,Y!==null&&Y.tag===6?(i(X,Y.sibling),he=h(Y,Q),he.return=X,X=he):(i(X,Y),he=sf(Q,X.mode,he),he.return=X,X=he),C(X)):i(X,Y)}return function(X,Y,Q,he){try{fi=0;var ke=ut(X,Y,Q,he);return Ho=null,ke}catch(ze){if(ze===ri||ze===Kl)throw ze;var Ye=cn(29,ze,null,X.mode);return Ye.lanes=he,Ye.return=X,Ye}finally{}}}var $o=t0(!0),n0=t0(!1),En=B(null),Xn=null;function Zr(t){var r=t.alternate;Z(Mt,Mt.current&1),Z(En,t),Xn===null&&(r===null||Oo.current!==null||r.memoizedState!==null)&&(Xn=t)}function r0(t){if(t.tag===22){if(Z(Mt,Mt.current),Z(En,t),Xn===null){var r=t.alternate;r!==null&&r.memoizedState!==null&&(Xn=t)}}else Wr()}function Wr(){Z(Mt,Mt.current),Z(En,En.current)}function Sr(t){W(En),Xn===t&&(Xn=null),W(Mt)}var Mt=B(0);function uc(t){for(var r=t;r!==null;){if(r.tag===13){var i=r.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||km(i)))return r}else if(r.tag===19&&r.memoizedProps.revealOrder!==void 0){if((r.flags&128)!==0)return r}else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===t)break;for(;r.sibling===null;){if(r.return===null||r.return===t)return null;r=r.return}r.sibling.return=r.return,r=r.sibling}return null}function $f(t,r,i,u){r=t.memoizedState,i=i(u,r),i=i==null?r:g({},r,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var Bf={enqueueSetState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueReplaceState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.tag=1,h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueForceUpdate:function(t,r){t=t._reactInternals;var i=mn(),u=Yr(i);u.tag=2,r!=null&&(u.callback=r),r=Gr(t,u,i),r!==null&&(hn(r,t,i),oi(r,t,i))}};function s0(t,r,i,u,h,x,C){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,x,C):r.prototype&&r.prototype.isPureReactComponent?!Za(i,u)||!Za(h,x):!0}function o0(t,r,i,u){t=r.state,typeof r.componentWillReceiveProps=="function"&&r.componentWillReceiveProps(i,u),typeof r.UNSAFE_componentWillReceiveProps=="function"&&r.UNSAFE_componentWillReceiveProps(i,u),r.state!==t&&Bf.enqueueReplaceState(r,r.state,null)}function Fs(t,r){var i=r;if("ref"in r){i={};for(var u in r)u!=="ref"&&(i[u]=r[u])}if(t=t.defaultProps){i===r&&(i=g({},i));for(var h in t)i[h]===void 0&&(i[h]=t[h])}return i}var dc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var r=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(r))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function a0(t){dc(t)}function i0(t){console.error(t)}function l0(t){dc(t)}function fc(t,r){try{var i=t.onUncaughtError;i(r.value,{componentStack:r.stack})}catch(u){setTimeout(function(){throw u})}}function c0(t,r,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:r.tag===1?r.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function Pf(t,r,i){return i=Yr(i),i.tag=3,i.payload={element:null},i.callback=function(){fc(t,r)},i}function u0(t){return t=Yr(t),t.tag=3,t}function d0(t,r,i,u){var h=i.type.getDerivedStateFromError;if(typeof h=="function"){var x=u.value;t.payload=function(){return h(x)},t.callback=function(){c0(r,i,u)}}var C=i.stateNode;C!==null&&typeof C.componentDidCatch=="function"&&(t.callback=function(){c0(r,i,u),typeof h!="function"&&(ns===null?ns=new Set([this]):ns.add(this));var O=u.stack;this.componentDidCatch(u.value,{componentStack:O!==null?O:""})})}function pj(t,r,i,u,h){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(r=i.alternate,r!==null&&ei(r,i,h,!0),i=En.current,i!==null){switch(i.tag){case 13:return Xn===null?dm():i.alternate===null&&Nt===0&&(Nt=3),i.flags&=-257,i.flags|=65536,i.lanes=h,u===gf?i.flags|=16384:(r=i.updateQueue,r===null?i.updateQueue=new Set([u]):r.add(u),mm(t,u,h)),!1;case 22:return i.flags|=65536,u===gf?i.flags|=16384:(r=i.updateQueue,r===null?(r={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=r):(i=r.retryQueue,i===null?r.retryQueue=new Set([u]):i.add(u)),mm(t,u,h)),!1}throw Error(o(435,i.tag))}return mm(t,u,h),dm(),!1}if(ot)return r=En.current,r!==null?((r.flags&65536)===0&&(r.flags|=256),r.flags|=65536,r.lanes=h,u!==cf&&(t=Error(o(422),{cause:u}),Ja(Nn(t,i)))):(u!==cf&&(r=Error(o(423),{cause:u}),Ja(Nn(r,i))),t=t.current.alternate,t.flags|=65536,h&=-h,t.lanes|=h,u=Nn(u,i),h=Pf(t.stateNode,u,h),vf(t,h),Nt!==4&&(Nt=2)),!1;var x=Error(o(520),{cause:u});if(x=Nn(x,i),bi===null?bi=[x]:bi.push(x),Nt!==4&&(Nt=2),r===null)return!0;u=Nn(u,i),i=r;do{switch(i.tag){case 3:return i.flags|=65536,t=h&-h,i.lanes|=t,t=Pf(i.stateNode,u,t),vf(i,t),!1;case 1:if(r=i.type,x=i.stateNode,(i.flags&128)===0&&(typeof r.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(ns===null||!ns.has(x))))return i.flags|=65536,h&=-h,i.lanes|=h,h=u0(h),d0(h,t,i,u),vf(i,h),!1}i=i.return}while(i!==null);return!1}var f0=Error(o(461)),Dt=!1;function Lt(t,r,i,u){r.child=t===null?n0(r,null,i,u):$o(r,t.child,i,u)}function m0(t,r,i,u,h){i=i.render;var x=r.ref;if("ref"in u){var C={};for(var O in u)O!=="ref"&&(C[O]=u[O])}else C=u;return Us(r),u=_f(t,r,i,C,x,h),O=jf(),t!==null&&!Dt?(Ef(t,r,h),_r(t,r,h)):(ot&&O&&af(r),r.flags|=1,Lt(t,r,u,h),r.child)}function h0(t,r,i,u,h){if(t===null){var x=i.type;return typeof x=="function"&&!rf(x)&&x.defaultProps===void 0&&i.compare===null?(r.tag=15,r.type=x,p0(t,r,x,u,h)):(t=Fl(i.type,null,u,r,r.mode,h),t.ref=r.ref,t.return=r,r.child=t)}if(x=t.child,!Zf(t,h)){var C=x.memoizedProps;if(i=i.compare,i=i!==null?i:Za,i(C,u)&&t.ref===r.ref)return _r(t,r,h)}return r.flags|=1,t=xr(x,u),t.ref=r.ref,t.return=r,r.child=t}function p0(t,r,i,u,h){if(t!==null){var x=t.memoizedProps;if(Za(x,u)&&t.ref===r.ref)if(Dt=!1,r.pendingProps=u=x,Zf(t,h))(t.flags&131072)!==0&&(Dt=!0);else return r.lanes=t.lanes,_r(t,r,h)}return Uf(t,r,i,u,h)}function g0(t,r,i){var u=r.pendingProps,h=u.children,x=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((r.flags&128)!==0){if(u=x!==null?x.baseLanes|i:i,t!==null){for(h=r.child=t.child,x=0;h!==null;)x=x|h.lanes|h.childLanes,h=h.sibling;r.childLanes=x&~u}else r.childLanes=0,r.child=null;return x0(t,r,u,i)}if((i&536870912)!==0)r.memoizedState={baseLanes:0,cachePool:null},t!==null&&Wl(r,x!==null?x.cachePool:null),x!==null?px(r,x):wf(),r0(r);else return r.lanes=r.childLanes=536870912,x0(t,r,x!==null?x.baseLanes|i:i,i)}else x!==null?(Wl(r,x.cachePool),px(r,x),Wr(),r.memoizedState=null):(t!==null&&Wl(r,null),wf(),Wr());return Lt(t,r,h,i),r.child}function x0(t,r,i,u){var h=pf();return h=h===null?null:{parent:At._currentValue,pool:h},r.memoizedState={baseLanes:i,cachePool:h},t!==null&&Wl(r,null),wf(),r0(r),t!==null&&ei(t,r,u,!0),null}function mc(t,r){var i=r.ref;if(i===null)t!==null&&t.ref!==null&&(r.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(o(284));(t===null||t.ref!==i)&&(r.flags|=4194816)}}function Uf(t,r,i,u,h){return Us(r),i=_f(t,r,i,u,void 0,h),u=jf(),t!==null&&!Dt?(Ef(t,r,h),_r(t,r,h)):(ot&&u&&af(r),r.flags|=1,Lt(t,r,i,h),r.child)}function y0(t,r,i,u,h,x){return Us(r),r.updateQueue=null,i=xx(r,u,i,h),gx(t),u=jf(),t!==null&&!Dt?(Ef(t,r,x),_r(t,r,x)):(ot&&u&&af(r),r.flags|=1,Lt(t,r,i,x),r.child)}function v0(t,r,i,u,h){if(Us(r),r.stateNode===null){var x=Ao,C=i.contextType;typeof C=="object"&&C!==null&&(x=Ft(C)),x=new i(u,x),r.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=Bf,r.stateNode=x,x._reactInternals=r,x=r.stateNode,x.props=u,x.state=r.memoizedState,x.refs={},xf(r),C=i.contextType,x.context=typeof C=="object"&&C!==null?Ft(C):Ao,x.state=r.memoizedState,C=i.getDerivedStateFromProps,typeof C=="function"&&($f(r,i,C,u),x.state=r.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(C=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),C!==x.state&&Bf.enqueueReplaceState(x,x.state,null),ii(r,u,x,h),ai(),x.state=r.memoizedState),typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!0}else if(t===null){x=r.stateNode;var O=r.memoizedProps,q=Fs(i,O);x.props=q;var ee=x.context,fe=i.contextType;C=Ao,typeof fe=="object"&&fe!==null&&(C=Ft(fe));var xe=i.getDerivedStateFromProps;fe=typeof xe=="function"||typeof x.getSnapshotBeforeUpdate=="function",O=r.pendingProps!==O,fe||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(O||ee!==C)&&o0(r,x,u,C),Fr=!1;var se=r.memoizedState;x.state=se,ii(r,u,x,h),ai(),ee=r.memoizedState,O||se!==ee||Fr?(typeof xe=="function"&&($f(r,i,xe,u),ee=r.memoizedState),(q=Fr||s0(r,i,q,u,se,ee,C))?(fe||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(r.flags|=4194308)):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),r.memoizedProps=u,r.memoizedState=ee),x.props=u,x.state=ee,x.context=C,u=q):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!1)}else{x=r.stateNode,yf(t,r),C=r.memoizedProps,fe=Fs(i,C),x.props=fe,xe=r.pendingProps,se=x.context,ee=i.contextType,q=Ao,typeof ee=="object"&&ee!==null&&(q=Ft(ee)),O=i.getDerivedStateFromProps,(ee=typeof O=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(C!==xe||se!==q)&&o0(r,x,u,q),Fr=!1,se=r.memoizedState,x.state=se,ii(r,u,x,h),ai();var oe=r.memoizedState;C!==xe||se!==oe||Fr||t!==null&&t.dependencies!==null&&Xl(t.dependencies)?(typeof O=="function"&&($f(r,i,O,u),oe=r.memoizedState),(fe=Fr||s0(r,i,fe,u,se,oe,q)||t!==null&&t.dependencies!==null&&Xl(t.dependencies))?(ee||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(u,oe,q),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(u,oe,q)),typeof x.componentDidUpdate=="function"&&(r.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(r.flags|=1024)):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=1024),r.memoizedProps=u,r.memoizedState=oe),x.props=u,x.state=oe,x.context=q,u=fe):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=1024),u=!1)}return x=u,mc(t,r),u=(r.flags&128)!==0,x||u?(x=r.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:x.render(),r.flags|=1,t!==null&&u?(r.child=$o(r,t.child,null,h),r.child=$o(r,null,i,h)):Lt(t,r,i,h),r.memoizedState=x.state,t=r.child):t=_r(t,r,h),t}function b0(t,r,i,u){return Qa(),r.flags|=256,Lt(t,r,i,u),r.child}var Vf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function qf(t){return{baseLanes:t,cachePool:ix()}}function Ff(t,r,i){return t=t!==null?t.childLanes&~i:0,r&&(t|=Cn),t}function w0(t,r,i){var u=r.pendingProps,h=!1,x=(r.flags&128)!==0,C;if((C=x)||(C=t!==null&&t.memoizedState===null?!1:(Mt.current&2)!==0),C&&(h=!0,r.flags&=-129),C=(r.flags&32)!==0,r.flags&=-33,t===null){if(ot){if(h?Zr(r):Wr(),ot){var O=wt,q;if(q=O){e:{for(q=O,O=Gn;q.nodeType!==8;){if(!O){O=null;break e}if(q=zn(q.nextSibling),q===null){O=null;break e}}O=q}O!==null?(r.memoizedState={dehydrated:O,treeContext:Ls!==null?{id:yr,overflow:vr}:null,retryLane:536870912,hydrationErrors:null},q=cn(18,null,null,0),q.stateNode=O,q.return=r,r.child=q,Wt=r,wt=null,q=!0):q=!1}q||Bs(r)}if(O=r.memoizedState,O!==null&&(O=O.dehydrated,O!==null))return km(O)?r.lanes=32:r.lanes=536870912,null;Sr(r)}return O=u.children,u=u.fallback,h?(Wr(),h=r.mode,O=hc({mode:"hidden",children:O},h),u=Is(u,h,i,null),O.return=r,u.return=r,O.sibling=u,r.child=O,h=r.child,h.memoizedState=qf(i),h.childLanes=Ff(t,C,i),r.memoizedState=Vf,u):(Zr(r),Yf(r,O))}if(q=t.memoizedState,q!==null&&(O=q.dehydrated,O!==null)){if(x)r.flags&256?(Zr(r),r.flags&=-257,r=Gf(t,r,i)):r.memoizedState!==null?(Wr(),r.child=t.child,r.flags|=128,r=null):(Wr(),h=u.fallback,O=r.mode,u=hc({mode:"visible",children:u.children},O),h=Is(h,O,i,null),h.flags|=2,u.return=r,h.return=r,u.sibling=h,r.child=u,$o(r,t.child,null,i),u=r.child,u.memoizedState=qf(i),u.childLanes=Ff(t,C,i),r.memoizedState=Vf,r=h);else if(Zr(r),km(O)){if(C=O.nextSibling&&O.nextSibling.dataset,C)var ee=C.dgst;C=ee,u=Error(o(419)),u.stack="",u.digest=C,Ja({value:u,source:null,stack:null}),r=Gf(t,r,i)}else if(Dt||ei(t,r,i,!1),C=(i&t.childLanes)!==0,Dt||C){if(C=pt,C!==null&&(u=i&-i,u=(u&42)!==0?1:Ra(u),u=(u&(C.suspendedLanes|i))!==0?0:u,u!==0&&u!==q.retryLane))throw q.retryLane=u,ko(t,u),hn(C,t,u),f0;O.data==="$?"||dm(),r=Gf(t,r,i)}else O.data==="$?"?(r.flags|=192,r.child=t.child,r=null):(t=q.treeContext,wt=zn(O.nextSibling),Wt=r,ot=!0,$s=null,Gn=!1,t!==null&&(_n[jn++]=yr,_n[jn++]=vr,_n[jn++]=Ls,yr=t.id,vr=t.overflow,Ls=r),r=Yf(r,u.children),r.flags|=4096);return r}return h?(Wr(),h=u.fallback,O=r.mode,q=t.child,ee=q.sibling,u=xr(q,{mode:"hidden",children:u.children}),u.subtreeFlags=q.subtreeFlags&65011712,ee!==null?h=xr(ee,h):(h=Is(h,O,i,null),h.flags|=2),h.return=r,u.return=r,u.sibling=h,r.child=u,u=h,h=r.child,O=t.child.memoizedState,O===null?O=qf(i):(q=O.cachePool,q!==null?(ee=At._currentValue,q=q.parent!==ee?{parent:ee,pool:ee}:q):q=ix(),O={baseLanes:O.baseLanes|i,cachePool:q}),h.memoizedState=O,h.childLanes=Ff(t,C,i),r.memoizedState=Vf,u):(Zr(r),i=t.child,t=i.sibling,i=xr(i,{mode:"visible",children:u.children}),i.return=r,i.sibling=null,t!==null&&(C=r.deletions,C===null?(r.deletions=[t],r.flags|=16):C.push(t)),r.child=i,r.memoizedState=null,i)}function Yf(t,r){return r=hc({mode:"visible",children:r},t.mode),r.return=t,t.child=r}function hc(t,r){return t=cn(22,t,null,r),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function Gf(t,r,i){return $o(r,t.child,null,i),t=Yf(r,r.pendingProps.children),t.flags|=2,r.memoizedState=null,t}function N0(t,r,i){t.lanes|=r;var u=t.alternate;u!==null&&(u.lanes|=r),df(t.return,r,i)}function Xf(t,r,i,u,h){var x=t.memoizedState;x===null?t.memoizedState={isBackwards:r,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:h}:(x.isBackwards=r,x.rendering=null,x.renderingStartTime=0,x.last=u,x.tail=i,x.tailMode=h)}function S0(t,r,i){var u=r.pendingProps,h=u.revealOrder,x=u.tail;if(Lt(t,r,u.children,i),u=Mt.current,(u&2)!==0)u=u&1|2,r.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=r.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&N0(t,i,r);else if(t.tag===19)N0(t,i,r);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===r)break e;for(;t.sibling===null;){if(t.return===null||t.return===r)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(Z(Mt,u),h){case"forwards":for(i=r.child,h=null;i!==null;)t=i.alternate,t!==null&&uc(t)===null&&(h=i),i=i.sibling;i=h,i===null?(h=r.child,r.child=null):(h=i.sibling,i.sibling=null),Xf(r,!1,h,i,x);break;case"backwards":for(i=null,h=r.child,r.child=null;h!==null;){if(t=h.alternate,t!==null&&uc(t)===null){r.child=h;break}t=h.sibling,h.sibling=i,i=h,h=t}Xf(r,!0,i,null,x);break;case"together":Xf(r,!1,null,null,void 0);break;default:r.memoizedState=null}return r.child}function _r(t,r,i){if(t!==null&&(r.dependencies=t.dependencies),ts|=r.lanes,(i&r.childLanes)===0)if(t!==null){if(ei(t,r,i,!1),(i&r.childLanes)===0)return null}else return null;if(t!==null&&r.child!==t.child)throw Error(o(153));if(r.child!==null){for(t=r.child,i=xr(t,t.pendingProps),r.child=i,i.return=r;t.sibling!==null;)t=t.sibling,i=i.sibling=xr(t,t.pendingProps),i.return=r;i.sibling=null}return r.child}function Zf(t,r){return(t.lanes&r)!==0?!0:(t=t.dependencies,!!(t!==null&&Xl(t)))}function gj(t,r,i){switch(r.tag){case 3:le(r,r.stateNode.containerInfo),qr(r,At,t.memoizedState.cache),Qa();break;case 27:case 5:Ne(r);break;case 4:le(r,r.stateNode.containerInfo);break;case 10:qr(r,r.type,r.memoizedProps.value);break;case 13:var u=r.memoizedState;if(u!==null)return u.dehydrated!==null?(Zr(r),r.flags|=128,null):(i&r.child.childLanes)!==0?w0(t,r,i):(Zr(r),t=_r(t,r,i),t!==null?t.sibling:null);Zr(r);break;case 19:var h=(t.flags&128)!==0;if(u=(i&r.childLanes)!==0,u||(ei(t,r,i,!1),u=(i&r.childLanes)!==0),h){if(u)return S0(t,r,i);r.flags|=128}if(h=r.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),Z(Mt,Mt.current),u)break;return null;case 22:case 23:return r.lanes=0,g0(t,r,i);case 24:qr(r,At,t.memoizedState.cache)}return _r(t,r,i)}function _0(t,r,i){if(t!==null)if(t.memoizedProps!==r.pendingProps)Dt=!0;else{if(!Zf(t,i)&&(r.flags&128)===0)return Dt=!1,gj(t,r,i);Dt=(t.flags&131072)!==0}else Dt=!1,ot&&(r.flags&1048576)!==0&&ex(r,Gl,r.index);switch(r.lanes=0,r.tag){case 16:e:{t=r.pendingProps;var u=r.elementType,h=u._init;if(u=h(u._payload),r.type=u,typeof u=="function")rf(u)?(t=Fs(u,t),r.tag=1,r=v0(null,r,u,t,i)):(r.tag=0,r=Uf(null,r,u,t,i));else{if(u!=null){if(h=u.$$typeof,h===k){r.tag=11,r=m0(null,r,u,t,i);break e}else if(h===z){r.tag=14,r=h0(null,r,u,t,i);break e}}throw r=L(u)||u,Error(o(306,r,""))}}return r;case 0:return Uf(t,r,r.type,r.pendingProps,i);case 1:return u=r.type,h=Fs(u,r.pendingProps),v0(t,r,u,h,i);case 3:e:{if(le(r,r.stateNode.containerInfo),t===null)throw Error(o(387));u=r.pendingProps;var x=r.memoizedState;h=x.element,yf(t,r),ii(r,u,null,i);var C=r.memoizedState;if(u=C.cache,qr(r,At,u),u!==x.cache&&ff(r,[At],i,!0),ai(),u=C.element,x.isDehydrated)if(x={element:u,isDehydrated:!1,cache:C.cache},r.updateQueue.baseState=x,r.memoizedState=x,r.flags&256){r=b0(t,r,u,i);break e}else if(u!==h){h=Nn(Error(o(424)),r),Ja(h),r=b0(t,r,u,i);break e}else{switch(t=r.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(wt=zn(t.firstChild),Wt=r,ot=!0,$s=null,Gn=!0,i=n0(r,null,u,i),r.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(Qa(),u===h){r=_r(t,r,i);break e}Lt(t,r,u,i)}r=r.child}return r;case 26:return mc(t,r),t===null?(i=ky(r.type,null,r.pendingProps,null))?r.memoizedState=i:ot||(i=r.type,t=r.pendingProps,u=kc(ge.current).createElement(i),u[Rt]=r,u[qt]=t,$t(u,i,t),jt(u),r.stateNode=u):r.memoizedState=ky(r.type,t.memoizedProps,r.pendingProps,t.memoizedState),null;case 27:return Ne(r),t===null&&ot&&(u=r.stateNode=jy(r.type,r.pendingProps,ge.current),Wt=r,Gn=!0,h=wt,os(r.type)?(Am=h,wt=zn(u.firstChild)):wt=h),Lt(t,r,r.pendingProps.children,i),mc(t,r),t===null&&(r.flags|=4194304),r.child;case 5:return t===null&&ot&&((h=u=wt)&&(u=qj(u,r.type,r.pendingProps,Gn),u!==null?(r.stateNode=u,Wt=r,wt=zn(u.firstChild),Gn=!1,h=!0):h=!1),h||Bs(r)),Ne(r),h=r.type,x=r.pendingProps,C=t!==null?t.memoizedProps:null,u=x.children,jm(h,x)?u=null:C!==null&&jm(h,C)&&(r.flags|=32),r.memoizedState!==null&&(h=_f(t,r,lj,null,null,i),Ai._currentValue=h),mc(t,r),Lt(t,r,u,i),r.child;case 6:return t===null&&ot&&((t=i=wt)&&(i=Fj(i,r.pendingProps,Gn),i!==null?(r.stateNode=i,Wt=r,wt=null,t=!0):t=!1),t||Bs(r)),null;case 13:return w0(t,r,i);case 4:return le(r,r.stateNode.containerInfo),u=r.pendingProps,t===null?r.child=$o(r,null,u,i):Lt(t,r,u,i),r.child;case 11:return m0(t,r,r.type,r.pendingProps,i);case 7:return Lt(t,r,r.pendingProps,i),r.child;case 8:return Lt(t,r,r.pendingProps.children,i),r.child;case 12:return Lt(t,r,r.pendingProps.children,i),r.child;case 10:return u=r.pendingProps,qr(r,r.type,u.value),Lt(t,r,u.children,i),r.child;case 9:return h=r.type._context,u=r.pendingProps.children,Us(r),h=Ft(h),u=u(h),r.flags|=1,Lt(t,r,u,i),r.child;case 14:return h0(t,r,r.type,r.pendingProps,i);case 15:return p0(t,r,r.type,r.pendingProps,i);case 19:return S0(t,r,i);case 31:return u=r.pendingProps,i=r.mode,u={mode:u.mode,children:u.children},t===null?(i=hc(u,i),i.ref=r.ref,r.child=i,i.return=r,r=i):(i=xr(t.child,u),i.ref=r.ref,r.child=i,i.return=r,r=i),r;case 22:return g0(t,r,i);case 24:return Us(r),u=Ft(At),t===null?(h=pf(),h===null&&(h=pt,x=mf(),h.pooledCache=x,x.refCount++,x!==null&&(h.pooledCacheLanes|=i),h=x),r.memoizedState={parent:u,cache:h},xf(r),qr(r,At,h)):((t.lanes&i)!==0&&(yf(t,r),ii(r,null,null,i),ai()),h=t.memoizedState,x=r.memoizedState,h.parent!==u?(h={parent:u,cache:u},r.memoizedState=h,r.lanes===0&&(r.memoizedState=r.updateQueue.baseState=h),qr(r,At,u)):(u=x.cache,qr(r,At,u),u!==h.cache&&ff(r,[At],i,!0))),Lt(t,r,r.pendingProps.children,i),r.child;case 29:throw r.pendingProps}throw Error(o(156,r.tag))}function jr(t){t.flags|=4}function j0(t,r){if(r.type!=="stylesheet"||(r.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Dy(r)){if(r=En.current,r!==null&&((et&4194048)===et?Xn!==null:(et&62914560)!==et&&(et&536870912)===0||r!==Xn))throw si=gf,lx;t.flags|=8192}}function pc(t,r){r!==null&&(t.flags|=4),t.flags&16384&&(r=t.tag!==22?Fn():536870912,t.lanes|=r,Vo|=r)}function hi(t,r){if(!ot)switch(t.tailMode){case"hidden":r=t.tail;for(var i=null;r!==null;)r.alternate!==null&&(i=r),r=r.sibling;i===null?t.tail=null:i.sibling=null;break;case"collapsed":i=t.tail;for(var u=null;i!==null;)i.alternate!==null&&(u=i),i=i.sibling;u===null?r||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function vt(t){var r=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(r)for(var h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags&65011712,u|=h.flags&65011712,h.return=t,h=h.sibling;else for(h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags,u|=h.flags,h.return=t,h=h.sibling;return t.subtreeFlags|=u,t.childLanes=i,r}function xj(t,r,i){var u=r.pendingProps;switch(lf(r),r.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return vt(r),null;case 1:return vt(r),null;case 3:return i=r.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),r.memoizedState.cache!==u&&(r.flags|=2048),wr(At),ve(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(Ka(r)?jr(r):t===null||t.memoizedState.isDehydrated&&(r.flags&256)===0||(r.flags|=1024,rx())),vt(r),null;case 26:return i=r.memoizedState,t===null?(jr(r),i!==null?(vt(r),j0(r,i)):(vt(r),r.flags&=-16777217)):i?i!==t.memoizedState?(jr(r),vt(r),j0(r,i)):(vt(r),r.flags&=-16777217):(t.memoizedProps!==u&&jr(r),vt(r),r.flags&=-16777217),null;case 27:je(r),i=ge.current;var h=r.type;if(t!==null&&r.stateNode!=null)t.memoizedProps!==u&&jr(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}t=re.current,Ka(r)?tx(r):(t=jy(h,u,i),r.stateNode=t,jr(r))}return vt(r),null;case 5:if(je(r),i=r.type,t!==null&&r.stateNode!=null)t.memoizedProps!==u&&jr(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}if(t=re.current,Ka(r))tx(r);else{switch(h=kc(ge.current),t){case 1:t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=h.createElement("div"),t.innerHTML="