diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml index ad384eb83e..7cb0b9636f 100644 --- a/.github/workflows/dotnet-verify-samples.yml +++ b/.github/workflows/dotnet-verify-samples.yml @@ -48,7 +48,8 @@ jobs: . .github dotnet - workflow-samples + python + declarative-agents - name: Setup dotnet uses: actions/setup-dotnet@v5.2.0 @@ -63,6 +64,20 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Generate filtered solution + shell: pwsh + run: | + ./dotnet/eng/scripts/New-FilteredSolution.ps1 ` + -Solution dotnet/agent-framework-dotnet.slnx ` + -TargetFramework net10.0 ` + -Configuration Debug ` + -OutputPath dotnet/filtered.slnx ` + -Verbose + + - name: Build solution + shell: bash + run: dotnet build dotnet/filtered.slnx -f net10.0 --warnaserror + - name: Run verify-samples id: verify working-directory: dotnet diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 4417165a24..4fc47af595 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -115,12 +115,13 @@ jobs: -m "not integration" --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -163,6 +164,7 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Test OpenAI samples timeout-minutes: 10 @@ -173,7 +175,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -225,6 +227,7 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Test Azure samples timeout-minutes: 10 @@ -235,7 +238,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -285,6 +288,7 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Stop local MCP server if: always() @@ -310,7 +314,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -375,12 +379,13 @@ jobs: -x --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -430,12 +435,13 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -489,13 +495,13 @@ jobs: echo "Cosmos DB emulator did not become ready in time." >&2 exit 1 - name: Test with pytest (Cosmos integration) - run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3e12773090..5530be9ffa 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -40,7 +40,7 @@ jobs: UV_CACHE_DIR: /tmp/.uv-cache # Unit tests - name: Run all tests - run: uv run poe test -A + run: uv run poe test -A --junitxml=pytest.xml working-directory: ./python # Surface failing tests @@ -48,7 +48,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false diff --git a/.gitignore b/.gitignore index 089abb5395..4994e9e2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ htmlcov/ .cache nosetests.xml coverage.xml +pytest.xml +python-coverage.xml *.cover *.py,cover .hypothesis/ diff --git a/README.md b/README.md index 4d5a9a30fc..50c0e271fe 100644 --- a/README.md +++ b/README.md @@ -120,38 +120,38 @@ if __name__ == "__main__": ``` ### Basic Agent - .NET +Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework + +```c# +// dotnet add package Microsoft.Agents.AI.Foundry +// Use `az login` to authenticate with Azure CLI +using Azure.AI.Projects; +using Azure.Identity; +using System; +using Azure.AI.Projects; +using Azure.Identity; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; + +var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); + +Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); +``` Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework ```c# -// dotnet add package Microsoft.Agents.AI.OpenAI --prerelease -using Microsoft.Agents.AI; +// dotnet add package Microsoft.Agents.AI.OpenAI +using System; using OpenAI; using OpenAI.Responses; // Replace the with your OpenAI API key. var agent = new OpenAIClient("") - .GetResponsesClient("gpt-4o-mini") - .AsAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); - -Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); -``` - -Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework - -```c# -// dotnet add package Microsoft.Agents.AI.AzureAI --prerelease -// dotnet add package Azure.Identity -// Use `az login` to authenticate with Azure CLI -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) - .AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); + .GetResponsesClient() + .AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); ``` @@ -207,4 +207,9 @@ The samples typically read configuration from environment variables. Common requ ## Important Notes -If you use the Microsoft Agent Framework to build applications that operate with third-party servers or agents, you do so at your own risk. We recommend reviewing all data being shared with third-party servers or agents and being cognizant of third-party practices for retention and location of data. It is your responsibility to manage whether your data will flow outside of your organization's Azure compliance and geographic boundaries and any related implications. +> [!IMPORTANT] +> If you use Microsoft Agent Framework to build applications that operate with any third-party servers, agents, code, or non-Azure Direct models (“Third-Party Systems”), you do so at your own risk. Third-Party Systems are Non-Microsoft Products under the Microsoft Product Terms and are governed by their own third-party license terms. You are responsible for any usage and associated costs. +> +>We recommend reviewing all data being shared with and received from Third-Party Systems and being cognizant of third-party practices for handling, sharing, retention and location of data. It is your responsibility to manage whether your data will flow outside of your organization’s Azure compliance and geographic boundaries and any related implications, and that appropriate permissions, boundaries and approvals are provisioned. +> +>You are responsible for carefully reviewing and testing applications you build using Microsoft Agent Framework in the context of your specific use cases, and making all appropriate decisions and customizations. This includes implementing your own responsible AI mitigations such as metaprompt, content filters, or other safety systems, and ensuring your applications meet appropriate quality, reliability, security, and trustworthiness standards. See also: [Transparency FAQ](./TRANSPARENCY_FAQ.md) diff --git a/dotnet/.github/skills/verify-samples-tool/SKILL.md b/dotnet/.github/skills/verify-samples-tool/SKILL.md index cbb1b35009..4d4f153bfd 100644 --- a/dotnet/.github/skills/verify-samples-tool/SKILL.md +++ b/dotnet/.github/skills/verify-samples-tool/SKILL.md @@ -9,9 +9,16 @@ The `verify-samples` project (`dotnet/eng/verify-samples/`) is an automated tool ## Running verify-samples +**Important:** By default, samples must be pre-built before running verify-samples. Build the solution first, or pass `--build` to build samples during the run: + ```bash cd dotnet +dotnet build agent-framework-dotnet.slnx -f net10.0 +``` +Then run verify-samples: + +```bash # Run all samples across all categories dotnet run --project eng/verify-samples -- --log results.log --csv results.csv @@ -24,6 +31,10 @@ dotnet run --project eng/verify-samples -- Agent_Step02_StructuredOutput Agent_S # Control parallelism (default 8) dotnet run --project eng/verify-samples -- --parallel 8 --log results.log +# Build samples during run (skips the need for a prior build step) +# This may cause build conflicts as multiple samples are built in parallel, so use with caution +dotnet run --project eng/verify-samples -- --build --log results.log + # Combine options dotnet run --project eng/verify-samples -- --category 03-workflows --parallel 4 --log results.log --csv results.csv --md results.md ``` diff --git a/dotnet/eng/verify-samples/AgentsSamples.cs b/dotnet/eng/verify-samples/AgentsSamples.cs index f44dd2f1f6..7a1ab8eaff 100644 --- a/dotnet/eng/verify-samples/AgentsSamples.cs +++ b/dotnet/eng/verify-samples/AgentsSamples.cs @@ -246,7 +246,7 @@ internal static class AgentsSamples ExpectedOutputDescription = [ "The output should contain information about both the current time and the weather in Seattle.", - "The weather information should reference the plugin result: cloudy with a high of 15°C.", + "The weather information should be similar to: cloudy with a high of 15°C. Exact phrasing may vary.", "The output should not contain error messages or stack traces.", ], }, @@ -521,7 +521,7 @@ internal static class AgentsSamples OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], ExpectedOutputDescription = [ - "The output should demonstrate server-side conversation sessions with non-streaming and streaming turns.", + "The output should contain multiple joke responses showing a multi-turn conversation.", "The output should not contain error messages or stack traces.", ], }, diff --git a/dotnet/eng/verify-samples/Program.cs b/dotnet/eng/verify-samples/Program.cs index 7f27d37dd5..ebddc4b16b 100644 --- a/dotnet/eng/verify-samples/Program.cs +++ b/dotnet/eng/verify-samples/Program.cs @@ -14,6 +14,9 @@ // dotnet run -- --log results.log # Write sequential log to file // dotnet run -- --csv results.csv # Write CSV summary to file // dotnet run -- --md results.md # Write Markdown summary to file +// dotnet run -- --build # Build samples during run (default: --no-build) +// Note: By default, this tool expects sample build outputs to already exist. +// Pre-build the solution before running, or pass --build to avoid missing build output failures. // // Required environment variables (for AI-powered samples): // AZURE_OPENAI_ENDPOINT @@ -63,7 +66,7 @@ try // Run all samples var reporter = new ConsoleReporter(); var verifier = new SampleVerifier(chatClient); - var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter); + var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter, buildSamples: options.BuildSamples); var run = await orchestrator.RunAllAsync(options.Samples, options.MaxParallelism); diff --git a/dotnet/eng/verify-samples/SampleRunner.cs b/dotnet/eng/verify-samples/SampleRunner.cs index 0fabd82262..f8bd3cc0e6 100644 --- a/dotnet/eng/verify-samples/SampleRunner.cs +++ b/dotnet/eng/verify-samples/SampleRunner.cs @@ -20,23 +20,32 @@ internal static class SampleRunner { /// /// Runs dotnet run --framework net10.0 in the given project directory. + /// When is false (the default), --no-build is passed + /// to skip building, assuming the project was pre-built. /// public static Task RunAsync( string projectPath, TimeSpan timeout, + bool build = false, CancellationToken cancellationToken = default) - => RunAsync(projectPath, "run --framework net10.0", timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken); + => RunAsync(projectPath, DotnetRunArgs(build), timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken); /// /// Runs dotnet run --framework net10.0 with stdin inputs. + /// When is false (the default), --no-build is passed + /// to skip building, assuming the project was pre-built. /// public static Task RunAsync( string projectPath, TimeSpan timeout, string?[]? inputs, int inputDelayMs = 2000, + bool build = false, CancellationToken cancellationToken = default) - => RunAsync(projectPath, "run --framework net10.0", timeout, inputs, inputDelayMs, cancellationToken); + => RunAsync(projectPath, DotnetRunArgs(build), timeout, inputs, inputDelayMs, cancellationToken); + + private static string DotnetRunArgs(bool build) => + $"run {(build ? "" : "--no-build")} --framework net10.0"; /// /// Runs an arbitrary dotnet command in the given working directory. diff --git a/dotnet/eng/verify-samples/SampleVerifier.cs b/dotnet/eng/verify-samples/SampleVerifier.cs index 9dc17b1769..ae28aa835f 100644 --- a/dotnet/eng/verify-samples/SampleVerifier.cs +++ b/dotnet/eng/verify-samples/SampleVerifier.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ComponentModel; using System.Text.Json.Serialization; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -27,11 +28,19 @@ internal sealed class SampleVerifier instructions: """ You are a test output verifier. You will be given: 1. The actual stdout output of a program - 2. A list of expectations about what the output should contain or demonstrate + 2. The stderr output (if any) + 3. A list of expectations about what the output should contain or demonstrate Your job is to determine whether the actual output satisfies each expectation. Be reasonable — the output comes from an LLM so exact wording won't match, but the semantic intent should be clearly satisfied. + + In your response, you MUST: + - Always provide ai_reasoning with a brief overall assessment. + - Always provide exactly one entry in expectation_results for each expectation, + in the same order as the input list. + - For each expectation_results entry, echo the expectation text in the expectation + field and explain your assessment in the detail field, citing evidence from the output. """, name: "OutputVerifier"); } @@ -78,7 +87,7 @@ internal sealed class SampleVerifier } else { - var aiResult = await this.VerifyWithAIAsync(run.Stdout, sample.ExpectedOutputDescription); + var aiResult = await this.VerifyWithAIAsync(run.Stdout, run.Stderr, sample.ExpectedOutputDescription); aiReasoning = aiResult.Reasoning; foreach (var unmet in aiResult.UnmetExpectations) @@ -100,16 +109,28 @@ internal sealed class SampleVerifier } private async Task<(string Reasoning, List UnmetExpectations)> VerifyWithAIAsync( - string actualOutput, + string stdout, + string stderr, string[] expectations) { var expectationList = string.Join("\n", expectations.Select((e, i) => $" {i + 1}. {e}")); + + var stderrSection = string.IsNullOrWhiteSpace(stderr) + ? "" + : $""" + + Stderr output: + --- + {Truncate(stderr, 2000)} + --- + """; + var prompt = $""" Actual program output: --- - {Truncate(actualOutput, 4000)} + {Truncate(stdout, 4000)} --- - + {stderrSection} Expectations to verify: {expectationList} @@ -126,7 +147,9 @@ internal sealed class SampleVerifier return ($"AI verification returned null result. Raw: {response.Text}", ["AI verification returned null result."]); } - var reasoning = result.Reasoning ?? "(no reasoning provided)"; + var reasoning = string.IsNullOrWhiteSpace(result.AIReasoning) + ? "(no reasoning provided)" + : result.AIReasoning; // Collect unmet expectations as individual failures var unmet = new List(); @@ -174,12 +197,14 @@ internal sealed class AIVerificationResponse public bool Pass { get; set; } /// Brief explanation of the overall assessment. - [JsonPropertyName("reasoning")] - public string? Reasoning { get; set; } + [JsonPropertyName("ai_reasoning")] + [Description("Always required. Brief explanation of the overall assessment, covering all expectations.")] + public string AIReasoning { get; set; } = string.Empty; /// Per-expectation results. [JsonPropertyName("expectation_results")] - public List? ExpectationResults { get; set; } + [Description("Always required. One entry per expectation, in the same order as the input list.")] + public List ExpectationResults { get; set; } = []; } /// @@ -190,7 +215,8 @@ internal sealed class ExpectationResult { /// The expectation text that was evaluated. [JsonPropertyName("expectation")] - public string? Expectation { get; set; } + [Description("Echo back the expectation text being evaluated.")] + public string Expectation { get; set; } = string.Empty; /// Whether this expectation was met. [JsonPropertyName("met")] @@ -198,5 +224,6 @@ internal sealed class ExpectationResult /// Detail about how the expectation was or was not met. [JsonPropertyName("detail")] - public string? Detail { get; set; } + [Description("Explain how the expectation was or was not met, citing specific evidence from the output.")] + public string Detail { get; set; } = string.Empty; } diff --git a/dotnet/eng/verify-samples/VerificationOrchestrator.cs b/dotnet/eng/verify-samples/VerificationOrchestrator.cs index 1ce805bc5a..b55efc9c14 100644 --- a/dotnet/eng/verify-samples/VerificationOrchestrator.cs +++ b/dotnet/eng/verify-samples/VerificationOrchestrator.cs @@ -14,19 +14,22 @@ internal sealed class VerificationOrchestrator private readonly LogFileWriter? _logWriter; private readonly string _dotnetRoot; private readonly TimeSpan _timeout; + private readonly bool _buildSamples; public VerificationOrchestrator( SampleVerifier verifier, ConsoleReporter reporter, string dotnetRoot, TimeSpan timeout, - LogFileWriter? logWriter = null) + LogFileWriter? logWriter = null, + bool buildSamples = false) { this._verifier = verifier; this._reporter = reporter; this._logWriter = logWriter; this._dotnetRoot = dotnetRoot; this._timeout = timeout; + this._buildSamples = buildSamples; } /// @@ -136,8 +139,8 @@ internal sealed class VerificationOrchestrator var projectPath = Path.Combine(this._dotnetRoot, sample.ProjectPath); var run = sample.Inputs.Length > 0 - ? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs) - : await SampleRunner.RunAsync(projectPath, this._timeout); + ? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs, build: this._buildSamples) + : await SampleRunner.RunAsync(projectPath, this._timeout, build: this._buildSamples); log.Add($"[{sample.Name}] Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode})"); this._reporter.WriteLineWithPrefix( diff --git a/dotnet/eng/verify-samples/VerifyOptions.cs b/dotnet/eng/verify-samples/VerifyOptions.cs index 78ba38acf1..95e0af8795 100644 --- a/dotnet/eng/verify-samples/VerifyOptions.cs +++ b/dotnet/eng/verify-samples/VerifyOptions.cs @@ -27,6 +27,12 @@ internal sealed class VerifyOptions /// public string? LogFilePath { get; init; } + /// + /// When true, samples are built as part of dotnet run. + /// When false (the default), --no-build is passed, assuming a prior build step. + /// + public bool BuildSamples { get; init; } + /// /// The filtered list of samples to process. /// @@ -55,6 +61,7 @@ internal sealed class VerifyOptions var logFilePath = ExtractArg(argList, "--log"); var csvFilePath = ExtractArg(argList, "--csv"); var markdownFilePath = ExtractArg(argList, "--md"); + var buildSamples = ExtractFlag(argList, "--build"); int maxParallelism = 8; var parallelArg = ExtractArg(argList, "--parallel"); @@ -105,6 +112,7 @@ internal sealed class VerifyOptions LogFilePath = logFilePath, CsvFilePath = csvFilePath, MarkdownFilePath = markdownFilePath, + BuildSamples = buildSamples, Samples = samples, }; } @@ -128,4 +136,16 @@ internal sealed class VerifyOptions list.RemoveRange(idx, 2); return value; } + + private static bool ExtractFlag(List list, string flag) + { + var idx = list.IndexOf(flag); + if (idx < 0) + { + return false; + } + + list.RemoveAt(idx); + return true; + } } diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index c64f43949f..9d91eebf28 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,21 +1,21 @@ - 1.0.0 - 6 + 1.1.0 + 1 $(VersionPrefix)-rc$(RCNumber) - $(VersionPrefix)-$(VersionSuffix).260402.1 - $(VersionPrefix)-preview.260402.1 + $(VersionPrefix)-$(VersionSuffix).260410.1 + $(VersionPrefix)-preview.260410.1 $(VersionPrefix) - 1.0.0 + 1.1.0 Debug;Release;Publish true - 1.0.0-rc5 - - true + 1.0.0 + + true $(NoWarn);CP0003 diff --git a/dotnet/samples/01-get-started/01_hello_agent/Program.cs b/dotnet/samples/01-get-started/01_hello_agent/Program.cs index e461f9ba75..5e866f1d83 100644 --- a/dotnet/samples/01-get-started/01_hello_agent/Program.cs +++ b/dotnet/samples/01-get-started/01_hello_agent/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/01-get-started/02_add_tools/Program.cs b/dotnet/samples/01-get-started/02_add_tools/Program.cs index da0b638562..e43f366c68 100644 --- a/dotnet/samples/01-get-started/02_add_tools/Program.cs +++ b/dotnet/samples/01-get-started/02_add_tools/Program.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) diff --git a/dotnet/samples/01-get-started/03_multi_turn/Program.cs b/dotnet/samples/01-get-started/03_multi_turn/Program.cs index 5d49e806ed..1887a22b4f 100644 --- a/dotnet/samples/01-get-started/03_multi_turn/Program.cs +++ b/dotnet/samples/01-get-started/03_multi_turn/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/01-get-started/04_memory/Program.cs b/dotnet/samples/01-get-started/04_memory/Program.cs index a97941620f..ebafc60b45 100644 --- a/dotnet/samples/01-get-started/04_memory/Program.cs +++ b/dotnet/samples/01-get-started/04_memory/Program.cs @@ -16,7 +16,7 @@ using OpenAI.Chat; using SampleApp; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/01-get-started/06_host_your_agent/Program.cs b/dotnet/samples/01-get-started/06_host_your_agent/Program.cs index 6012119b25..c106f2e4f2 100644 --- a/dotnet/samples/01-get-started/06_host_your_agent/Program.cs +++ b/dotnet/samples/01-get-started/06_host_your_agent/Program.cs @@ -8,7 +8,7 @@ // // Environment variables: // AZURE_OPENAI_ENDPOINT -// AZURE_OPENAI_DEPLOYMENT_NAME (defaults to "gpt-4o-mini") +// AZURE_OPENAI_DEPLOYMENT_NAME (defaults to "gpt-5.4-mini") // // Run with: func start // Then call: POST http://localhost:7071/api/agents/HostedAgent/run @@ -23,7 +23,7 @@ using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Set up an AI agent following the standard Microsoft Agent Framework pattern. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index f55e317e36..77a58b3198 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -15,7 +15,7 @@ All samples require the following environment variables: ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` For the client samples, you can optionally set: diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 83445dc1fc..d1cd5a50c9 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -97,7 +97,7 @@ Console.WriteLine(""" """); var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT environment variable is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Log application startup appLogger.LogInformation("OpenTelemetry Aspire Demo application started"); diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/README.md b/dotnet/samples/02-agents/AgentOpenTelemetry/README.md index 229d37dca6..d79c00eb87 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/README.md +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/README.md @@ -34,7 +34,7 @@ graph TD Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` **Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs index a4b702be2c..af41e69c77 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs @@ -9,7 +9,7 @@ using Azure.Identity; using Microsoft.Agents.AI; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; const string JokerName = "Joker"; const string JokerInstructions = "You are good at telling jokes."; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md index 7c92d809d3..dbe7c2c12f 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md @@ -22,5 +22,5 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index f5321c4350..233705d4af 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -9,7 +9,7 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; const string JokerName = "JokerAgent"; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md index ec688df59d..0e225751fb 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md @@ -22,5 +22,5 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs index 1f83f6fbef..024adf626d 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md index 4cacf30131..2c22cd623e 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIChatCompletion/README.md @@ -12,5 +12,5 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs index f29b850700..279cadc12b 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/README.md index 4cacf30131..2c22cd623e 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/README.md @@ -12,5 +12,5 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs index f5af4d2369..3d7ed3a871 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs @@ -8,7 +8,7 @@ using OpenAI; using OpenAI.Chat; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; +var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; AIAgent agent = new OpenAIClient( apiKey) diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/README.md index ef7ce3ae02..3f61854aa6 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/README.md @@ -9,5 +9,5 @@ Set the following environment variables: ```powershell $env:OPENAI_API_KEY="*****" # Replace with your OpenAI api key -$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:OPENAI_CHAT_MODEL_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs index baa6677a4f..456aaafef0 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs @@ -7,7 +7,7 @@ using OpenAI; using OpenAI.Responses; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; +var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; AIAgent agent = new OpenAIClient( apiKey) diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/README.md index ef7ce3ae02..3f61854aa6 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/README.md @@ -9,5 +9,5 @@ Set the following environment variables: ```powershell $env:OPENAI_API_KEY="*****" # Replace with your OpenAI api key -$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:OPENAI_CHAT_MODEL_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs index b787bb86a3..f6b9b58b79 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -16,7 +16,7 @@ using OpenAI.Responses; // --- Configuration --- string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // --- Skills Provider --- // Discovers skills from the 'skills' directory containing SKILL.md files. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md index 592aca4a27..0c5b7f8416 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -30,7 +30,7 @@ Converts between common units (miles↔km, pounds↔kg) using a multiplication f ```bash export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ### Run diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs index 46c58985fb..8c1cfa33bb 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs @@ -16,7 +16,7 @@ using OpenAI.Responses; // --- Configuration --- string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // --- Build the code-defined skill --- var unitConverterSkill = new AgentInlineSkill( diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md index 5b7c8747dd..bb31b25713 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md @@ -31,7 +31,7 @@ Converts between common units using multiplication factors. Defined entirely in ```bash export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ### Run diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj index fd3d71fe7e..d7233702ac 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001 + $(NoWarn);MAAI001;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs index fb0f202230..7f5e356a60 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill. -// Class-based skills bundle all components into a single class implementation. +// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill +// with attributes for automatic script and resource discovery. +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -11,7 +12,7 @@ using OpenAI.Responses; // --- Configuration --- string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // --- Class-Based Skill --- // Instantiate the skill class. @@ -44,17 +45,16 @@ AgentResponse response = await agent.RunAsync( Console.WriteLine($"Agent: {response.Text}"); /// -/// A unit-converter skill defined as a C# class. +/// A unit-converter skill defined as a C# class using attributes for discovery. /// /// -/// Class-based skills bundle all components (name, description, body, resources, scripts) -/// into a single class. +/// Properties annotated with are automatically +/// discovered as skill resources, and methods annotated with +/// are automatically discovered as skill scripts. Alternatively, +/// and can be overridden. /// -internal sealed class UnitConverterSkill : AgentClassSkill +internal sealed class UnitConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "unit-converter", @@ -69,31 +69,40 @@ internal sealed class UnitConverterSkill : AgentClassSkill 3. Present the result clearly with both units. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource( - "conversion-table", - """ - # Conversion Tables + /// + /// Gets the used to marshal parameters and return values + /// for scripts and resources. + /// + /// + /// This override is not necessary for this sample, but can be used to provide custom + /// serialization options, for example a source-generated JsonTypeInfoResolver + /// for Native AOT compatibility. + /// + protected override JsonSerializerOptions? SerializerOptions => null; - Formula: **result = value × factor** + /// + /// A conversion table resource providing multiplication factors. + /// + [AgentSkillResource("conversion-table")] + [Description("Lookup table of multiplication factors for common unit conversions.")] + public string ConversionTable => """ + # Conversion Tables - | From | To | Factor | - |-------------|-------------|----------| - | miles | kilometers | 1.60934 | - | kilometers | miles | 0.621371 | - | pounds | kilograms | 0.453592 | - | kilograms | pounds | 2.20462 | - """), - ]; + Formula: **result = value × factor** - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert", ConvertUnits), - ]; + | From | To | Factor | + |-------------|-------------|----------| + | miles | kilometers | 1.60934 | + | kilometers | miles | 0.621371 | + | pounds | kilograms | 0.453592 | + | kilograms | pounds | 2.20462 | + """; + /// + /// Converts a value by the given factor. + /// + [AgentSkillScript("convert")] + [Description("Multiplies a value by a conversion factor and returns the result as JSON.")] private static string ConvertUnits(double value, double factor) { double result = Math.Round(value * factor, 4); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md index 506784256a..028cb05a37 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md @@ -1,12 +1,16 @@ # Class-Based Agent Skills Sample -This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`. +This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill` +with **attributes** for automatic script and resource discovery. ## What it demonstrates - Creating skills as classes that extend `AgentClassSkill` -- Bundling name, description, body, resources, and scripts into a single class +- Using `[AgentSkillResource]` on properties to define resources +- Using `[AgentSkillScript]` on methods to define scripts +- Automatic discovery (no need to override `Resources`/`Scripts`) - Using the `AgentSkillsProvider` constructor with class-based skills +- Overriding `SerializerOptions` for Native AOT compatibility ## Skills Included @@ -28,7 +32,7 @@ A `UnitConverterSkill` class that converts between common units. Defined in `Pro ```bash export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ### Run diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj index 7e7e9ef0fa..01abf37da8 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001 + $(NoWarn);MAAI001;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs index b8a9e8fbb1..28d5cb9ee9 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs @@ -8,11 +8,12 @@ // Three different skill sources are registered here: // 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk // 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill -// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill +// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill with attributes // // For simpler, single-source scenarios, see the earlier steps in this sample series // (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based). +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -22,7 +23,7 @@ using OpenAI.Responses; // --- Configuration --- string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // --- 1. Code-Defined Skill: volume-converter --- var volumeConverterSkill = new AgentInlineSkill( @@ -89,13 +90,15 @@ AgentResponse response = await agent.RunAsync( Console.WriteLine($"Agent: {response.Text}"); /// -/// A temperature-converter skill defined as a C# class. +/// A temperature-converter skill defined as a C# class using attributes for discovery. /// -internal sealed class TemperatureConverterSkill : AgentClassSkill +/// +/// Properties annotated with are automatically +/// discovered as skill resources, and methods annotated with +/// are automatically discovered as skill scripts. +/// +internal sealed class TemperatureConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "temperature-converter", @@ -110,29 +113,27 @@ internal sealed class TemperatureConverterSkill : AgentClassSkill 3. Present the result clearly with both temperature scales. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource( - "temperature-conversion-formulas", - """ - # Temperature Conversion Formulas + /// + /// A reference table of temperature conversion formulas. + /// + [AgentSkillResource("temperature-conversion-formulas")] + [Description("Formulas for converting between Fahrenheit, Celsius, and Kelvin.")] + public string ConversionFormulas => """ + # Temperature Conversion Formulas - | From | To | Formula | - |-------------|-------------|---------------------------| - | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | - | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | - | Celsius | Kelvin | K = °C + 273.15 | - | Kelvin | Celsius | °C = K − 273.15 | - """), - ]; - - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert-temperature", ConvertTemperature), - ]; + | From | To | Formula | + |-------------|-------------|---------------------------| + | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | + | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | + | Celsius | Kelvin | K = °C + 273.15 | + | Kelvin | Celsius | °C = K − 273.15 | + """; + /// + /// Converts a temperature value between scales. + /// + [AgentSkillScript("convert-temperature")] + [Description("Converts a temperature value from one scale to another.")] private static string ConvertTemperature(double value, string from, string to) { double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md index 14a0d089b9..7681359414 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/README.md @@ -45,7 +45,7 @@ Defined as `TemperatureConverterSkill` class in `Program.cs`. Converts °F↔°C ```bash export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ### Run diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj index 959fa29167..699672ded5 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001;CA1812 + $(NoWarn);MAAI001;CA1812;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs index f0c5a4c8a5..251503a918 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs @@ -13,6 +13,7 @@ // showing that DI works identically regardless of how the skill is defined. // When prompted with a question spanning both domains, the agent uses both skills. +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -22,7 +23,7 @@ using OpenAI.Responses; // --- Configuration --- string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // --- DI Container --- // Register application services that skill resources and scripts can resolve at execution time. @@ -62,8 +63,8 @@ var distanceSkill = new AgentInlineSkill( // Approach 2: Class-Based Skill with DI (AgentClassSkill) // ===================================================================== // Handles weight conversions (pounds ↔ kilograms). -// Resources and scripts are encapsulated in a class. Factory methods -// CreateResource and CreateScript accept delegates with IServiceProvider. +// Resources and scripts are discovered via reflection using attributes. +// Methods with an IServiceProvider parameter receive DI automatically. // // Alternatively, class-based skills can accept dependencies through their // constructor. Register the skill class itself in the ServiceCollection and @@ -113,14 +114,13 @@ Console.WriteLine($"Agent: {response.Text}"); /// /// /// This skill resolves from the DI container -/// in both its resource and script functions. This enables clean separation of -/// concerns and testability while retaining the class-based skill pattern. +/// in both its resource and script methods. Methods with an +/// parameter are automatically injected by the framework. Properties and methods annotated +/// with and +/// are automatically discovered via reflection. /// -internal sealed class WeightConverterSkill : AgentClassSkill +internal sealed class WeightConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "weight-converter", @@ -135,25 +135,27 @@ internal sealed class WeightConverterSkill : AgentClassSkill 3. Present the result clearly with both units. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource("weight-table", (IServiceProvider serviceProvider) => - { - var service = serviceProvider.GetRequiredService(); - return service.GetWeightTable(); - }), - ]; + /// + /// Returns the weight conversion table from the DI-registered . + /// + [AgentSkillResource("weight-table")] + [Description("Lookup table of multiplication factors for weight conversions.")] + private static string GetWeightTable(IServiceProvider serviceProvider) + { + var service = serviceProvider.GetRequiredService(); + return service.GetWeightTable(); + } - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) => - { - var service = serviceProvider.GetRequiredService(); - return service.Convert(value, factor); - }), - ]; + /// + /// Converts a value by the given factor using the DI-registered . + /// + [AgentSkillScript("convert")] + [Description("Multiplies a value by a conversion factor and returns the result as JSON.")] + private static string Convert(double value, double factor, IServiceProvider serviceProvider) + { + var service = serviceProvider.GetRequiredService(); + return service.Convert(value, factor); + } } // --------------------------------------------------------------------------- diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md index b284b745d5..296f90ef09 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/README.md @@ -45,7 +45,7 @@ Set the following environment variables: | Variable | Description | |---|---| | `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-4o-mini`) | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name (defaults to `gpt-5.4-mini`) | ## Running the Sample diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs index ff4628ef7a..46ef6807cd 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs @@ -12,7 +12,7 @@ using Microsoft.SemanticKernel.Connectors.InMemory; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; // Create a vector store to store the chat messages in. diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs index f1842eb634..c3a4fc291e 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var mem0ServiceUri = Environment.GetEnvironmentVariable("MEM0_ENDPOINT") ?? throw new InvalidOperationException("MEM0_ENDPOINT is not set."); var mem0ApiKey = Environment.GetEnvironmentVariable("MEM0_API_KEY") ?? throw new InvalidOperationException("MEM0_API_KEY is not set."); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 1132e9ef1d..402ae47a2d 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -15,7 +15,7 @@ using Microsoft.Agents.AI.Foundry; string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "memory-store-sample"; -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; // Create an AIProjectClient for Foundry with Azure Identity authentication. diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md index b2d52e9837..e863b2eada 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -14,7 +14,7 @@ This sample demonstrates how to create and run an agent that uses Microsoft Foun ## Prerequisites 1. Azure subscription with Microsoft Foundry project -2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002) +2. Azure OpenAI resource with a chat model deployment (e.g., gpt-5.4-mini) and an embedding model deployment (e.g., text-embedding-ada-002) 3. .NET 10.0 SDK 4. Azure CLI logged in (`az login`) @@ -26,7 +26,7 @@ export AZURE_AI_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api export AZURE_AI_MEMORY_STORE_ID="my_memory_store" # Model deployment names (models deployed in your Foundry project) -export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" export AZURE_AI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-ada-002" ``` diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/Program.cs index ab3a0376eb..053fabb7bb 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/Program.cs @@ -15,7 +15,7 @@ using OpenAI.Chat; using SampleApp; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/README.md index c1e35f5a88..4d6f88ce51 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/README.md @@ -13,7 +13,7 @@ This sample demonstrates how to create a custom `ChatHistoryProvider` that keeps - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) - An Azure OpenAI resource with: - - A chat deployment (e.g., `gpt-4o-mini`) + - A chat deployment (e.g., `gpt-5.4-mini`) - An embedding deployment (e.g., `text-embedding-3-large`) ## Configuration @@ -23,7 +23,7 @@ Set the following environment variables: | Variable | Description | Default | |---|---|---| | `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-5.4-mini` | | `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` | ## Running the Sample diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs index e82f420105..1b44ac3a54 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs @@ -7,7 +7,7 @@ using Microsoft.Agents.AI; using OpenAI.Responses; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; +var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; AIAgent agent = new ResponsesClient(new ApiKeyCredential(apiKey)) diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs index 12e30dc203..4fafbdf2b5 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.AI; using OpenAI; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5"; +var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; var client = new OpenAIClient(apiKey) .GetResponsesClient() diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Program.cs index 5efc6c0ad6..b1076de051 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Program.cs @@ -7,7 +7,7 @@ using OpenAI.Chat; using OpenAIChatClientSample; string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; +string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; // Create a ChatClient directly from OpenAIClient ChatClient chatClient = new OpenAIClient(apiKey).GetChatClient(model); diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/README.md index 9c91e964eb..78b3b7e8a4 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/README.md +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/README.md @@ -13,7 +13,7 @@ This sample demonstrates how to create an AI agent directly from an `OpenAI.Chat 1. Set the required environment variables: ```bash set OPENAI_API_KEY=your_api_key_here - set OPENAI_CHAT_MODEL_NAME=gpt-4o-mini + set OPENAI_CHAT_MODEL_NAME=gpt-5.4-mini ``` 2. Run the sample: diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs index dbd11ce3c6..7de38d2896 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs @@ -7,7 +7,7 @@ using OpenAI.Responses; using OpenAIResponseClientSample; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; +var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; // Create a ResponsesClient directly from OpenAIClient ResponsesClient responseClient = new OpenAIClient(apiKey).GetResponsesClient(); diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/README.md index 1acbe3137d..c0079ef5de 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/README.md +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/README.md @@ -13,7 +13,7 @@ This sample demonstrates how to create an AI agent directly from an `OpenAI.Resp 1. Set the required environment variables: ```bash set OPENAI_API_KEY=your_api_key_here - set OPENAI_CHAT_MODEL_NAME=gpt-4o-mini + set OPENAI_CHAT_MODEL_NAME=gpt-5.4-mini ``` 2. Run the sample: diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs index a8dc73839a..407a03951e 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs @@ -15,7 +15,7 @@ using OpenAI.Chat; using OpenAI.Conversations; string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; +string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; // Create a ConversationClient directly from OpenAIClient OpenAIClient openAIClient = new(apiKey); diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/README.md index 1b4d393418..fa53974817 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/README.md +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/README.md @@ -69,7 +69,7 @@ foreach (ClientResult result in getConversationItemsResults.GetRawPages()) 1. Set the required environment variables: ```powershell $env:OPENAI_API_KEY = "your_api_key_here" - $env:OPENAI_CHAT_MODEL_NAME = "gpt-4o-mini" + $env:OPENAI_CHAT_MODEL_NAME = "gpt-5.4-mini" ``` 2. Run the sample: diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs index e1db6d3f4f..b20d6d31a6 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs @@ -15,7 +15,7 @@ using Microsoft.SemanticKernel.Connectors.InMemory; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs index 2d6e30ae14..92de582569 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs @@ -14,7 +14,7 @@ using OpenAI.Chat; using Qdrant.Client; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; var afOverviewUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/overview/index.md"; var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md"; diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md index ec69136e14..7875a5d3cf 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md @@ -23,7 +23,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini $env:AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-3-large" # Optional, defaults to text-embedding-3-large ``` diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs index d4e3a40756..2b7c28d345 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; TextSearchProviderOptions textSearchOptions = new() { diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs index c68310eae5..8fc21174a1 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -14,7 +14,7 @@ using OpenAI.Responses; using OpenAI.VectorStores; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create an AI Project client and get an OpenAI client that works with the foundry service. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs index 5bf0918ab3..7e6b6942f6 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/Program.cs @@ -8,7 +8,7 @@ using Neo4j.AgentFramework.GraphRAG; using Neo4j.Driver; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var neo4jUri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? throw new InvalidOperationException("NEO4J_URI is not set."); var neo4jUsername = Environment.GetEnvironmentVariable("NEO4J_USERNAME") ?? "neo4j"; var neo4jPassword = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? throw new InvalidOperationException("NEO4J_PASSWORD is not set."); diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md index 418407e831..7295f89ca1 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/README.md @@ -15,7 +15,7 @@ The sample uses a Neo4j fulltext index for retrieval and a Cypher `RetrievalQuer ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" $env:NEO4J_URI="neo4j+s://your-instance.databases.neo4j.io" $env:NEO4J_USERNAME="neo4j" $env:NEO4J_PASSWORD="your-password" diff --git a/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs index 8ff4181a51..ea3a3ccddd 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs @@ -14,7 +14,7 @@ using OpenAI.Chat; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create a sample function tool that the agent can use. [Description("Get the weather for a given location.")] diff --git a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/Program.cs index 7e74315e7d..3acb1bc125 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/Program.cs @@ -14,7 +14,7 @@ using SampleApp; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create chat client to be used by chat client agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md index 9b9411f17e..babe679ea2 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step02_StructuredOutput/README.md @@ -28,7 +28,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` ## Run the sample diff --git a/dotnet/samples/02-agents/Agents/Agent_Step03_PersistedConversations/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step03_PersistedConversations/Program.cs index d3331cb2b8..d9404723f3 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step03_PersistedConversations/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step03_PersistedConversations/Program.cs @@ -11,7 +11,7 @@ using Microsoft.Agents.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create the agent // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs index 78a8952082..ce0dfac645 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step04_3rdPartyChatHistoryStorage/Program.cs @@ -18,7 +18,7 @@ using SampleApp; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create a vector store to store the chat messages in. // Replace this with a vector store implementation of your choice if you want to persist the chat history to disk. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step05_Observability/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step05_Observability/Program.cs index 20a0c252a2..3e7d7e6bd4 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step05_Observability/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step05_Observability/Program.cs @@ -11,7 +11,7 @@ using OpenTelemetry; using OpenTelemetry.Trace; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); // Create TracerProvider with console exporter diff --git a/dotnet/samples/02-agents/Agents/Agent_Step06_DependencyInjection/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step06_DependencyInjection/Program.cs index 218ab1a10e..9e712d7a73 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step06_DependencyInjection/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step06_DependencyInjection/Program.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create a host builder that we will register services with and then run. HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs index 5ab4406abd..82b9e16fdd 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Hosting; using ModelContextProtocol.Server; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md index 2feaee22e2..14b0835151 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md @@ -22,7 +22,7 @@ To use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) 1. Open a web browser and navigate to the URL displayed in the terminal. If not opened automatically, this will open the MCP Inspector interface. 1. In the MCP Inspector interface, add the following environment variables to allow your MCP server to access Microsoft Foundry Project to create and run the agent: - AZURE_AI_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Microsoft Foundry Project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME = gpt-4o-mini # Replace with your model deployment name + - AZURE_AI_MODEL_DEPLOYMENT_NAME = gpt-5.4-mini # Replace with your model deployment name 1. Find and click the `Connect` button in the MCP Inspector interface to connect to the MCP server. 1. As soon as the connection is established, open the `Tools` tab in the MCP Inspector interface and select the `Joker` tool from the list. 1. Specify your prompt as a value for the `query` argument, for example: `Tell me a joke about a pirate` and click the `Run Tool` button to run the tool. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs index 08e5b63139..fda3e872cc 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs @@ -9,7 +9,7 @@ using OpenAI.Chat; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; +var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/README.md b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/README.md index e70c09f513..ade4491942 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/README.md @@ -20,7 +20,7 @@ This sample demonstrates how to use image multi-modality with an AI agent. It sh Before running this sample, ensure you have: 1. An Azure OpenAI project set up -2. A compatible model deployment (e.g., gpt-4o) +2. A compatible model deployment (e.g., gpt-5.4-mini) 3. Azure CLI installed and authenticated ## Environment Variables @@ -29,7 +29,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" # Replace with your model deployment name (optional, defaults to gpt-4o) +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Replace with your model deployment name (optional, defaults to gpt-5.4-mini) ``` ## Run the sample diff --git a/dotnet/samples/02-agents/Agents/Agent_Step09_AsFunctionTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step09_AsFunctionTool/Program.cs index aca1a95ce4..a6d6f06a36 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step09_AsFunctionTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step09_AsFunctionTool/Program.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs index b568ef5867..844b40e405 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs @@ -15,7 +15,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; 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-5"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var stateStore = new Dictionary(); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/README.md b/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/README.md index ca52e8afa3..b5be2cdd4c 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/README.md @@ -24,5 +24,5 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5" # Optional, defaults to gpt-5 +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs index f7f3602fd3..2ffc968d84 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs @@ -15,7 +15,7 @@ using Microsoft.Extensions.AI; // Get Microsoft Foundry configuration from environment variables var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; +var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Get a client to create/retrieve server side agents with // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/README.md b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/README.md index 74895e0cdf..b03f027fc2 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/README.md @@ -27,7 +27,7 @@ Attempting to use function middleware on agents that do not wrap a ChatClientAge 1. Environment variables: - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint - - `AZURE_OPENAI_DEPLOYMENT_NAME`: Chat deployment name (optional; defaults to `gpt-4o`) + - `AZURE_OPENAI_DEPLOYMENT_NAME`: Chat deployment name (optional; defaults to `gpt-5.4-mini`) 2. Sign in with Azure CLI (PowerShell): ```powershell az login diff --git a/dotnet/samples/02-agents/Agents/Agent_Step12_Plugins/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step12_Plugins/Program.cs index 2e9b405183..15771197d2 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step12_Plugins/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step12_Plugins/Program.cs @@ -17,7 +17,7 @@ using Microsoft.Extensions.DependencyInjection; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create a service collection to hold the agent plugin and its dependencies. ServiceCollection services = new(); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs index 915bc5c376..dccb13beaf 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Construct the agent, and provide a factory to create an in-memory chat message store with a reducer that keeps only the last 2 non-system messages. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs index f474b938a6..215a790fb7 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/README.md b/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/README.md index e898733bc3..2a668f9e6c 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/README.md @@ -23,5 +23,5 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` \ No newline at end of file diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs index d329816f13..92222f64e7 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -10,7 +10,7 @@ using Microsoft.Agents.AI; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var deepResearchDeploymentName = Environment.GetEnvironmentVariable("AZURE_AI_REASONING_DEPLOYMENT_NAME") ?? "o3-deep-research"; -var modelDeploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +var modelDeploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var bingConnectionId = Environment.GetEnvironmentVariable("AZURE_AI_BING_CONNECTION_ID") ?? throw new InvalidOperationException("AZURE_AI_BING_CONNECTION_ID is not set."); // Configure extended network timeout for long-running Deep Research tasks. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md index b04e65cafc..ee3c0935a2 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md @@ -13,7 +13,7 @@ Before running this sample, ensure you have: 1. A Microsoft Foundry project set up 2. A deep research model deployment (e.g., o3-deep-research) -3. A model deployment (e.g., gpt-4o) +3. A model deployment (e.g., gpt-5.4-mini) 4. A Bing Connection configured in your Microsoft Foundry project 5. Azure CLI installed and authenticated @@ -45,5 +45,5 @@ $env:AZURE_AI_BING_CONNECTION_ID="/subscriptions//resourceGroups//pr # Optional, defaults to o3-deep-research $env:AZURE_AI_REASONING_DEPLOYMENT_NAME="o3-deep-research" -# Optional, defaults to gpt-4o -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" +# Optional, defaults to gpt-5.4-mini +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" diff --git a/dotnet/samples/02-agents/Agents/Agent_Step16_Declarative/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step16_Declarative/Program.cs index 215833c795..9fd5f29f93 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step16_Declarative/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step16_Declarative/Program.cs @@ -8,7 +8,7 @@ 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"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create the chat client // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs index 0fd21833e1..69946be8e6 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs @@ -18,7 +18,7 @@ using SampleApp; using MEAI = 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-5-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // A sample function to load the next three calendar events for the user. Func> loadNextThreeCalendarEvents = async () => diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index ce0a4a294d..b8d774e74d 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -16,7 +16,7 @@ using Microsoft.Agents.AI.Compaction; 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"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md index 0640a42f21..be4610a152 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -68,7 +68,7 @@ Order strategies from **least aggressive** to **most aggressive**. The pipeline ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` ## Running the Sample @@ -110,7 +110,7 @@ IEnumerable compacted = await CompactionProvider.CompactAsync( The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost: ```csharp -IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); +IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-5.4-mini").AsIChatClient(); new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000)) ``` diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs index 4e526f8f41..18f4eccd5f 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs @@ -23,7 +23,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var store = Environment.GetEnvironmentVariable("AZURE_OPENAI_RESPONSES_STORE") ?? "false"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md index 9b96562a66..25ac616275 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md @@ -45,7 +45,7 @@ ChatClientAgent ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` ## Running the Sample diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index 6cdf80625b..f429d8156c 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -59,7 +59,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` If the variables are not set, you will be prompted for the values when running the samples. diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs index 9a22d1cddf..0803418ca4 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -10,7 +10,7 @@ using Azure.Identity; using Microsoft.Agents.AI.Foundry; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; const string JokerName = "JokerAgent"; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md index 738a1d2e42..8179c3b299 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md @@ -5,7 +5,7 @@ This sample demonstrates the full lifecycle of a `FoundryAgent` backed by a serv ## Prerequisites - A Microsoft Foundry project endpoint -- A model deployment name (defaults to `gpt-4o-mini`) +- A model deployment name (defaults to `gpt-5.4-mini`) - Azure CLI installed and authenticated ## Environment Variables @@ -13,7 +13,7 @@ This sample demonstrates the full lifecycle of a `FoundryAgent` backed by a serv | Variable | Description | Required | | --- | --- | --- | | `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | Yes | -| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name | No (defaults to `gpt-4o-mini`) | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name | No (defaults to `gpt-5.4-mini`) | ## Running the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs index cd89116db7..403bae05c2 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs @@ -7,7 +7,7 @@ using Azure.Identity; using Microsoft.Agents.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md index 88eebb2a82..612bd21891 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md @@ -22,7 +22,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs index 15b880102f..e00982199f 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs @@ -8,7 +8,7 @@ using Azure.Identity; using Microsoft.Agents.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md index a895981952..f34c486b53 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md @@ -23,7 +23,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs index 6057d71895..317474aa6e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs @@ -10,7 +10,7 @@ using Azure.Identity; using Microsoft.Agents.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md index 9d35133b39..ee91d935ef 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md @@ -23,7 +23,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs index 7935835a24..e1b4548a04 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs @@ -16,7 +16,7 @@ static string GetWeather([Description("The location to get the weather for.")] s AITool tool = AIFunctionFactory.Create(GetWeather); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md index 9aa2a4ee69..dfad8d0b5c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md @@ -24,7 +24,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs index 9a85dba83b..3943f32295 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -13,7 +13,7 @@ static string GetWeather([Description("The location to get the weather for.")] s => $"The weather in {location} is cloudy with a high of 15°C."; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md index 430c57548d..a832d308e9 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs index 28cd4cf6bc..07636f12dd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs @@ -13,7 +13,7 @@ using SampleApp; #pragma warning disable CA5399 string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md index f5421b3f64..f2770d6055 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md @@ -18,7 +18,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs index c9774cb1bf..18ce97ef88 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs @@ -8,7 +8,7 @@ using Azure.Identity; using Microsoft.Agents.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md index a8cfda07ca..42074f2972 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs index 68bfb91af0..e4b451fea3 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs @@ -11,7 +11,7 @@ using OpenTelemetry.Trace; string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create TracerProvider with console exporter. string sourceName = Guid.NewGuid().ToString("N"); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md index cb3bb729ff..70e10d805b 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" $env:APPLICATIONINSIGHTS_CONNECTION_STRING="..." # Optional ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs index 52a7d73132..019323e56f 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Hosting; using SampleApp; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md index c9ad936fd6..52bb3f591e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs index 87f80297af..b07917ee01 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.AI; using ModelContextProtocol.Client; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Connect to the Microsoft Learn MCP server via HTTP (Streamable HTTP transport). Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md index 72437e0802..ae7fffcb2a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs index 2fdc150be9..076237c072 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md index 370bf896cf..12d5fb6284 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md @@ -12,14 +12,14 @@ This sample demonstrates how to use image multi-modality with an agent. ## Prerequisites - .NET 10 SDK or later -- Microsoft Foundry service endpoint and a vision-capable model deployment (e.g., `gpt-4o`) +- Microsoft Foundry service endpoint and a vision-capable model deployment (e.g., `gpt-5.4-mini`) - Azure CLI installed and authenticated (`az login`) Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs index 3715ab194f..06d2a4dc18 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs @@ -13,7 +13,7 @@ static string GetWeather([Description("The location to get the weather for.")] s => $"The weather in {location} is cloudy with a high of 15°C."; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md index 7d361305b9..4fe155d76d 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs index e37bf89639..27240b8372 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs @@ -21,7 +21,7 @@ static string GetDateTime() => DateTimeOffset.Now.ToString(); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md index 26b12a22b8..45543329bc 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md @@ -20,7 +20,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs index 3af63090c5..966a7bba12 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs @@ -17,7 +17,7 @@ using Microsoft.Extensions.DependencyInjection; using SampleApp; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; const string AssistantInstructions = "You are a helpful assistant that helps people find information."; const string AssistantName = "PluginAssistant"; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md index 8cc2f59116..e10025b7f3 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs index 8d7f598a4a..c4661ae49e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs @@ -13,7 +13,7 @@ const string AgentInstructions = "You are a personal math tutor. When asked a ma const string AgentName = "CoderAgent-RAPI"; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md index 1a8cfc8aae..db63f82e9c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md @@ -18,7 +18,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj index 7bd94bd716..9b717d9447 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);OPENAICUA001 + $(NoWarn);OPENAICUA001;MEAI001 @@ -19,13 +19,13 @@ - + Always - + Always - + Always diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.jpg new file mode 100644 index 0000000000..372916a298 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.jpg differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png deleted file mode 100644 index 5984b95cb3..0000000000 Binary files a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png and /dev/null differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.jpg new file mode 100644 index 0000000000..02920b3fd7 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.jpg differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png deleted file mode 100644 index ed3ab3d8d4..0000000000 Binary files a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png and /dev/null differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.jpg new file mode 100644 index 0000000000..3d6100f5b7 Binary files /dev/null and b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.jpg differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png deleted file mode 100644 index 04d76e2075..0000000000 Binary files a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png and /dev/null differ diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs index 1ee421b465..d1df3e7ccd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI; using OpenAI.Responses; namespace Demo.ComputerUse; @@ -16,83 +17,77 @@ internal enum SearchState internal static class ComputerUseUtil { - /// - /// Load and convert screenshot images to base64 data URLs. - /// - internal static Dictionary LoadScreenshotAssets() + internal static async Task> UploadScreenshotAssetsAsync(IHostedFileClient fileClient) { - string baseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets"); + string assetsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets"); - ReadOnlySpan<(string key, string fileName)> screenshotFiles = - [ - ("browser_search", "cua_browser_search.png"), - ("search_typed", "cua_search_typed.png"), - ("search_results", "cua_search_results.png") - ]; + (string key, string fileName)[] files = + [ + ("browser_search", "cua_browser_search.jpg"), + ("search_typed", "cua_search_typed.jpg"), + ("search_results", "cua_search_results.jpg") + ]; - Dictionary screenshots = []; - foreach (var (key, fileName) in screenshotFiles) + Dictionary screenshots = []; + + foreach (var (key, fileName) in files) { - string fullPath = Path.GetFullPath(Path.Combine(baseDir, fileName)); - screenshots[key] = File.ReadAllBytes(fullPath); + HostedFileContent result = await fileClient.UploadAsync( + Path.Combine(assetsDir, fileName), new HostedFileClientOptions() { Purpose = "assistants" }); + screenshots[key] = result.FileId; } return screenshots; } + internal static async Task EnsureDeleteScreenshotAssetsAsync(IHostedFileClient fileClient, Dictionary screenshots) + { + foreach (var (_, fileId) in screenshots) + { + try + { + await fileClient.DeleteAsync(fileId); + } + catch + { + } + } + } + /// - /// Process a computer action and simulate its execution. + /// Simulates executing a computer action by advancing the state + /// and returning the screenshot file ID for the new state. /// - internal static (SearchState CurrentState, byte[] ImageBytes) HandleComputerActionAndTakeScreenshot( + internal static async Task<(SearchState State, string FileId)> GetScreenshotAsync( ComputerCallAction action, SearchState currentState, - Dictionary screenshots) + Dictionary screenshots) { - Console.WriteLine($"Simulating the execution of computer action: {action.Kind}"); - - SearchState newState = DetermineNextState(action, currentState); - string imageKey = GetImageKey(newState); - - return (newState, screenshots[imageKey]); - } - - private static SearchState DetermineNextState(ComputerCallAction action, SearchState currentState) - { - string actionType = action.Kind.ToString(); - - if (actionType.Equals("type", StringComparison.OrdinalIgnoreCase) && action.TypeText is not null) + if (action.Kind == ComputerCallActionKind.Wait) { - return SearchState.Typed; + await Task.Delay(TimeSpan.FromSeconds(5)); } - if (IsEnterKeyAction(action, actionType)) + SearchState nextState = action.Kind switch { - Console.WriteLine(" -> Detected ENTER key press"); - return SearchState.PressedEnter; - } + ComputerCallActionKind.Click when currentState == SearchState.Typed => SearchState.PressedEnter, + ComputerCallActionKind.Type when action.TypeText is not null => SearchState.Typed, + ComputerCallActionKind.KeyPress when IsEnterKey(action) => SearchState.PressedEnter, + _ => currentState + }; - if (actionType.Equals("click", StringComparison.OrdinalIgnoreCase) && currentState == SearchState.Typed) + string imageKey = nextState switch { - Console.WriteLine(" -> Detected click after typing"); - return SearchState.PressedEnter; - } + SearchState.PressedEnter => "search_results", + SearchState.Typed => "search_typed", + _ => "browser_search" + }; - return currentState; + return (nextState, screenshots[imageKey]); } - private static bool IsEnterKeyAction(ComputerCallAction action, string actionType) - { - return (actionType.Equals("key", StringComparison.OrdinalIgnoreCase) || - actionType.Equals("keypress", StringComparison.OrdinalIgnoreCase)) && - action.KeyPressKeyCodes is not null && - (action.KeyPressKeyCodes.Contains("Return", StringComparer.OrdinalIgnoreCase) || - action.KeyPressKeyCodes.Contains("Enter", StringComparer.OrdinalIgnoreCase)); - } - - private static string GetImageKey(SearchState state) => state switch - { - SearchState.PressedEnter => "search_results", - SearchState.Typed => "search_typed", - _ => "browser_search" - }; + private static bool IsEnterKey(ComputerCallAction action) => + action.KeyPressKeyCodes is not null && + (action.KeyPressKeyCodes.Contains("Return", StringComparer.OrdinalIgnoreCase) || + action.KeyPressKeyCodes.Contains("Enter", StringComparer.OrdinalIgnoreCase)); } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index d79c1122b7..00e4e02843 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -1,146 +1,109 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Computer Use Tool with a ChatClientAgent. +// This sample shows how to use the Computer Use tool with AIProjectClient.AsAIAgent(...). using Azure.AI.Projects; using Azure.Identity; +using Demo.ComputerUse; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; -namespace Demo.ComputerUse; +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_COMPUTER_USE_DEPLOYMENT_NAME") ?? "computer-use-preview"; -internal sealed class Program +AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential()); +using IHostedFileClient fileClient = projectClient.GetProjectOpenAIClient().AsIHostedFileClient(); + +AIAgent agent = projectClient.AsAIAgent( + model: deploymentName, + name: "ComputerAgent", + instructions: "You are a computer automation assistant.", + tools: [FoundryAITool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769)]); + +Dictionary screenshots = []; + +try { - private static async Task Main(string[] args) + // Upload pre-captured screenshots that simulate browser state transitions. + screenshots = await ComputerUseUtil.UploadScreenshotAssetsAsync(fileClient); + + // Enable auto-truncation for the Responses API. + ChatClientAgentRunOptions runOptions = new() { - const string AgentInstructions = @" - You are a computer automation assistant. - - Be direct and efficient. When you reach the search results page, read and describe the actual search result titles and descriptions you can see. - "; - - const string AgentName = "ComputerAgent-RAPI"; - - string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); - string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "computer-use-preview"; - - // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. - // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid - // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. - AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - - // Create a AIAgent with ComputerUseTool. - AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, - instructions: AgentInstructions, - name: AgentName, - description: "Computer automation agent with screen interaction capabilities.", - tools: [ - FoundryAITool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769), - ]); - - await InvokeComputerUseAgentAsync(agent); - } - - private static async Task InvokeComputerUseAgentAsync(AIAgent agent) - { - // Load screenshot assets - Dictionary screenshots = ComputerUseUtil.LoadScreenshotAssets(); - - ChatOptions chatOptions = new(); - CreateResponseOptions responseCreationOptions = new() + ChatOptions = new ChatOptions { - TruncationMode = ResponseTruncationMode.Auto - }; - chatOptions.RawRepresentationFactory = (_) => responseCreationOptions; - ChatClientAgentRunOptions runOptions = new(chatOptions) - { - AllowBackgroundResponses = true, - }; - - ChatMessage message = new(ChatRole.User, [ - new TextContent("I need you to help me search for 'OpenAI news'. Please type 'OpenAI news' and submit the search. Once you see search results, the task is complete."), - new DataContent(new BinaryData(screenshots["browser_search"]), "image/png") - ]); - - // Initial request with screenshot - start with Bing search page - Console.WriteLine("Starting computer automation session (initial screenshot: cua_browser_search.png)..."); - - // We use PreviousResponseId to chain calls, sending only the new computer_call_output items - // instead of re-sending the full context. - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse response = await agent.RunAsync(message, session: session, options: runOptions); - - // Main interaction loop - const int MaxIterations = 10; - int iteration = 0; - // Initialize state machine - SearchState currentState = SearchState.Initial; - - while (true) - { - // 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. - runOptions.ContinuationToken = token; - - response = await agent.RunAsync(session, runOptions); - } - - // Clear the continuation token so the next RunAsync call is a fresh request. - runOptions.ContinuationToken = null; - - Console.WriteLine($"Agent response received (ID: {response.ResponseId})"); - - if (iteration >= MaxIterations) - { - Console.WriteLine($"\nReached maximum iterations ({MaxIterations}). Stopping."); - break; - } - - iteration++; - Console.WriteLine($"\n--- Iteration {iteration} ---"); - - // Check for computer calls in the response - IEnumerable computerCallResponseItems = response.Messages - .SelectMany(x => x.Contents) - .Where(c => c.RawRepresentation is ComputerCallResponseItem and not null) - .Select(c => (ComputerCallResponseItem)c.RawRepresentation!); - - ComputerCallResponseItem? firstComputerCall = computerCallResponseItems.FirstOrDefault(); - if (firstComputerCall is null) - { - Console.WriteLine("No computer call actions found. Ending interaction."); - Console.WriteLine($"Final Response: {response}"); - break; - } - - // Process the first computer call response - ComputerCallAction action = firstComputerCall.Action; - string currentCallId = firstComputerCall.CallId; - - Console.WriteLine($"Processing computer call (ID: {currentCallId})"); - - // Simulate executing the action and taking a screenshot - (SearchState CurrentState, byte[] ImageBytes) screenInfo = ComputerUseUtil.HandleComputerActionAndTakeScreenshot(action, currentState, screenshots); - currentState = screenInfo.CurrentState; - - Console.WriteLine("Sending action result back to agent..."); - - // Send only the computer_call_output — the session carries PreviousResponseId for context continuity. - AIContent callOutput = new() - { - RawRepresentation = new ComputerCallOutputResponseItem( - currentCallId, - output: ComputerCallOutput.CreateScreenshotOutput(new BinaryData(screenInfo.ImageBytes), "image/png")) - }; - - response = await agent.RunAsync([new ChatMessage(ChatRole.User, [callOutput])], session: session, options: runOptions); + RawRepresentationFactory = (_) => new CreateResponseOptions() { TruncationMode = ResponseTruncationMode.Auto }, } + }; + + // Send the initial request with a screenshot of the browser. + ChatMessage message = new(ChatRole.User, [ + new TextContent("Search for 'OpenAI news'. Type it and submit. Once you see results, the task is complete."), + new AIContent() { RawRepresentation = ResponseContentPart.CreateInputImagePart(imageFileId: screenshots["browser_search"], imageDetailLevel: ResponseImageDetailLevel.High) } + ]); + + Console.WriteLine("Starting computer use session..."); + + AgentSession session = await agent.CreateSessionAsync(); + AgentResponse response = await agent.RunAsync(message, session: session, options: runOptions); + + SearchState currentState = SearchState.Initial; + + for (int i = 0; i < 10; i++) + { + // Find the next computer call action. + ComputerCallResponseItem? computerCall = response.Messages + .SelectMany(m => m.Contents) + .Select(c => c.RawRepresentation as ComputerCallResponseItem) + .FirstOrDefault(item => item is not null); + + if (computerCall is null) + { + if (currentState == SearchState.PressedEnter) + { + Console.WriteLine("No more computer actions. Done."); + Console.WriteLine(response); + break; + } + + // Check if the agent is asking for confirmation to proceed, and if so, respond affirmatively. + TextContent? textContent = response.Messages + .Where(m => m.Role == ChatRole.Assistant) + .SelectMany(m => m.Contents.OfType()) + .FirstOrDefault(); + + if (textContent?.Text is { } text && ( + text.Contains("Would you like me") || + text.Contains("Should I") || + text.Contains("proceed") || + text.Contains('?'))) + { + response = await agent.RunAsync("Please proceed.", session, runOptions); + continue; + } + + break; + } + + Console.WriteLine($"[{i + 1}] Action: {computerCall!.Action.Kind}"); + + // Simulate the action and get the resulting screenshot. + (currentState, string fileId) = await ComputerUseUtil.GetScreenshotAsync(computerCall.Action, currentState, screenshots); + + // Send the screenshot back as the computer call output. + AIContent callOutput = new() + { + RawRepresentation = new ComputerCallOutputResponseItem( + computerCall.CallId, + output: ComputerCallOutput.CreateScreenshotOutput(screenshotImageFileId: fileId)) + }; + + response = await agent.RunAsync([new ChatMessage(ChatRole.User, [callOutput])], session: session, options: runOptions); } } +finally +{ + await ComputerUseUtil.EnsureDeleteScreenshotAssetsAsync(fileClient, screenshots); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md index ecaa18e10f..eee05e2a69 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md @@ -1,13 +1,39 @@ -# Computer Use with the Responses API +# Computer Use with the Responses API -This sample shows how to use the Computer Use tool with a `ChatClientAgent` using the Responses API directly. +This sample shows how to use the Computer Use tool with `AIProjectClient.AsAIAgent(...)`. ## What this sample demonstrates -- Using `FoundryAITool.CreateComputerTool()` with `ChatClientAgent` +- Using `FoundryAITool.CreateComputerTool()` to add computer use capabilities - Processing computer call actions (click, type, key press) - Managing the computer use interaction loop with screenshots -- Handling the Azure Agents API workaround for `previous_response_id` with `computer_call_output` + +For more information, see [Use the computer tool](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/computer-use?pivots=csharp). + +## How the simulation works + +In a real computer use scenario, the model controls a virtual keyboard and mouse to interact with a live browser — typing text, clicking buttons, and pressing keys. The host application captures a screenshot after each action and sends it back to the model so it can decide what to do next. + +**This sample does not connect to a real browser.** Instead, it intercepts the model's actions and returns pre-captured screenshots as if the actions were actually performed. No real typing, clicking, or key presses happen — the sample fakes the environment so you can explore the computer use protocol without any browser automation setup. + +### State transitions + +The model receives a screenshot as input, analyzes it, and responds with a computer action as output. The sample maps each action to a new state and returns the corresponding screenshot: + +| Step | Model Action | What Happens | Screenshot Sent Back to Model | +|------|-----------------|-------------------------------------------|--------------------------------------------------------------| +| 1 | | Session starts with the user prompt | `cua_browser_search.jpg` — empty search page | +| 2 | Click | Model clicks the search box to focus it | `cua_browser_search.jpg` — same page | +| 3 | Type | Model types the search query into the box | `cua_search_typed.jpg` — search text visible in the box | +| 3a | *(text response)* | Model may ask for confirmation instead of acting | `cua_search_typed.jpg` — same page | +| 4 | KeyPress Enter | Model presses Enter to submit the search | `cua_search_results.jpg` — search results page | + +### Interaction loop + +1. The user prompt and the initial screenshot (`cua_browser_search.jpg` — an empty search page) are sent to the model as input. +2. The model analyzes the screenshot and responds with a computer action (e.g., click on the search box to focus it, then type search text, then press Enter). +3. The sample intercepts the action, advances the state, and sends back the next pre-captured screenshot as if the action was performed on a real browser. +4. Steps 2–3 repeat until the model stops requesting actions or the iteration limit is reached. ## Prerequisites @@ -19,7 +45,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="computer-use-preview" +$env:AZURE_AI_COMPUTER_USE_DEPLOYMENT_NAME="computer-use-preview" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs index c01a951df4..1a2d870342 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs @@ -10,7 +10,7 @@ using OpenAI.Assistants; using OpenAI.Files; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; const string AgentInstructions = "You are a helpful assistant that can search through uploaded files to answer questions."; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md index 5ce7f11cda..45818ca354 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs index 1968b89ca1..d47f4b0078 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs @@ -10,7 +10,7 @@ using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md index 52a11ec1dd..05227fdd98 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs index e244bb3cb0..11048c8154 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs @@ -22,7 +22,7 @@ BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ ]); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md index fc48fd8744..18bffeacd6 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" $env:AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID="your-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/your-bing-connection" $env:AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME="your-instance-name" # The Bing Custom Search configuration name (from Azure portal) ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs index a5f8bf845a..186acb9da5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs @@ -20,7 +20,7 @@ var sharepointOptions = new SharePointGroundingToolOptions(); sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId)); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md index bfaacee860..1049eb4ebc 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" $env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/SharepointTestTool" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs index 69954b9483..ccc3c0dcf0 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs @@ -17,7 +17,7 @@ var fabricToolOptions = new FabricDataAgentToolOptions(); fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md index 4a4d31e151..03536262d2 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" $env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/FabricTestTool" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs index 20d74b0401..da1652536b 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs @@ -12,7 +12,7 @@ const string AgentInstructions = "You are a helpful assistant that can search th const string AgentName = "WebSearchAgent-RAPI"; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md index 1a9d80106d..81d37e6ff5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md @@ -18,7 +18,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs index b00cc2f801..5ba9ccedb1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs @@ -15,7 +15,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? $"foundry-memory-sample-{Guid.NewGuid():N}"; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md index b8020553af..63af9cd9c8 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md @@ -20,7 +20,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" $env:AZURE_AI_MEMORY_STORE_ID="your-memory-store-name" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs index c9f8c0060b..772d1a17f9 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs @@ -31,7 +31,7 @@ Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => List wrappedTools = mcpTools.Select(tool => (AITool)new LoggingMcpTool(tool)).ToList(); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md index 84456df2b7..c3464efe5d 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md index 8cc964e227..c65cb24acd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -10,7 +10,7 @@ The simplest way to create a Foundry agent is using the `FoundryAgent` type dire FoundryAgent agent = new( new Uri(endpoint), new AzureCliCredential(), - model: "gpt-4o-mini", + model: "gpt-5.4-mini", instructions: "You are good at telling jokes.", name: "JokerAgent"); @@ -38,7 +38,7 @@ Set: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` Some samples require extra tool-specific environment variables. See each sample for details. diff --git a/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Program.cs b/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Program.cs index 270acfb946..78634eb62d 100644 --- a/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Program.cs +++ b/dotnet/samples/02-agents/DeclarativeAgents/ChatClient/Program.cs @@ -9,7 +9,7 @@ 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"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create the chat client // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs index d35c1385cc..ca3e38ad24 100644 --- a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs +++ b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs @@ -44,7 +44,7 @@ internal static class Program // Set up the Azure OpenAI client var endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "gpt-4o-mini"; + var deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md index 0bf24dfb26..48deaad94e 100644 --- a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md +++ b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md @@ -11,7 +11,7 @@ The DevUI provides an interactive web interface for testing and debugging AI age Set the following environment variables: - `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint URL (required) -- `AZURE_OPENAI_DEPLOYMENT_NAME` - Your deployment name (defaults to "gpt-4o-mini") +- `AZURE_OPENAI_DEPLOYMENT_NAME` - Your deployment name (defaults to "gpt-5.4-mini") ## Running the Sample diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Program.cs index d773332fdd..d1e80b65df 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Program.cs @@ -10,7 +10,7 @@ using ModelContextProtocol.Client; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create an MCPClient for the GitHub server await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new() diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/README.md b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/README.md index 426bb67a97..2c6503e998 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server/README.md @@ -19,7 +19,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` ## Setup and Running diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs index d741d60701..aaa4b0d11a 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs @@ -14,7 +14,7 @@ using ModelContextProtocol.Client; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // We can customize a shared HttpClient with a custom handler if desired using var sharedHandler = new SocketsHttpHandler diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/README.md b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/README.md index 7c646ec915..59c0af0c3f 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/README.md @@ -27,7 +27,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` ## Setup and Running diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index 7d64ad9399..ce27d036f9 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; +var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Get a client to create/retrieve server side agents with. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md index d03833b826..c1a62a9080 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md @@ -12,5 +12,5 @@ Set the following environment variables: ```powershell $env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index 9cbaecad4b..8c94ebc027 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/README.md @@ -35,7 +35,7 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` If the variables are not set, you will be prompted for the values when running the samples. diff --git a/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs index f8715e4543..59d47d57b1 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // **** MCP Tool with Auto Approval **** // ************************************* diff --git a/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md b/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md index c311edae40..6094a20304 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/README.md @@ -13,5 +13,5 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs index 41b622fdad..eb5cb2db94 100644 --- a/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs @@ -33,7 +33,7 @@ public static class Program { // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the executors diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index 458c280ca1..cab2e0162d 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -25,7 +25,7 @@ public static class Program // Set up the Azure AI Project client var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); // Create agents diff --git a/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs b/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs index a8d42b5342..0b6b821f9f 100644 --- a/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs +++ b/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs @@ -43,7 +43,7 @@ public static class Program private static async Task Main() { var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/README.md b/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/README.md index f569b836e9..cda9be4673 100644 --- a/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/README.md +++ b/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/README.md @@ -45,7 +45,7 @@ The sample demonstrates continuous event-driven execution with inline approval h - Azure OpenAI or OpenAI configured with the required environment variables - `AZURE_OPENAI_ENDPOINT` environment variable set -- `AZURE_OPENAI_DEPLOYMENT_NAME` environment variable (defaults to "gpt-4o-mini") +- `AZURE_OPENAI_DEPLOYMENT_NAME` environment variable (defaults to "gpt-5.4-mini") ## Running the Sample diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs index bc9faff3b0..004267d978 100644 --- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -39,7 +39,7 @@ public static class Program { // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the workflow and turn it into an agent diff --git a/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj b/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj index 35897932e0..b4a3f86230 100644 --- a/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj +++ b/dotnet/samples/03-workflows/Concurrent/Concurrent/Concurrent.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs index 57b650ce82..38e89653d6 100644 --- a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using Azure.AI.OpenAI; +using System.Text; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; @@ -31,22 +32,26 @@ public static class Program { private static async Task Main() { - // Set up the Azure OpenAI client - var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // Set up the Azure AI Project client + var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; + var chatClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()) + .ProjectOpenAIClient.GetChatClient(deploymentName).AsIChatClient(); // Create the executors - ChatClientAgent physicist = new( + var physicist = new ChatClientAgent( chatClient, name: "Physicist", instructions: "You are an expert in physics. You answer questions from a physics perspective." - ); - ChatClientAgent chemist = new( + ).BindAsExecutor(new AIAgentHostOptions { ForwardIncomingMessages = false }); + + var chemist = new ChatClientAgent( chatClient, name: "Chemist", instructions: "You are an expert in chemistry. You answer questions from a chemistry perspective." - ); + ).BindAsExecutor(new AIAgentHostOptions { ForwardIncomingMessages = false }); + var startExecutor = new ConcurrentStartExecutor(); var aggregationExecutor = new ConcurrentAggregationExecutor(); @@ -61,11 +66,30 @@ public static class Program await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input: "What is temperature?"); await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { - if (evt is WorkflowOutputEvent output) + switch (evt) { - Console.WriteLine($"Workflow completed with results:\n{output.Data}"); + case WorkflowOutputEvent workflowOutput: + Console.WriteLine($"Workflow completed with results:\n{workflowOutput.Data}"); + break; + + case WorkflowErrorEvent workflowError: + WriteError(workflowError.Exception?.ToString() ?? "Unknown workflow error occurred"); + break; + + case ExecutorFailedEvent executorFailed: + WriteError($"Executor '{executorFailed.ExecutorId}' failed with {( + executorFailed.Data == null ? "unknown error" : $"exception {executorFailed.Data}" + )}."); + break; } } + + void WriteError(string error) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Write(error); + Console.ResetColor(); + } } } @@ -92,7 +116,7 @@ internal sealed partial class ConcurrentStartExecutor() : // the message but will not start processing until they receive a turn token. await context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken); // Broadcast the turn token to kick off the agents. - await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken); + await context.SendMessageAsync(new TurnToken(emitEvents: false), cancellationToken: cancellationToken); } } @@ -116,11 +140,19 @@ internal sealed partial class ConcurrentAggregationExecutor() : public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._messages.AddRange(message); + } - if (this._messages.Count == 2) + protected override ValueTask OnMessageDeliveryFinishedAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + StringBuilder resultBuilder = new(); + foreach (ChatMessage m in this._messages) { - var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.AuthorName}: {m.Text}")); - await context.YieldOutputAsync(formattedMessages, cancellationToken); + resultBuilder.AppendLine($"{m.AuthorName}: {m.Text}"); + resultBuilder.AppendLine(); } + + this._messages.Clear(); + + return context.YieldOutputAsync(resultBuilder.ToString(), cancellationToken); } } diff --git a/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs index de4f252deb..370011d80f 100644 --- a/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -36,7 +36,7 @@ public static class Program { // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents diff --git a/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs b/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs index 7dd5927711..4e85039af8 100644 --- a/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs +++ b/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs @@ -37,7 +37,7 @@ public static class Program { // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents diff --git a/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs index d41c0ff275..3dfb13bf60 100644 --- a/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs +++ b/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs @@ -39,7 +39,7 @@ public static class Program { // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml index 7b942cb2bd..a626a0ac11 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml @@ -9,7 +9,7 @@ # 4. Uses an agent to summarize the results # # Example input: -# gpt-4.1 +# gpt-5.4-mini # kind: Workflow trigger: diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs index 560b6d25ca..24d5a6e267 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs @@ -31,7 +31,7 @@ namespace Demo.Workflows.Declarative.InvokeMcpTool; /// /// /// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Microsoft Foundry MCP server to get AI model details. -/// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input, +/// When you run the sample, provide an AI model (e.g. gpt-5.4-mini) as input, /// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results. /// /// diff --git a/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/Program.cs b/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/Program.cs index f1911dc43f..acf5d26769 100644 --- a/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/Program.cs @@ -72,7 +72,7 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) .AsIChatClient() diff --git a/dotnet/samples/03-workflows/_StartHere/02_AgentsInWorkflows/Program.cs b/dotnet/samples/03-workflows/_StartHere/02_AgentsInWorkflows/Program.cs index 990b5f9f17..d0bc5d4749 100644 --- a/dotnet/samples/03-workflows/_StartHere/02_AgentsInWorkflows/Program.cs +++ b/dotnet/samples/03-workflows/_StartHere/02_AgentsInWorkflows/Program.cs @@ -29,7 +29,7 @@ public static class Program { // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents diff --git a/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/Program.cs b/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/Program.cs index a562226740..7e6fb55be7 100644 --- a/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/03-workflows/_StartHere/03_AgentWorkflowPatterns/Program.cs @@ -24,7 +24,7 @@ public static class Program { // Set up the Azure OpenAI client. var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); Console.Write("Choose workflow type ('sequential', 'concurrent', 'handoffs', 'groupchat'): "); diff --git a/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/04_MultiModelService.csproj b/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/04_MultiModelService.csproj index ee2bd37bf2..02eeb6a9de 100644 --- a/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/04_MultiModelService.csproj +++ b/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/04_MultiModelService.csproj @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/Program.cs b/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/Program.cs index 5edc956ccb..7817c5014b 100644 --- a/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/Program.cs +++ b/dotnet/samples/03-workflows/_StartHere/04_MultiModelService/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using Amazon.BedrockRuntime; +using Google.GenAI; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -9,22 +9,20 @@ using Microsoft.Extensions.AI; const string Topic = "Goldendoodles make the best pets."; // Create the IChatClients to talk to different services. -IChatClient aws = new AmazonBedrockRuntimeClient( - Environment.GetEnvironmentVariable("BEDROCK_ACCESS_KEY"!), - Environment.GetEnvironmentVariable("BEDROCK_SECRET_KEY")!, - Amazon.RegionEndpoint.USEast1) - .AsIChatClient("amazon.nova-pro-v1:0"); +IChatClient google = new Client(vertexAI: false, apiKey: Environment.GetEnvironmentVariable("GOOGLE_GENAI_API_KEY")) + .AsIChatClient("gemini-2.5-flash"); IChatClient anthropic = new Anthropic.AnthropicClient( new() { ApiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") }) .AsIChatClient("claude-sonnet-4-20250514"); IChatClient openai = new OpenAI.OpenAIClient( - Environment.GetEnvironmentVariable("OPENAI_API_KEY")!).GetChatClient("gpt-4o-mini") - .AsIChatClient(); + Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .GetResponsesClient() + .AsIChatClient("gpt-5.4-mini"); // Define our agents. -AIAgent researcher = new ChatClientAgent(aws, +AIAgent researcher = new ChatClientAgent(google, instructions: """ Write a short essay on topic specified by the user. The essay should be three to five paragraphs, written at a high school reading level, and include relevant background information, key claims, and notable perspectives. @@ -60,6 +58,12 @@ AIAgent workflowAgent = AgentWorkflowBuilder.BuildSequential(researcher, factChe string? lastAuthor = null; await foreach (var update in workflowAgent.RunStreamingAsync(Topic)) { + // Skip WorkflowEvent-only updates + if ((update.Contents == null || update.Contents.Count == 0) && update.RawRepresentation is WorkflowEvent) + { + continue; + } + if (lastAuthor != update.AuthorName) { lastAuthor = update.AuthorName; diff --git a/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs b/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs index c566054146..2359a1f10e 100644 --- a/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs +++ b/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs @@ -42,7 +42,7 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create executors for text processing diff --git a/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/README.md b/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/README.md index 5b93a83b6f..8568f8fc44 100644 --- a/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/README.md +++ b/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/README.md @@ -77,7 +77,7 @@ Without this adapter, the workflow would fail because the agent cannot accept ra - An Azure OpenAI endpoint and deployment - Set the following environment variables: - `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint URL - - `AZURE_OPENAI_DEPLOYMENT_NAME` - Your chat completion deployment name (defaults to "gpt-4o-mini") + - `AZURE_OPENAI_DEPLOYMENT_NAME` - Your chat completion deployment name (defaults to "gpt-5.4-mini") ## Running the Sample diff --git a/dotnet/samples/03-workflows/_StartHere/07_WriterCriticWorkflow/Program.cs b/dotnet/samples/03-workflows/_StartHere/07_WriterCriticWorkflow/Program.cs index f93372bc54..0d8ffbf1cf 100644 --- a/dotnet/samples/03-workflows/_StartHere/07_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/_StartHere/07_WriterCriticWorkflow/Program.cs @@ -49,7 +49,7 @@ public static class Program // Set up the Azure OpenAI client string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); - string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create executors for content creation and review diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs index cbb3799274..d0b71ef785 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; 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. diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md index c050ad0830..33b5f692f2 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md @@ -18,5 +18,5 @@ Set the following environment variables: ```powershell $env:A2A_AGENT_HOST="https://your-a2a-agent-host" # Replace with your A2A agent host endpoint $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md index ed34b820d0..2839c67587 100644 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md +++ b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/README.md @@ -22,7 +22,7 @@ The following prerequisites are required to run the samples: - [.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) +- [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-5.4-mini or better is recommended) - [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler) (local emulator or Azure-hosted) - [Docker](https://docs.docker.com/get-docker/) installed if running the Durable Task Scheduler emulator locally diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md index 9f52715256..43f988d327 100644 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md +++ b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/README.md @@ -20,7 +20,7 @@ The following prerequisites are required to run the samples: - [.NET 10.0 SDK or later](https://dotnet.microsoft.com/download/dotnet) - [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) +- [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-5.4-mini or better is recommended) - [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler) (local emulator or Azure-hosted) - [Docker](https://docs.docker.com/get-docker/) installed if running the Durable Task Scheduler emulator locally - [Redis](https://redis.io/) (for sample 07 only) - can be run locally using Docker diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md index 4887a77ccc..a0030058d6 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md @@ -59,7 +59,7 @@ DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub # Azure OpenAI (required) AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -AZURE_OPENAI_DEPLOYMENT="gpt-4o" +AZURE_OPENAI_DEPLOYMENT="gpt-5.4-mini" AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials ``` diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs index 0b9696e3a1..3624acd981 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs @@ -36,7 +36,7 @@ public static class Program .AddUserSecrets(Assembly.GetExecutingAssembly()) .Build(); var apiKey = configRoot["A2AClient:ApiKey"] ?? throw new ArgumentException("A2AClient:ApiKey must be provided"); - var modelId = configRoot["A2AClient:ModelId"] ?? "gpt-4.1"; + var modelId = configRoot["A2AClient:ModelId"] ?? "gpt-5.4-mini"; var agentUrls = configRoot["A2AClient:AgentUrls"] ?? "http://localhost:5000/;http://localhost:5001/;http://localhost:5002/"; // Create the Host agent diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/README.md b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/README.md index eb233fb8d1..8e0418c229 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/README.md +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/README.md @@ -20,7 +20,7 @@ The agent urls are provided as a ` ` delimited list of strings ```powershell cd dotnet/samples/05-end-to-end/A2AClientServer/A2AClient -$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" +$env:OPENAI_CHAT_MODEL_NAME="gpt-5.4-mini" $env:OPENAI_API_KEY="" $env:AGENT_URLS="http://localhost:5000/policy;http://localhost:5000/invoice;http://localhost:5000/logistics" ``` diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index f1c0b966fe..8dcb3d1a34 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -36,7 +36,7 @@ IConfigurationRoot configuration = new ConfigurationBuilder() .Build(); string? apiKey = configuration["OPENAI_API_KEY"]; -string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-4o-mini"; +string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini"; string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; var invoiceQueryPlugin = new InvoiceQuery(); diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/README.md b/dotnet/samples/05-end-to-end/A2AClientServer/README.md index 1f49d56f80..0efb17e748 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/README.md +++ b/dotnet/samples/05-end-to-end/A2AClientServer/README.md @@ -206,7 +206,7 @@ Sample output from the A2A client: ``` A2AClient> dotnet run info: HostClientAgent[0] - Initializing Agent Framework agent with model: gpt-4o-mini + Initializing Agent Framework agent with model: gpt-5.4-mini User (:q or quit to exit): Customer is disputing transaction TICKET-XYZ987 as they claim the received fewer t-shirts than ordered. diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index d2c17a5541..a12ca1c5ad 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using AGUIServer; using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; using OpenAI.Chat; @@ -13,11 +14,11 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default)); builder.Services.AddAGUI(); -WebApplication app = builder.Build(); - string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); +const string AgentName = "AGUIAssistant"; + // Create the AI agent with tools // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -27,7 +28,7 @@ var agent = new AzureOpenAIClient( new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( - name: "AGUIAssistant", + name: AgentName, tools: [ AIFunctionFactory.Create( () => DateTimeOffset.UtcNow, @@ -48,7 +49,15 @@ var agent = new AzureOpenAIClient( AGUIServerSerializerContext.Default.Options) ]); +// Register the agent with the host and configure it to use an in-memory session store +// so that conversation state is maintained across requests. In production, you may want to use a persistent session store. +builder + .AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(); + +WebApplication app = builder.Build(); + // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUI(AgentName, "/"); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md index 2e4887cde9..788ae93d7d 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md @@ -19,7 +19,7 @@ Configure the required Azure OpenAI environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="<>" -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` > **Note:** This sample uses `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, Visual Studio, or environment variables). diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/README.md b/dotnet/samples/05-end-to-end/AGUIWebChat/README.md index 721d1bdf41..96a78e80ac 100644 --- a/dotnet/samples/05-end-to-end/AGUIWebChat/README.md +++ b/dotnet/samples/05-end-to-end/AGUIWebChat/README.md @@ -15,7 +15,7 @@ The server requires Azure OpenAI credentials. Set the following environment vari ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -$env:AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name" # e.g., "gpt-4o" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name" # e.g., "gpt-5.4-mini" ``` The server uses `DefaultAzureCredential` for authentication. Ensure you are logged in using one of the following methods: diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AppHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AppHost/Program.cs index 328e3f5e83..0f6ead59c9 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AppHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AppHost/Program.cs @@ -6,7 +6,7 @@ var builder = DistributedApplication.CreateBuilder(args); var azOpenAiResource = builder.AddParameterFromConfiguration("AzureOpenAIName", "AzureOpenAI:Name"); var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIResourceGroup", "AzureOpenAI:ResourceGroup"); -var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup)); +var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-5.4-mini", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup)); var agentHost = builder.AddProject("agenthost") .WithHttpEndpoint(name: "devui") diff --git a/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs b/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs index 33e7001a51..aadb48c635 100644 --- a/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs @@ -13,7 +13,7 @@ using Microsoft.Agents.AI.Purview; 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"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID") ?? throw new InvalidOperationException("PURVIEW_CLIENT_APP_ID is not set."); // This will get a user token for an entra app configured to call the Purview API. diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md index c84dd125c3..56eecb6747 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md @@ -39,7 +39,7 @@ The AgentService requires an OpenAI-compatible endpoint. Set these environment v ```bash export OPENAI_API_KEY="" -export OPENAI_MODEL="gpt-4.1-mini" +export OPENAI_MODEL="gpt-5.4-mini" ``` ## Running the Sample diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs index e443888cea..e348212b37 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs @@ -74,7 +74,7 @@ builder.Services.ConfigureHttpJsonOptions(options => // --------------------------------------------------------------------------- string apiKey = builder.Configuration["OPENAI_API_KEY"] ?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable."); -string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini"; +string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-5.4-mini"; // Here we are using Singleton lifetime, since none of the services, function tools and user context classes in the sample have state that are per request. // You should evaluate the appropriate lifetime for your own services and tools based on their behavior and dependencies. diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs index c816b018e9..fee781d660 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md index f2d9a65103..465dfacbf0 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md @@ -15,7 +15,7 @@ Before running this sample, ensure you have: 1. .NET 10 SDK installed 2. An Azure OpenAI endpoint configured -3. A deployment of a chat model (e.g., gpt-4o-mini) +3. A deployment of a chat model (e.g., gpt-5.4-mini) 4. Azure CLI installed and authenticated (`az login`) ## Environment Variables @@ -26,8 +26,8 @@ Set the following environment variables: # Replace with your Azure OpenAI endpoint $env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" -# Optional, defaults to gpt-4o-mini -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +# Optional, defaults to gpt-5.4-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## How It Works diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml index aa78734283..c7e67b3d4e 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml @@ -21,8 +21,8 @@ template: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - - name: "gpt-4o-mini" + - name: "gpt-5.4-mini" kind: model - id: gpt-4o-mini + id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs index b7b610b663..4178946604 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs @@ -15,7 +15,7 @@ using Microsoft.Extensions.AI; using OpenAI.Responses; string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // Create an MCP tool that can be called without approval. AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp") diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md index 106e08e720..dc0718dfa7 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md @@ -15,7 +15,7 @@ Key features: Before running this sample, ensure you have: 1. An Azure OpenAI endpoint configured -2. A deployment of a chat model (e.g., gpt-4o-mini) +2. A deployment of a chat model (e.g., gpt-5.4-mini) 3. Azure CLI installed and authenticated **Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. @@ -28,8 +28,8 @@ Set the following environment variables: # Replace with your Azure OpenAI endpoint $env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" -# Optional, defaults to gpt-4o-mini -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +# Optional, defaults to gpt-5.4-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## How It Works diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml index 6444f1aad0..7c02acb02a 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml @@ -24,8 +24,8 @@ template: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - - name: "gpt-4o-mini" + - name: "gpt-5.4-mini" kind: model - id: gpt-4o-mini + id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs index 329ad74ea2..0da7c57b12 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs @@ -17,7 +17,7 @@ using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; Console.WriteLine($"Project Endpoint: {endpoint}"); Console.WriteLine($"Model Deployment: {deploymentName}"); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md index 58ea174718..aba51898ec 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md @@ -15,7 +15,7 @@ Key features: Before running this sample, ensure you have: 1. .NET 10 SDK installed -2. A Microsoft Foundry Project with a chat model deployed (e.g., gpt-4o-mini) +2. A Microsoft Foundry Project with a chat model deployed (e.g., gpt-5.4-mini) 3. Azure CLI installed and authenticated (`az login`) ## Environment Variables @@ -26,8 +26,8 @@ Set the following environment variables: # Replace with your Microsoft Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" -# Optional, defaults to gpt-4o-mini -$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +# Optional, defaults to gpt-5.4-mini +$env:MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## How It Works diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml index e60d9ccadf..7e75a738ce 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml @@ -22,8 +22,8 @@ template: - name: AZURE_AI_PROJECT_ENDPOINT value: ${AZURE_AI_PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - kind: model - id: gpt-4o-mini + id: gpt-5.4-mini name: chat diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs index bb28fc0d9b..518ce5679f 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.AI; using OpenAI.Chat; string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; TextSearchProviderOptions textSearchOptions = new() { diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md index 396bc1bc9b..b62d9068ce 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md @@ -15,7 +15,7 @@ Key features: Before running this sample, ensure you have: 1. An Azure OpenAI endpoint configured -2. A deployment of a chat model (e.g., gpt-4o-mini) +2. A deployment of a chat model (e.g., gpt-5.4-mini) 3. Azure CLI installed and authenticated ## Environment Variables @@ -26,8 +26,8 @@ Set the following environment variables: # Replace with your Azure OpenAI endpoint $env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" -# Optional, defaults to gpt-4o-mini -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +# Optional, defaults to gpt-5.4-mini +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ## How It Works diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml index 1366071b17..6cdad09e9c 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml @@ -24,8 +24,8 @@ template: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - - name: "gpt-4o-mini" + - name: "gpt-5.4-mini" kind: model - id: gpt-4o-mini + id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs index f5ea72e7f7..886e205acf 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.AI; // Set up the Azure OpenAI client string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md index 0f2f188f1b..b7a2f9ca53 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md @@ -25,4 +25,4 @@ Set the following environment variables: ```powershell $env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini \ No newline at end of file +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini \ No newline at end of file diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml index 900f05d513..3c97fa2ac1 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml @@ -21,8 +21,8 @@ template: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - - name: "gpt-4o-mini" + - name: "gpt-5.4-mini" kind: model - id: gpt-4o-mini + id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs index ca36341b97..cc1e3314f0 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs @@ -13,7 +13,7 @@ using Microsoft.Agents.AI.Workflows; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; Console.WriteLine($"Using Azure AI endpoint: {endpoint}"); Console.WriteLine($"Using model deployment: {deploymentName}"); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md index 4f589be86e..390df95e20 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md @@ -44,7 +44,7 @@ Before running this sample, ensure you have: 1. **Microsoft Foundry Project** - Project created. - - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) + - Chat model deployed (e.g., `gpt-5.4-mini`) - Note your project endpoint URL and model deployment name > **Note**: You can right-click the project in the Microsoft Foundry VS Code extension and select `Copy Project Endpoint URL` to get the endpoint. @@ -66,14 +66,14 @@ Set the following environment variables: ```powershell # Replace with your actual values $env:AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` **Bash:** ```bash export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +export MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ### Running the Sample diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml index 672444cdd1..79d848fa5a 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml @@ -24,8 +24,8 @@ template: - name: AZURE_AI_PROJECT_ENDPOINT value: ${AZURE_AI_PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - - name: "gpt-4o-mini" + - name: "gpt-5.4-mini" kind: model - id: gpt-4o-mini + id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json index b6b1c77b85..eae0c9ec3f 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json @@ -1,4 +1,4 @@ { "AZURE_AI_PROJECT_ENDPOINT": "https://.services.ai.azure.com/api/projects/", - "MODEL_DEPLOYMENT_NAME": "gpt-4o-mini" + "MODEL_DEPLOYMENT_NAME": "gpt-5.4-mini" } diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs index 185afd186f..c09a0a4a82 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs @@ -19,7 +19,7 @@ using Microsoft.Extensions.AI; // Get configuration from environment variables var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; Console.WriteLine($"Project Endpoint: {endpoint}"); Console.WriteLine($"Model Deployment: {deploymentName}"); // Simulated hotel data for Seattle diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md index e9dbced0a7..43c5a6cb69 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md @@ -41,7 +41,7 @@ Before running this sample, ensure you have: 1. **Microsoft Foundry Project** - Project created. - - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) + - Chat model deployed (e.g., `gpt-5.4-mini`) - Note your project endpoint URL and model deployment name 2. **Azure CLI** @@ -58,21 +58,21 @@ Before running this sample, ensure you have: Set the following environment variables (matching `agent.yaml`): - `AZURE_AI_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4o-mini`) +- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-5.4-mini`) **PowerShell:** ```powershell # Replace with your actual values $env:AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` **Bash:** ```bash export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +export MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" ``` ### Running the Sample diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml index 100defd112..eacb3ec2c0 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml @@ -25,8 +25,8 @@ template: - name: AZURE_AI_PROJECT_ENDPOINT value: ${AZURE_AI_PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME - value: gpt-4o-mini + value: gpt-5.4-mini resources: - - name: "gpt-4o-mini" + - name: "gpt-5.4-mini" kind: model - id: gpt-4o-mini \ No newline at end of file + id: gpt-5.4-mini \ No newline at end of file diff --git a/dotnet/samples/05-end-to-end/HostedAgents/README.md b/dotnet/samples/05-end-to-end/HostedAgents/README.md index 2042212a2e..a2b603cc34 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/README.md @@ -20,7 +20,7 @@ Before running any sample, ensure you have: 1. **.NET 10 SDK** or later — [Download](https://dotnet.microsoft.com/download/dotnet/10.0) 2. **Azure CLI** installed — [Install guide](https://learn.microsoft.com/cli/azure/install-azure-cli) -3. **Azure OpenAI** or **Microsoft Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`) +3. **Azure OpenAI** or **Microsoft Foundry project** with a chat model deployed (e.g., `gpt-5.4-mini`) ### Authenticate with Azure CLI @@ -38,9 +38,9 @@ Most samples require one or more of these environment variables: | Variable | Used By | Description | |----------|---------|-------------| | `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-5.4-mini`) | | `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Microsoft Foundry project endpoint | -| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-4o-mini`) | +| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-5.4-mini`) | See each sample's README for the specific variables required. diff --git a/dotnet/samples/05-end-to-end/M365Agent/README.md b/dotnet/samples/05-end-to-end/M365Agent/README.md index 08e1c3d6c2..9bf1aa6773 100644 --- a/dotnet/samples/05-end-to-end/M365Agent/README.md +++ b/dotnet/samples/05-end-to-end/M365Agent/README.md @@ -4,7 +4,7 @@ This is a sample of a simple Weather Forecast Agent that is hosted on an Asp.Net This Agent Sample is intended to introduce you the basics of integrating Agent Framework with the Microsoft 365 Agents SDK in order to use Agent Framework agents in various M365 services and applications. It can also be used as the base for a custom Agent that you choose to develop. -***Note:*** This sample requires JSON structured output from the model which works best from newer versions of the model such as gpt-4o-mini. +***Note:*** This sample requires JSON structured output from the model which works best from newer versions of the model such as gpt-5.4-mini. ## Prerequisites @@ -12,7 +12,7 @@ This Agent Sample is intended to introduce you the basics of integrating Agent F - [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) -- You will need an Azure OpenAI or OpenAI resource using `gpt-4o-mini` +- You will need an Azure OpenAI or OpenAI resource using `gpt-5.4-mini` - Configure OpenAI in appsettings diff --git a/dotnet/samples/AGENTS.md b/dotnet/samples/AGENTS.md index 7208facf36..89e1359e85 100644 --- a/dotnet/samples/AGENTS.md +++ b/dotnet/samples/AGENTS.md @@ -85,7 +85,7 @@ using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -97,7 +97,7 @@ AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredent Environment variables: - `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint -- `AZURE_OPENAI_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-4o-mini`) +- `AZURE_OPENAI_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-5.4-mini`) For authentication, run `az login` before running samples. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml deleted file mode 100644 index 79cd4b890d..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml +++ /dev/null @@ -1,284 +0,0 @@ - - - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index b1d97a5c9c..6a40c129ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -109,6 +109,27 @@ public sealed class FoundryAgent : DelegatingAIAgent #region Convenience methods + /// + /// Creates a new agent session instance using an existing conversation identifier to continue that conversation. + /// + /// The identifier of an existing conversation to continue. + /// The to monitor for cancellation requests. + /// + /// A value task representing the asynchronous operation. The task result contains a new instance configured to work with the specified conversation. + /// + /// + /// + /// This method creates an that relies on server-side chat history storage, where the chat history + /// is maintained by the underlying AI service rather than by a local . + /// + /// + /// Agent sessions created with this method will only work with + /// instances that support server-side conversation storage through their underlying . + /// + /// + public ValueTask CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) + => ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken); + /// /// Creates a server-side conversation session that appears in the Foundry Project UI. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index e20d1ab448..948ecdca42 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -21,6 +24,42 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; /// public static class AGUIEndpointRouteBuilderExtensions { + /// + /// Maps an AG-UI agent endpoint using an agent registered in dependency injection via . + /// + /// The endpoint route builder. + /// The hosted agent builder that identifies the agent registration. + /// The URL pattern for the endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + IHostedAgentBuilder agentBuilder, + [StringSyntax("route")] string pattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapAGUI(agentBuilder.Name, pattern); + } + + /// + /// Maps an AG-UI agent endpoint using a named agent registered in dependency injection. + /// + /// The endpoint route builder. + /// The name of the keyed agent registration to resolve from dependency injection. + /// The URL pattern for the endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("route")] string pattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapAGUI(pattern, agent); + } + /// /// Maps an AG-UI agent endpoint. /// @@ -28,11 +67,24 @@ public static class AGUIEndpointRouteBuilderExtensions /// The URL pattern for the endpoint. /// The agent instance. /// An for the mapped endpoint. + /// + /// + /// If an is registered in dependency injection keyed by the agent's name, + /// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the + /// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted). + /// + /// public static IEndpointConventionBuilder MapAGUI( this IEndpointRouteBuilder endpoints, [StringSyntax("route")] string pattern, AIAgent aiAgent) { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(aiAgent); + + var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name); + var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore()); + return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) => { if (input is null) @@ -63,21 +115,43 @@ public static class AGUIEndpointRouteBuilderExtensions } }; + var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId; + var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); + // Run the agent and convert to AG-UI events - var events = aiAgent.RunStreamingAsync( + var events = hostAgent.RunStreamingAsync( messages, + session: session, options: runOptions, cancellationToken: cancellationToken) .AsChatResponseUpdatesAsync() .FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken) .AsAGUIEventStreamAsync( - input.ThreadId, + threadId, input.RunId, jsonSerializerOptions, cancellationToken); + // Wrap the event stream to save the session after streaming completes + var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); + var sseLogger = context.RequestServices.GetRequiredService>(); - return new AGUIServerSentEventsResult(events, sseLogger); + return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); }); } + + private static async IAsyncEnumerable SaveSessionAfterStreamingAsync( + IAsyncEnumerable events, + AIHostAgent hostAgent, + string threadId, + AgentSession session, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (BaseEvent evt in events.ConfigureAwait(false)) + { + yield return evt; + } + + await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false); + } } 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 d6169ad805..1565977149 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 @@ -19,6 +19,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml deleted file mode 100644 index 1ebb184fbc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net472/Microsoft.Agents.AI.OpenAI.dll - lib/net472/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml deleted file mode 100644 index fbf1db84c7..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index c47298f112..9a2ecd8c23 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -168,7 +168,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos string filePath = Path.Combine(this.Directory.FullName, fileName); if (!this.CheckpointIndex.Contains(key) || - !File.Exists(fileName)) + !File.Exists(filePath)) { throw new KeyNotFoundException($"Checkpoint '{key.CheckpointId}' not found in store at '{this.Directory.FullName}'."); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index 4eb1290961..ffcc61dad4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -287,10 +287,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream { // Discard each event (including InternalCompletionSignals) } - - // After clearing, signal the run loop to continue if needed - // The run loop will send a new completion signal when it finishes processing from the restored state - this.SignalInput(); } public async ValueTask StopAsync() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index d865b990c4..9f092e8e88 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -310,22 +310,40 @@ public abstract class Executor : IIdentified return result.Result; } + /// + /// Invoked once per superstep before any messages are delivered to the Executor. + /// + /// The workflow context. + /// The to monitor for cancellation requests. + /// The default is . + /// A ValueTask representing the asynchronous operation. + protected internal virtual ValueTask OnMessageDeliveryStartingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => default; + + /// + /// Invoked once per superstep after all messages have been delivered to the Executor. + /// + /// The workflow context. + /// The to monitor for cancellation requests. + /// The default is . + /// A ValueTask representing the asynchronous operation. + protected internal virtual ValueTask OnMessageDeliveryFinishedAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => default; + /// /// Invoked before a checkpoint is saved, allowing custom pre-save logic in derived classes. /// /// The workflow context. - /// A ValueTask representing the asynchronous operation. /// The to monitor for cancellation requests. /// The default is . + /// A ValueTask representing the asynchronous operation. protected internal virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => default; /// /// Invoked after a checkpoint is loaded, allowing custom post-load logic in derived classes. /// /// The workflow context. - /// A ValueTask representing the asynchronous operation. /// The to monitor for cancellation requests. /// The default is . + /// A ValueTask representing the asynchronous operation. protected internal virtual ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => default; /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 4e9f201053..4c93414c63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -8,6 +8,10 @@ using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +using ExecutorFactoryFunc = System.Func, + string, + System.Threading.Tasks.ValueTask>; + namespace Microsoft.Agents.AI.Workflows; internal static class DiagnosticConstants @@ -233,6 +237,57 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } + private Dictionary CreateExecutorBindings(WorkflowBuilder builder) + { + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); + + // There are two types of ids being used in this method, and it is critical that we are clear about + // which one we are using, and where. + // AgentId...: comes from AIAgent.Id, is often an unreadable machine identifier (e.g. a Guid), and is used to address + // the handoffs + // ExecutorId: uses AIAgent.GetDescriptiveId() to use a friendlier name in telemetry, and is used for ExecutorBinding, + // which are subsequently used in building the workflow + + // The outgoing dictionary maps from AgentId => ExecutorBinding + return this._allAgents.ToDictionary(keySelector: a => a.Id, elementSelector: CreateFactoryBinding); + + ExecutorBinding CreateFactoryBinding(AIAgent agent) + { + if (!this._targets.TryGetValue(agent, out HashSet? handoffs)) + { + handoffs = new(); + } + + // Use the ExecutorId as the placeholder id for a (possibly) future-bound factory + builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), (SwitchBuilder sb) => + { + foreach (HandoffTarget handoff in handoffs) + { + sb.AddCase(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching + HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level + } + + sb.WithDefault(HandoffEndExecutor.ExecutorId); + }); + + ExecutorFactoryFunc factory = + (config, sessionId) => new( + new HandoffAgentExecutor(agent, + handoffs, + options)); + + // Make sure to use ExecutorId when binding the executor, not AgentId + ExecutorBinding binding = factory.BindExecutor(HandoffAgentExecutor.IdFor(agent)); + + builder.BindExecutor(binding); + + return binding; + } + } + /// /// Builds a composed of agents that operate via handoffs, with the next /// agent to process messages selected by the current agent. @@ -240,17 +295,12 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(this._returnToPrevious); - HandoffsEndExecutor end = new(this._returnToPrevious); + HandoffStartExecutor start = new(this._returnToPrevious); + HandoffEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, - this._emitAgentResponseEvents, - this._emitAgentResponseUpdateEvents, - this._toolCallFilteringBehavior); - - // Create an AgentExecutor for each agent. - Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); + // Create an factory-based ExecutorBinding for each agent. + Dictionary executors = this.CreateExecutorBindings(builder); // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). if (this._returnToPrevious) @@ -263,7 +313,7 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl if (agent.Id != initialAgentId) { string agentId = agent.Id; - sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + sb.AddCase(state => state?.PreviousAgentId == agentId, executors[agentId]); } } @@ -275,13 +325,6 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl builder.AddEdge(start, executors[this._initialAgent.Id]); } - // Initialize each executor with its handoff targets to the other executors. - foreach (var agent in this._allAgents) - { - executors[agent.Id].Initialize(builder, end, executors, - this._targets.TryGetValue(agent, out HashSet? targets) ? targets : []); - } - // Build the workflow. return builder.WithOutputFrom(end).Build(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs index a2561437ee..d08c23c089 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs @@ -182,6 +182,9 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, fromCheckpoint, [], cancellationToken) .ConfigureAwait(false); - return new(runHandle); + Run run = new(runHandle); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); + + return run; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 0daa9bf285..d3f229a7da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -249,17 +249,33 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle Executor executor = await this.RunContext.EnsureExecutorAsync(receiverId, this.StepTracer, cancellationToken).ConfigureAwait(false); this.StepTracer.TraceActivated(receiverId); - while (envelopes.TryDequeue(out var envelope)) - { - (object message, TypeId messageType) = await TranslateMessageAsync(envelope).ConfigureAwait(false); - await executor.ExecuteCoreAsync( - message, - messageType, - this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext), - this.TelemetryContext, - cancellationToken - ).ConfigureAwait(false); + // TODO: #5084 - Add delivery-level activity (max one per step per executor) to capture non-message + // specific invocations of executor logic. + IWorkflowContext tracelessContext = this.RunContext.BindWorkflowContext(receiverId); + + try + { + await executor.OnMessageDeliveryStartingAsync(tracelessContext, cancellationToken) + .ConfigureAwait(false); + + while (envelopes.TryDequeue(out var envelope)) + { + (object message, TypeId messageType) = await TranslateMessageAsync(envelope).ConfigureAwait(false); + + await executor.ExecuteCoreAsync( + message, + messageType, + this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext), + this.TelemetryContext, + cancellationToken + ).ConfigureAwait(false); + } + } + finally + { + await executor.OnMessageDeliveryFinishedAsync(tracelessContext, cancellationToken) + .ConfigureAwait(false); } async ValueTask<(object, TypeId)> TranslateMessageAsync(MessageEnvelope envelope) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 3f3d83fbee..b7d2911537 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -19,6 +19,9 @@ internal static class TurnExtensions public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) => turnTokenSetting ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(this HandoffState handoffState, bool? agentSetting) + => handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting); } internal sealed class AIAgentHostExecutor : ChatProtocolExecutor @@ -81,7 +84,11 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor // resumes can be processed in one invocation. return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => { - pendingMessages.Add(new ChatMessage(ChatRole.User, [response])); + pendingMessages.Add(new ChatMessage(ChatRole.User, [response]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); @@ -104,7 +111,12 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor // resumes can be processed in one invocation. return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => { - pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])); + pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); @@ -186,16 +198,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), cancellationToken); - private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) + private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default) { -#pragma warning disable MEAI001 - Dictionary userInputRequests = new(); - Dictionary functionCalls = new(); AgentResponse response; + AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); - if (emitEvents) + if (emitUpdateEvents) { -#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. // Run the agent in streaming mode only when agent run update events are to be emitted. IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( messages, @@ -206,7 +215,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); - ExtractUnservicedRequests(update.Contents); + collector.ProcessAgentResponseUpdate(update); updates.Add(update); } @@ -220,7 +229,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor cancellationToken: cancellationToken) .ConfigureAwait(false); - ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents)); + collector.ProcessAgentResponse(response); } if (this._options.EmitAgentResponseEvents) @@ -228,45 +237,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } - if (userInputRequests.Count > 0 || functionCalls.Count > 0) - { - Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask; - Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask; - - await Task.WhenAll(userInputTask, functionCallTask) - .ConfigureAwait(false); - } + await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false); return response; - - void ExtractUnservicedRequests(IEnumerable contents) - { - foreach (AIContent content in contents) - { - if (content is ToolApprovalRequestContent userInputRequest) - { - // It is an error to simultaneously have multiple outstanding user input requests with the same ID. - userInputRequests.Add(userInputRequest.RequestId, userInputRequest); - } - else if (content is ToolApprovalResponseContent userInputResponse) - { - // If the set of messages somehow already has a corresponding user input response, remove it. - _ = userInputRequests.Remove(userInputResponse.RequestId); - } - else if (content is FunctionCallContent functionCall) - { - // For function calls, we emit an event to notify the workflow. - // - // possibility 1: this will be handled inline by the agent abstraction - // possibility 2: this will not be handled inline by the agent abstraction - functionCalls.Add(functionCall.CallId, functionCall); - } - else if (content is FunctionResultContent functionResult) - { - _ = functionCalls.Remove(functionResult.CallId); - } - } - } -#pragma warning restore MEAI001 } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs new file mode 100644 index 0000000000..7e4f8c8c9d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs @@ -0,0 +1,78 @@ +// 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; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandler? userInputHandler, + AIContentExternalHandler? functionCallHandler) +{ + private readonly Dictionary _userInputRequests = []; + private readonly Dictionary _functionCalls = []; + + public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0 + ? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken) + : Task.CompletedTask; + + Task functionCallTask = functionCallHandler != null && this._functionCalls.Count > 0 + ? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken) + : Task.CompletedTask; + + return Task.WhenAll(userInputTask, functionCallTask); + } + + public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func? functionCallFilter = null) + => this.ProcessAIContents(update.Contents, functionCallFilter); + + public void ProcessAgentResponse(AgentResponse response) + => this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents)); + + public void ProcessAIContents(IEnumerable contents, Func? functionCallFilter = null) + { + foreach (AIContent content in contents) + { + if (content is ToolApprovalRequestContent userInputRequest) + { + if (this._userInputRequests.ContainsKey(userInputRequest.RequestId)) + { + throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}"); + } + + // It is an error to simultaneously have multiple outstanding user input requests with the same ID. + this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest); + } + else if (content is ToolApprovalResponseContent userInputResponse) + { + // If the set of messages somehow already has a corresponding user input response, remove it. + _ = this._userInputRequests.Remove(userInputResponse.RequestId); + } + else if (content is FunctionCallContent functionCall) + { + // For function calls, we emit an event to notify the workflow. + // + // possibility 1: this will be handled inline by the agent abstraction + // possibility 2: this will not be handled inline by the agent abstraction + if (functionCallFilter == null || functionCallFilter(functionCall)) + { + if (this._functionCalls.ContainsKey(functionCall.CallId)) + { + throw new InvalidOperationException($"FunctionCallContent with duplicate CallId: {functionCall.CallId}"); + } + + this._functionCalls.Add(functionCall.CallId, functionCall); + } + } + else if (content is FunctionResultContent functionResult) + { + _ = this._functionCalls.Remove(functionResult.CallId); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index e885b894fd..eac2eb5687 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; @@ -166,128 +165,331 @@ internal sealed class HandoffMessagesFilter } } +internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) +{ + public AgentResponse Response => agentResponse; + + public string? HandoffTargetId => handoffTargetId; + + [MemberNotNullWhen(true, nameof(HandoffTargetId))] + public bool IsHandoffRequested => this.HandoffTargetId != null; +} + +internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List FilteredIncomingMessages, List TurnMessages) +{ + public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId) + { + if (this.CurrentTurnState == null) + { + throw new InvalidOperationException("Cannot create a handoff request: Out of turn."); + } + + IEnumerable allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages]; + + return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId); + } +} + /// Executor used to represent an agent in a handoffs workflow, responding to events. [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] -internal sealed class HandoffAgentExecutor( - AIAgent agent, - HandoffAgentExecutorOptions options) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffAgentExecutor : + StatefulExecutor { private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; - private readonly AIAgent _agent = agent; + public static string IdFor(AIAgent agent) => agent.GetDescriptiveId(); + + private readonly AIAgent _agent; + private readonly ChatClientAgentRunOptions? _agentOptions; + + private readonly HandoffAgentExecutorOptions _options; + private readonly HashSet _handoffFunctionNames = []; private readonly Dictionary _handoffFunctionToAgentId = []; - private ChatClientAgentRunOptions? _agentOptions; - public void Initialize( - WorkflowBuilder builder, - Executor end, - Dictionary executors, - HashSet handoffs) => - builder.AddSwitch(this, sb => - { - if (handoffs.Count != 0) - { - Debug.Assert(this._agentOptions is null); - this._agentOptions = new() - { - ChatOptions = new() - { - AllowMultipleToolCalls = false, - Instructions = options.HandoffInstructions, - Tools = [], - }, - }; + private static HandoffAgentHostState InitialStateFactory() => new(null, [], []); - int index = 0; - foreach (HandoffTarget handoff in handoffs) - { - index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); - - this._handoffFunctionNames.Add(handoffFunc.Name); - this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; - - this._agentOptions.ChatOptions.Tools.Add(handoffFunc); - - sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); - } - } - - sb.WithDefault(end); - }); - - public override async ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) + public HandoffAgentExecutor(AIAgent agent, HashSet handoffs, HandoffAgentExecutorOptions options) + : base(IdFor(agent), InitialStateFactory) { - string? requestedHandoff = null; - List updates = []; - List allMessages = message.Messages; + this._agent = agent; + this._options = options; - List? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + this._agentOptions = CreateAgentHandoffContext(this._options.HandoffInstructions, handoffs, this._handoffFunctionNames, this._handoffFunctionToAgentId); + } - // If a handoff was invoked by a previous agent, filter out the handoff function - // call and tool result messages before sending to the underlying agent. These - // are internal workflow mechanics that confuse the target model into ignoring the - // original user question. - HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior); - IEnumerable messagesForAgent = message.InvokedHandoff is not null - ? handoffMessagesFilter.FilterMessages(allMessages) - : allMessages; + private static ChatClientAgentRunOptions? CreateAgentHandoffContext(string? handoffInstructions, HashSet handoffs, HashSet functionNames, Dictionary functionToAgentId) + { + ChatClientAgentRunOptions? result = null; - await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent, - options: this._agentOptions, - cancellationToken: cancellationToken) - .ConfigureAwait(false)) + if (handoffs.Count != 0) { - await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - - foreach (var fcc in update.Contents.OfType() - .Where(fcc => this._handoffFunctionNames.Contains(fcc.Name))) + result = new() { - requestedHandoff = fcc.Name; - await AddUpdateAsync( - new AgentResponseUpdate - { - AgentId = this._agent.Id, - AuthorName = this._agent.Name ?? this._agent.Id, - Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Tool, - }, - cancellationToken - ) - .ConfigureAwait(false); + ChatOptions = new() + { + AllowMultipleToolCalls = false, + Instructions = handoffInstructions, + Tools = [], + }, + }; + + int index = 0; + foreach (HandoffTarget handoff in handoffs) + { + index++; + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + + functionNames.Add(handoffFunc.Name); + functionToAgentId[handoffFunc.Name] = handoff.Target.Id; + + result.ChatOptions.Tools.Add(handoffFunc); } } - AgentResponse agentResponse = updates.ToAgentResponse(); + return result; + } - if (options.EmitAgentResponseEvents) + private AIContentExternalHandler? _userInputHandler; + private AIContentExternalHandler? _functionCallHandler; + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + { + return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder)) + .SendsMessage(); + } + + private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder) + { + this._userInputHandler = new AIContentExternalHandler( + ref protocolBuilder, + portId: $"{this.Id}_UserInput", + intercepted: false, + handler: this.HandleUserInputResponseAsync); + + this._functionCallHandler = new AIContentExternalHandler( + ref protocolBuilder, + portId: $"{this.Id}_FunctionCall", + intercepted: false, // TODO: Use this instead of manual function handling for handoff? + handler: this.HandleFunctionResultAsync); + + return protocolBuilder; + } + + private ValueTask HandleUserInputResponseAsync( + ToolApprovalResponseContent response, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId)) { - await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'."); } - allMessages.AddRange(agentResponse.Messages); + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.InvokeWithStateAsync((state, ctx, ct) => + { + state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + return this.ContinueTurnAsync(state, ctx, ct); + }, context, skipCache: false, cancellationToken); + } + + private ValueTask HandleFunctionResultAsync( + FunctionResultContent result, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId)) + { + throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'."); + } + + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.InvokeWithStateAsync((state, ctx, ct) => + { + state.TurnMessages.Add( + new ChatMessage(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + return this.ContinueTurnAsync(state, ctx, ct); + }, context, skipCache: false, cancellationToken); + } + + private async ValueTask ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + { + List? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + + bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents); + AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken) + .ConfigureAwait(false); + + if (this.HasOutstandingRequests && result.IsHandoffRequested) + { + throw new InvalidOperationException("Cannot request a handoff while holding pending requests."); + } roleChanges.ResetUserToAssistantForChangedRoles(); - string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) - ? targetAgentId - : this._agent.Id; - - return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); - - async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) + // We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only + // happens if we have no outstanding requests. + if (!this.HasOutstandingRequests) { - updates.Add(update); - if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) + HandoffState outgoingState = state.PrepareHandoff(result, this._agent.Id); + + await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); + + // reset the state for the next handoff (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which + // can be a bit confusing.) + return null; + } + + state.TurnMessages.AddRange(result.Response.Messages); + return state; + } + + public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken); + + ValueTask InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + { + // Check that we are not getting this message while in the middle of a turn + if (state.CurrentTurnState != null) { - await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration."); } + + // If a handoff was invoked by a previous agent, filter out the handoff function + // call and tool result messages before sending to the underlying agent. These + // are internal workflow mechanics that confuse the target model into ignoring the + // original user question. + HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior); + IEnumerable messagesForAgent = message.RequestedHandoffTargetAgentId is not null + ? handoffMessagesFilter.FilterMessages(message.Messages) + : message.Messages; + + // This works because the runtime guarantees that a given executor instance will process messages serially, + // though there is no global cross-executor ordering guarantee (and in turn, no canonical message delivery order) + state = new(message, messagesForAgent.ToList(), []); + + return this.ContinueTurnAsync(state, context, cancellationToken); } } - public ValueTask ResetAsync() => default; + private const string UserInputRequestStateKey = nameof(_userInputHandler); + private const string FunctionCallRequestStateKey = nameof(_functionCallHandler); + + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + + Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask(); + await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + + await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false); + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true) + || (this._functionCallHandler?.HasPendingRequests == true); + + private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default) + { + AgentResponse response; + + AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); + + IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( + messages, + options: this._agentOptions, + cancellationToken: cancellationToken); + + string? requestedHandoff = null; + List updates = []; + List candidateRequests = []; + await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) + { + await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); + + collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); + + bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) + { + bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); + if (isHandoffRequest) + { + candidateRequests.Add(candidateHandoffRequest); + } + + return !isHandoffRequest; + } + } + + if (candidateRequests.Count > 1) + { + string message = $"Duplicate handoff requests in single turn ([{string.Join(", ", candidateRequests.Select(request => request.Name))}]). Using last ({candidateRequests.Last().Name})"; + await context.AddEventAsync(new WorkflowWarningEvent(message), cancellationToken).ConfigureAwait(false); + } + + if (candidateRequests.Count > 0) + { + FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1]; + requestedHandoff = handoffRequest.Name; + + await AddUpdateAsync( + new AgentResponseUpdate + { + AgentId = this._agent.Id, + AuthorName = this._agent.Name ?? this._agent.Id, + Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")], + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Tool, + }, + cancellationToken + ) + .ConfigureAwait(false); + } + + response = updates.ToAgentResponse(); + + if (this._options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); + } + + await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false); + + return new(response, LookupHandoffTarget(requestedHandoff)); + + ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) + { + updates.Add(update); + + return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default; + } + + string? LookupHandoffTarget(string? requestedHandoff) + => requestedHandoff != null + ? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null + : null; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs similarity index 86% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs index 4a43c00a72..0ba8fc3501 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; @@ -21,9 +21,9 @@ internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(Exec { if (returnToPrevious) { - await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, - handoff.CurrentAgentId, - HandoffConstants.CurrentAgentTrackerScope, + await context.QueueStateUpdateAsync(HandoffConstants.PreviousAgentTrackerKey, + handoff.PreviousAgentId, + HandoffConstants.PreviousAgentTrackerScope, cancellationToken) .ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs similarity index 71% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs index 87c3b4566b..063f73bb6f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs @@ -9,12 +9,12 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal static class HandoffConstants { - internal const string CurrentAgentTrackerKey = "LastAgentId"; - internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; + internal const string PreviousAgentTrackerKey = "LastAgentId"; + internal const string PreviousAgentTrackerScope = "HandoffOrchestration"; } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -32,15 +32,15 @@ internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtoco if (returnToPrevious) { return context.InvokeWithStateAsync( - async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) => { - HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId); await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); - return currentAgentId; + return previousAgentId; }, - HandoffConstants.CurrentAgentTrackerKey, - HandoffConstants.CurrentAgentTrackerScope, + HandoffConstants.PreviousAgentTrackerKey, + HandoffConstants.PreviousAgentTrackerScope, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index 56e2fef9df..644bc7df0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -7,6 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, - string? InvokedHandoff, + string? RequestedHandoffTargetAgentId, List Messages, - string? CurrentAgentId = null); + string? PreviousAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 3ed23cc019..d1d239506f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -113,6 +113,12 @@ public abstract class StatefulExecutor : Executor { if (!skipCache && !context.ConcurrentRunsEnabled) { + if (this._stateCache is null) + { + this._stateCache = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken) + .ConfigureAwait(false); + } + TState newState = await invocation(this._stateCache ?? this._initialStateFactory(), context, cancellationToken).ConfigureAwait(false) @@ -168,9 +174,12 @@ public abstract class StatefulExecutor(string id, /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - protocolBuilder.RouteBuilder.AddHandler(this.HandleAsync); + Func handlerDelegate = this.HandleAsync; - return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []) + return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate)) + .AddMethodAttributeTypes(handlerDelegate.Method) + .AddClassAttributeTypes(this.GetType()) + .SendsMessageTypes(sentMessageTypes ?? []) .YieldsOutputTypes(outputTypes ?? []); } @@ -203,19 +212,12 @@ public abstract class StatefulExecutor(string id, /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - protocolBuilder.RouteBuilder.AddHandler(this.HandleAsync); - - if (this.Options.AutoSendMessageHandlerResultObject) - { - protocolBuilder.SendsMessage(); - } - - if (this.Options.AutoYieldOutputHandlerResultObject) - { - protocolBuilder.YieldsOutput(); - } - - return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []); + Func> handlerDelegate = this.HandleAsync; + return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate)) + .AddMethodAttributeTypes(handlerDelegate.Method) + .AddClassAttributeTypes(this.GetType()) + .SendsMessageTypes(sentMessageTypes ?? []) + .YieldsOutputTypes(outputTypes ?? []); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 02891b4f48..69eb796914 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -129,8 +130,17 @@ public sealed class CompactionProvider : AIContextProvider CompactionMessageIndex messageIndex; if (state.MessageGroups.Count > 0) { - // Update existing index with any new messages appended since the last call. messageIndex = new([.. state.MessageGroups]); + + // Treat all messages already in the index as chat history. + foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages)) + { + message.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = + new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!); + } + + // Update existing index with any new messages appended since the last call. messageIndex.Update(messageList); } else @@ -159,6 +169,20 @@ public sealed class CompactionProvider : AIContextProvider state.MessageGroups.Clear(); state.MessageGroups.AddRange(messageIndex.Groups); + // Treat any messages that were generated by the compaction strategies as chat history. + // This is to avoid adding them to chat history at the end of the run, which we don't want + // since they may be summaries of previous messages that are already in chat history. + foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages)) + { + // Only consider messages that aren't already marked as ChatHistory and messages that weren't passed into the provider. + if (message.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory && !messageList.Any(x => x.ContentEquals(message))) + { + message.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = + new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!); + } + } + return new AIContext { Instructions = context.AIContext.Instructions, diff --git a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index 8b7fd65d4c..a8268863bd 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -1,6 +1,34 @@  + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(System.String,System.String,System.String,System.String,System.String,System.String,Microsoft.Extensions.AI.AdditionalPropertiesDictionary) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.AddResource(System.String,System.Delegate,System.String) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.AddScript(System.String,System.Delegate,System.String) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -17,23 +45,30 @@ CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(System.String,System.String,System.String,System.String,System.String,System.String,Microsoft.Extensions.AI.AdditionalPropertiesDictionary) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.AddResource(System.String,System.Delegate,System.String) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.AddScript(System.String,System.Delegate,System.String) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll true @@ -52,23 +87,30 @@ CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(System.String,System.String,System.String,System.String,System.String,System.String,Microsoft.Extensions.AI.AdditionalPropertiesDictionary) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.AddResource(System.String,System.Delegate,System.String) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.AddScript(System.String,System.Delegate,System.String) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll true @@ -87,23 +129,30 @@ CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(System.String,System.String,System.String,System.String,System.String,System.String,Microsoft.Extensions.AI.AdditionalPropertiesDictionary) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.AddResource(System.String,System.Delegate,System.String) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.AddScript(System.String,System.Delegate,System.String) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll true @@ -122,23 +171,30 @@ CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(System.String,System.String,System.String,System.String,System.String,System.String,Microsoft.Extensions.AI.AdditionalPropertiesDictionary) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll true CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll + M:Microsoft.Agents.AI.AgentInlineSkill.AddResource(System.String,System.Delegate,System.String) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentInlineSkill.AddScript(System.String,System.Delegate,System.String) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll true @@ -155,25 +211,4 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/netstandard2.0/Microsoft.Agents.AI.dll - lib/netstandard2.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/netstandard2.0/Microsoft.Agents.AI.dll - lib/netstandard2.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/netstandard2.0/Microsoft.Agents.AI.dll - lib/netstandard2.0/Microsoft.Agents.AI.dll - true - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 0da54d0426..e49c620187 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI; /// /// /// Mixed skill types — combine file-based, code-defined (), -/// and class-based () skills in a single provider. +/// and class-based () skills in a single provider. /// Multiple file script runners — use different script runners for different /// file skill directories via per-source scriptRunner parameters on /// / . diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index c6dc3bc629..972dbee53f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -31,9 +32,16 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; + // "." means the skill directory root itself (no subdirectory descent constraint) + private const string RootDirectoryIndicator = "."; + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; + // Standard subdirectory names per https://agentskills.io/specification#directory-structure + private static readonly string[] s_defaultScriptDirectories = ["scripts"]; + private static readonly string[] s_defaultResourceDirectories = ["references", "assets"]; + // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. @@ -55,6 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; + private readonly IReadOnlyList _scriptDirectories; + private readonly IReadOnlyList _resourceDirectories; private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; @@ -88,22 +98,28 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource ILoggerFactory? loggerFactory = null) { this._skillPaths = Throw.IfNull(skillPaths); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); - - ValidateExtensions(resolvedOptions.AllowedResourceExtensions); - ValidateExtensions(resolvedOptions.AllowedScriptExtensions); + ValidateExtensions(options?.AllowedResourceExtensions); + ValidateExtensions(options?.AllowedScriptExtensions); this._allowedResourceExtensions = new HashSet( - resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + options?.AllowedResourceExtensions ?? s_defaultResourceExtensions, StringComparer.OrdinalIgnoreCase); this._allowedScriptExtensions = new HashSet( - resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, + options?.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); + this._scriptDirectories = options?.ScriptDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)] + : s_defaultScriptDirectories; + + this._resourceDirectories = options?.ResourceDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)] + : s_defaultResourceDirectories; + this._scriptRunner = scriptRunner; - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } /// @@ -179,8 +195,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource return null; } - var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); - var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name); + // Append a trailing separator so path-containment checks don't false-match + // sibling directories. e.g. "/skills/myskill" matches "/skills/myskill-evil/", + // but "/skills/myskill/" does not. + string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; + + var resources = this.DiscoverResourceFiles(normalizedSkillDirectoryFullPath, frontmatter.Name); + var scripts = this.DiscoverScriptFiles(normalizedSkillDirectoryFullPath, frontmatter.Name); return new AgentFileSkill( frontmatter: frontmatter, @@ -282,147 +303,213 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Scans a skill directory for resource files matching the configured extensions. + /// Scans configured resource directories within a skill directory for resource files matching the configured extensions. /// /// - /// Recursively walks and collects files whose extension - /// matches the allowed set, excluding SKILL.md itself. Each candidate - /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with - /// a warning. + /// By default, scans references/ and assets/ subdirectories as specified by the + /// Agent Skills specification. + /// Configure to scan different or + /// additional directories, including "." for the skill root itself. + /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { - string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) + { + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); + + // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") + string targetDirectory = isRootDirectory + ? skillDirectoryFullPath + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; + + if (!Directory.Exists(targetDirectory)) + { + continue; + } + + // Directory-level symlink check: skip if targetDirectory (or any intermediate + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied + // trusted path, and the security boundary guards files within it, not the path itself. + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); + } + + continue; + } + #if NET - var enumerationOptions = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - }; + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) #else - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) #endif - { - string fileName = Path.GetFileName(filePath); - - // Exclude SKILL.md itself - if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase)) { - continue; - } + string fileName = Path.GetFileName(filePath); - // Filter by extension - string extension = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension)) - { - if (this._logger.IsEnabled(LogLevel.Debug)) + // Exclude SKILL.md itself + if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase)) { - LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); + continue; } - continue; - } - - // Normalize the enumerated path to guard against non-canonical forms - string resolvedFilePath = Path.GetFullPath(filePath); - - // Path containment check - if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension)) { - LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + if (this._logger.IsEnabled(LogLevel.Debug)) + { + LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); + } + + continue; } - continue; - } + // Normalize the enumerated path to guard against non-canonical forms. + // e.g. "references/../../../etc/shadow" → "/etc/shadow" + string resolvedFilePath = Path.GetFullPath(filePath); - // Symlink check - if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Path containment: reject if the resolved path escapes the target directory. + // e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip + if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) { - LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; } - continue; - } + // Per-file symlink check: detects if the file (or any intermediate segment) + // is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow" + if (HasSymlinkInPath(resolvedFilePath, targetDirectory)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } - // Compute relative path and normalize to forward slashes - string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); - resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); + continue; + } + + // Compute relative path and normalize separators. + // e.g. "/skills/myskill/references/guide.md" → "references/guide.md" + string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length)); + + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); + } } return resources; } /// - /// Scans a skill directory for script files matching the configured extensions. + /// Scans configured script directories within a skill directory for script files matching the configured extensions. /// /// - /// Recursively walks the skill directory and collects files whose extension - /// matches the allowed set. Each candidate is validated against path-traversal - /// and symlink-escape checks; unsafe files are skipped with a warning. + /// By default, scans the scripts/ subdirectory as specified by the + /// Agent Skills specification. + /// Configure to scan different or + /// additional directories, including "." for the skill root itself. + /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; var scripts = new List(); + foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) + { + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); + + // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") + string targetDirectory = isRootDirectory + ? skillDirectoryFullPath + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; + + if (!Directory.Exists(targetDirectory)) + { + continue; + } + + // Directory-level symlink check: skip if targetDirectory (or any intermediate + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied + // trusted path, and the security boundary guards files within it, not the path itself. + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); + } + + continue; + } + #if NET - var enumerationOptions = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - }; + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) #else - foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) #endif - { - // Filter by extension - string extension = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) { - continue; - } - - // Normalize the enumerated path to guard against non-canonical forms - string resolvedFilePath = Path.GetFullPath(filePath); - - // Path containment check - if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) { - LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + continue; } - continue; - } + // Normalize the enumerated path to guard against non-canonical forms. + // e.g. "scripts/../../../etc/shadow" → "/etc/shadow" + string resolvedFilePath = Path.GetFullPath(filePath); - // Symlink check - if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) + // Path containment: reject if the resolved path escapes the target directory. + // e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip + if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) { - LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; } - continue; - } + // Per-file symlink check: detects if the file (or any intermediate segment) + // is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow" + if (HasSymlinkInPath(resolvedFilePath, targetDirectory)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } - // Compute relative path and normalize to forward slashes - string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); - scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + continue; + } + + // Compute relative path and normalize separators. + // e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py" + string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length)); + + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + } } return scripts; @@ -431,14 +518,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource /// /// Checks whether any segment in the path (relative to the directory) is a symlink. /// - private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) + private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath) { - string relativePath = fullPath.Substring(normalizedDirectoryPath.Length); + string relativePath = pathToCheck.Substring(trustedBasePath.Length); string[] segments = relativePath.Split( - new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); - string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string currentPath = trustedBasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); foreach (string segment in segments) { @@ -454,21 +541,28 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Normalizes a relative path by replacing backslashes with forward slashes - /// and trimming a leading "./" prefix. + /// Normalizes a relative path or directory name by stripping a leading "./"/".\", + /// trimming trailing separators, and replacing backslashes with forward + /// slashes. /// private static string NormalizePath(string path) { + // Strip leading "./" or ".\" + if (path.StartsWith("./", StringComparison.Ordinal) || + path.StartsWith(".\\", StringComparison.Ordinal)) + { + path = path.Substring(2); + } + + // Trim trailing directory separators + path = path.TrimEnd('/', '\\'); + + // Normalize all separators to forward slashes if (path.IndexOf('\\') >= 0) { path = path.Replace('\\', '/'); } - if (path.StartsWith("./", StringComparison.Ordinal)) - { - path = path.Substring(2); - } - return path; } @@ -508,6 +602,46 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } } + private static IEnumerable ValidateAndNormalizeDirectoryNames(IEnumerable directories, ILogger logger) + { + foreach (string directory in directories) + { + if (string.IsNullOrWhiteSpace(directory)) + { + throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories)); + } + + // "." is valid — it means the skill root directory. + if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal)) + { + yield return directory; + continue; + } + + // Reject absolute paths and any path segments that escape upward. + if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory)) + { + LogDirectoryNameSkippedInvalid(logger, directory); + continue; + } + + yield return NormalizePath(directory); + } + } + + private static bool ContainsParentTraversalSegment(string directory) + { + foreach (string segment in directory.Split('/', '\\')) + { + if (segment == "..") + { + return true; + } + } + + return false; + } + [LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")] private static partial void LogSkillsDiscovered(ILogger logger, int count); @@ -532,6 +666,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); + [LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] + private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName); + [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); @@ -540,4 +677,10 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); + + [LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] + private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName); + + [LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")] + private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs index edaec327fa..b5c83c0220 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -30,4 +30,30 @@ public sealed class AgentFileSkillsSourceOptions /// .ps1, .cs, .csx. /// public IEnumerable? AllowedScriptExtensions { get; set; } + + /// + /// Gets or sets relative directory paths to scan for script files within each skill directory. + /// Values may be single-segment names (e.g., "scripts") or multi-segment relative + /// paths (e.g., "sub/scripts"). Use "." to include files directly at the + /// skill root. Leading "./" prefixes, trailing separators, and backslashes are + /// normalized automatically; paths containing ".." segments or absolute paths are + /// rejected. + /// When , defaults to scripts (per the + /// Agent Skills specification). + /// When set, replaces the defaults entirely. + /// + public IEnumerable? ScriptDirectories { get; set; } + + /// + /// Gets or sets relative directory paths to scan for resource files within each skill directory. + /// Values may be single-segment names (e.g., "references") or multi-segment relative + /// paths (e.g., "sub/resources"). Use "." to include files directly at the + /// skill root. Leading "./" prefixes, trailing separators, and backslashes are + /// normalized automatically; paths containing ".." segments or absolute paths are + /// rejected. + /// When , defaults to references and assets (per the + /// Agent Skills specification). + /// When set, replaces the defaults entirely. + /// + public IEnumerable? ResourceDirectories { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 47f12d5ea5..b44f423bc2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -1,7 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -9,17 +15,55 @@ namespace Microsoft.Agents.AI; /// /// Abstract base class for defining skills as C# classes that bundle all components together. /// +/// +/// The concrete skill type. This type parameter is annotated with +/// to ensure that the IL trimmer and Native AOT compiler +/// preserve the members needed for attribute-based discovery. +/// /// /// /// Inherit from this class to create a self-contained skill definition. Override the abstract -/// properties to provide name, description, and instructions. Use , -/// , and to define -/// inline resources and scripts. +/// properties to provide name, description, and instructions. +/// +/// +/// Scripts and resources can be defined in two ways: +/// +/// +/// Attribute-based (recommended): Annotate methods with to define scripts, +/// and properties or methods with to define resources. These are automatically +/// discovered via reflection on . This approach is compatible with Native AOT. +/// +/// +/// Explicit override: Override and , using +/// , , +/// and to define inline resources and scripts. This approach is also compatible with Native AOT. +/// +/// +/// +/// +/// Multi-level inheritance limitation: Discovery reflects only on , +/// so if a further-derived subclass adds new attributed members, they will not be discovered unless +/// that subclass also uses the CRTP pattern +/// (e.g., class SpecialSkill : AgentClassSkill<SpecialSkill>). /// /// /// /// -/// public class PdfFormatterSkill : AgentClassSkill +/// // Attribute-based approach (recommended, AOT-compatible): +/// public class PdfFormatterSkill : AgentClassSkill<PdfFormatterSkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF."); +/// protected override string Instructions => "Use this skill to format documents..."; +/// +/// [AgentSkillResource("template")] +/// public string Template => "Use this template..."; +/// +/// [AgentSkillScript("format-pdf")] +/// private static string FormatPdf(string content) => content; +/// } +/// +/// // Explicit override approach (AOT-compatible): +/// public class ExplicitPdfFormatterSkill : AgentClassSkill<ExplicitPdfFormatterSkill> /// { /// private IReadOnlyList<AgentSkillResource>? _resources; /// private IReadOnlyList<AgentSkillScript>? _scripts; @@ -42,15 +86,41 @@ namespace Microsoft.Agents.AI; /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class AgentClassSkill : AgentSkill +public abstract class AgentClassSkill< + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.NonPublicProperties | + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods)] TSelf> + : AgentSkill + where TSelf : AgentClassSkill { + private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + private string? _content; + private bool _resourcesDiscovered; + private bool _scriptsDiscovered; + private IReadOnlyList? _reflectedResources; + private IReadOnlyList? _reflectedScripts; /// /// Gets the raw instructions text for this skill. /// protected abstract string Instructions { get; } + /// + /// Gets the used to marshal parameters and return values + /// for scripts and resources. + /// + /// + /// Override this property to provide custom serialization options. This value is used by + /// reflection-discovered scripts and resources, and also as a fallback by + /// and when no + /// explicit is passed to those methods. + /// The default value is , which causes to be used. + /// + protected virtual JsonSerializerOptions? SerializerOptions => null; + /// /// /// Returns a synthesized XML document containing name, description, instructions, resources, and scripts. @@ -63,6 +133,48 @@ public abstract class AgentClassSkill : AgentSkill this.Resources, this.Scripts); + /// + /// + /// Returns resources discovered via reflection by scanning for + /// members annotated with . This discovery is + /// compatible with Native AOT because is annotated with + /// . The result is cached after the first access. + /// + public override IReadOnlyList? Resources + { + get + { + if (!this._resourcesDiscovered) + { + this._reflectedResources = this.DiscoverResources(); + this._resourcesDiscovered = true; + } + + return this._reflectedResources; + } + } + + /// + /// + /// Returns scripts discovered via reflection by scanning for + /// methods annotated with . This discovery is + /// compatible with Native AOT because is annotated with + /// . The result is cached after the first access. + /// + public override IReadOnlyList? Scripts + { + get + { + if (!this._scriptsDiscovered) + { + this._reflectedScripts = this.DiscoverScripts(); + this._scriptsDiscovered = true; + } + + return this._reflectedScripts; + } + } + /// /// Creates a skill resource backed by a static value. /// @@ -70,7 +182,7 @@ public abstract class AgentClassSkill : AgentSkill /// The static resource value. /// An optional description of the resource. /// A new instance. - protected static AgentSkillResource CreateResource(string name, object value, string? description = null) + protected AgentSkillResource CreateResource(string name, object value, string? description = null) => new AgentInlineSkillResource(name, value, description); /// @@ -79,9 +191,13 @@ public abstract class AgentClassSkill : AgentSkill /// The resource name. /// A method that produces the resource value when requested. /// An optional description of the resource. + /// + /// Optional used to marshal the delegate's parameters and return value. + /// When , falls back to . + /// /// A new instance. - protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null) - => new AgentInlineSkillResource(name, method, description); + protected AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + => new AgentInlineSkillResource(name, method, description, serializerOptions ?? this.SerializerOptions); /// /// Creates a skill script backed by a delegate. @@ -89,7 +205,131 @@ public abstract class AgentClassSkill : AgentSkill /// The script name. /// A method to execute when the script is invoked. /// An optional description of the script. + /// + /// Optional used to marshal the delegate's parameters and return value. + /// When , falls back to . + /// /// A new instance. - protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null) - => new AgentInlineSkillScript(name, method, description); + protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + => new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions); + + private List? DiscoverResources() + { + List? resources = null; + + var selfType = typeof(TSelf); + + // Discover resources from properties annotated with [AgentSkillResource]. + foreach (var property in selfType.GetProperties(DiscoveryBindingFlags)) + { + var attr = property.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + var getter = property.GetGetMethod(nonPublic: true); + if (getter is null) + { + continue; + } + + // Indexer properties have getter parameters and cannot be used as resources + // because ReadAsync invokes the underlying AIFunction with no named arguments. + if (getter.GetParameters().Length > 0) + { + throw new InvalidOperationException( + $"Property '{property.Name}' on type '{selfType.Name}' is an indexer and cannot be used as a skill resource. " + + "Remove the [AgentSkillResource] attribute or use a non-indexer property."); + } + + var name = attr.Name ?? property.Name; + if (resources?.Exists(r => r.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name."); + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: name, + method: getter, + target: getter.IsStatic ? null : this, + description: property.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + // Discover resources from methods annotated with [AgentSkillResource]. + foreach (var method in selfType.GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + ValidateResourceMethodParameters(method, selfType); + + var name = attr.Name ?? method.Name; + if (resources?.Exists(r => r.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name."); + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return resources; + } + + private static void ValidateResourceMethodParameters(MethodInfo method, Type skillType) + { + foreach (var param in method.GetParameters()) + { + if (param.ParameterType != typeof(IServiceProvider) && + param.ParameterType != typeof(CancellationToken)) + { + throw new InvalidOperationException( + $"Method '{method.Name}' on type '{skillType.Name}' has parameter '{param.Name}' of type " + + $"'{param.ParameterType}' which cannot be supplied when reading a resource. " + + "Resource methods may only accept IServiceProvider and/or CancellationToken parameters. " + + "Remove the [AgentSkillResource] attribute or change the method signature."); + } + } + } + + private List? DiscoverScripts() + { + List? scripts = null; + + foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + var name = attr.Name ?? method.Name; + if (scripts?.Exists(s => s.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a script named '{name}'. Ensure each [AgentSkillScript] has a unique name."); + } + + scripts ??= []; + scripts.Add(new AgentInlineSkillScript( + name: name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return scripts; + } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs index f24d41c362..cdfb14a584 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -14,7 +15,7 @@ namespace Microsoft.Agents.AI; /// /// /// All calls to , -/// , and +/// , and /// must be made before the skill's is first accessed. /// Calls made after that point will not be reflected in the generated /// . In typical usage, this means configuring all @@ -25,6 +26,7 @@ namespace Microsoft.Agents.AI; public sealed class AgentInlineSkill : AgentSkill { private readonly string _instructions; + private readonly JsonSerializerOptions? _serializerOptions; private List? _resources; private List? _scripts; private string? _cachedContent; @@ -35,10 +37,16 @@ public sealed class AgentInlineSkill : AgentSkill /// /// The skill frontmatter containing name, description, and other metadata. /// Skill instructions text. - public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) + /// + /// Optional applied by default to all scripts and delegate resources + /// added to this skill. Individual and + /// calls can override this default. When , is used. + /// + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions, JsonSerializerOptions? serializerOptions = null) { this.Frontmatter = Throw.IfNull(frontmatter); this._instructions = Throw.IfNullOrWhitespace(instructions); + this._serializerOptions = serializerOptions; } /// @@ -52,6 +60,11 @@ public sealed class AgentInlineSkill : AgentSkill /// Optional compatibility information (max 500 chars). /// Optional space-delimited list of pre-approved tools. /// Optional arbitrary key-value metadata. + /// + /// Optional applied by default to all scripts and delegate resources + /// added to this skill. Individual and + /// calls can override this default. When , is used. + /// public AgentInlineSkill( string name, string description, @@ -59,7 +72,8 @@ public sealed class AgentInlineSkill : AgentSkill string? license = null, string? compatibility = null, string? allowedTools = null, - AdditionalPropertiesDictionary? metadata = null) + AdditionalPropertiesDictionary? metadata = null, + JsonSerializerOptions? serializerOptions = null) : this( new AgentSkillFrontmatter(name, description, compatibility) { @@ -67,7 +81,8 @@ public sealed class AgentInlineSkill : AgentSkill AllowedTools = allowedTools, Metadata = metadata, }, - instructions) + instructions, + serializerOptions) { } @@ -103,10 +118,14 @@ public sealed class AgentInlineSkill : AgentSkill /// The resource name. /// A method that produces the resource value when requested. /// An optional description of the resource. + /// + /// Optional for this resource's delegate marshaling. + /// When , the skill-level default (if any) is used; otherwise is used. + /// /// This instance, for chaining. - public AgentInlineSkill AddResource(string name, Delegate method, string? description = null) + public AgentInlineSkill AddResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) { - (this._resources ??= []).Add(new AgentInlineSkillResource(name, method, description)); + (this._resources ??= []).Add(new AgentInlineSkillResource(name, method, description, serializerOptions ?? this._serializerOptions)); return this; } @@ -117,10 +136,14 @@ public sealed class AgentInlineSkill : AgentSkill /// The script name. /// A method to execute when the script is invoked. /// An optional description of the script. + /// + /// Optional for this script's delegate marshaling. + /// When , the skill-level default (if any) is used; otherwise is used. + /// /// This instance, for chaining. - public AgentInlineSkill AddScript(string name, Delegate method, string? description = null) + public AgentInlineSkill AddScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) { - (this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description)); + (this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description, serializerOptions ?? this._serializerOptions)); return this; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs index 38791c3b21..556cfdc781 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs @@ -2,6 +2,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -40,11 +42,39 @@ internal sealed class AgentInlineSkillResource : AgentSkillResource /// The resource name. /// A method that produces the resource value when requested. /// An optional description of the resource. - public AgentInlineSkillResource(string name, Delegate method, string? description = null) + /// + /// Optional used to marshal the delegate's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) : base(name, description) { Throw.IfNull(method); - this._function = AIFunctionFactory.Create(method, name: this.Name); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, options); + } + + /// + /// Initializes a new instance of the class from a . + /// The method is invoked via an each time is called, + /// producing a dynamic (computed) value. + /// + /// The resource name. + /// A method that produces the resource value when requested. + /// The target instance for instance methods, or for static methods. + /// An optional description of the resource. + /// + /// Optional used to marshal the method's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillResource(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + : base(name, description) + { + Throw.IfNull(method); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, target, options); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index e851c6f9fc..1e3041aafc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -26,11 +27,38 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript /// The script name. /// A method to execute when the script is invoked. Parameters are automatically deserialized from JSON. /// An optional description of the script. - public AgentInlineSkillScript(string name, Delegate method, string? description = null) + /// + /// Optional used to marshal the delegate's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) : base(Throw.IfNullOrWhitespace(name), description) { Throw.IfNull(method); - this._function = AIFunctionFactory.Create(method, name: this.Name); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, options); + } + + /// + /// Initializes a new instance of the class from a . + /// The method's parameters and return type are automatically marshaled via . + /// + /// The script name. + /// The method to execute when the script is invoked. + /// The target instance for instance methods, or for static methods. + /// An optional description of the script. + /// + /// Optional used to marshal the method's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + : base(Throw.IfNullOrWhitespace(name), description) + { + Throw.IfNull(method); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, target, options); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs new file mode 100644 index 0000000000..a642d6c281 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Marks a property or method as a skill resource that is automatically discovered by . +/// +/// +/// +/// Apply this attribute to properties or methods in an subclass to register +/// them as skill resources. +/// +/// +/// To provide a description for the resource, apply +/// to the same member. +/// +/// +/// When applied to a property, the property getter is invoked each time the resource is read, +/// enabling dynamic (computed) resources. When applied to a method, the method is invoked each time +/// the resource is read, also enabling dynamic resources. Methods with an +/// parameter support dependency injection. +/// +/// +/// This attribute is compatible with Native AOT when used with . +/// Alternatively, override the property and use +/// instead. +/// +/// +/// +/// +/// public class MySkill : AgentClassSkill<MySkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill."); +/// protected override string Instructions => "Use this skill to do something."; +/// +/// [AgentSkillResource("reference-data")] +/// [Description("Some reference content for the skill.")] +/// public string ReferenceData => "Some reference content."; +/// } +/// +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillResourceAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// The resource name defaults to the property or method name. + /// + public AgentSkillResourceAttribute() + { + } + + /// + /// Initializes a new instance of the class + /// with an explicit resource name. + /// + /// The resource name used to identify this resource. + public AgentSkillResourceAttribute(string name) + { + this.Name = name; + } + + /// + /// Gets the resource name, or to use the member name. + /// + public string? Name { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs new file mode 100644 index 0000000000..30f65cf383 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Marks a method as a skill script that is automatically discovered by . +/// +/// +/// +/// Apply this attribute to methods in an subclass to register them as +/// skill scripts. The method's parameters and return type are automatically marshaled via +/// AIFunctionFactory. +/// +/// +/// To provide a description for the script, apply +/// to the same method. +/// +/// +/// Methods can be instance or static, and may have any visibility (public, private, etc.). +/// Methods with an parameter support dependency injection. +/// +/// +/// This attribute is compatible with Native AOT when used with . +/// Alternatively, override the property and use +/// instead. +/// +/// +/// +/// +/// public class MySkill : AgentClassSkill<MySkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill."); +/// protected override string Instructions => "Use this skill to do something."; +/// +/// [AgentSkillScript("do-something")] +/// [Description("Converts the input to upper case.")] +/// private static string DoSomething(string input) => input.ToUpperInvariant(); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillScriptAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// The script name defaults to the method name. + /// + public AgentSkillScriptAttribute() + { + } + + /// + /// Initializes a new instance of the class + /// with an explicit script name. + /// + /// The script name used to identify this script. + public AgentSkillScriptAttribute(string name) + { + this.Name = name; + } + + /// + /// Gets the script name, or to use the method name. + /// + public string? Name { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 1ddc8c7c82..31f981d5c6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -196,6 +196,48 @@ public class FoundryAgentTests #endregion + #region CreateSessionAsync tests + + [Fact] + public async Task CreateSessionAsync_WithConversationId_ReturnsChatClientAgentSessionAsync() + { + // Arrange + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + const string ConversationId = "test-conversation-id"; + + // Act + AgentSession session = await agent.CreateSessionAsync(ConversationId); + + // Assert + ChatClientAgentSession chatSession = Assert.IsType(session); + Assert.Equal(ConversationId, chatSession.ConversationId); + } + + [Fact] + public async Task CreateSessionAsync_WithoutConversationId_ReturnsChatClientAgentSessionWithoutConversationIdAsync() + { + // Arrange + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + // Act + AgentSession session = await agent.CreateSessionAsync(); + + // Assert + ChatClientAgentSession chatSession = Assert.IsType(session); + Assert.Null(chatSession.ConversationId); + } + + #endregion + #region Functional tests [Fact] 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 490f816cd4..e0b072a44b 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 @@ -21,6 +21,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs new file mode 100644 index 0000000000..785a3b2e00 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.AGUI; +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 SessionPersistenceTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync() + { + // Arrange - use hosting DI pattern with InMemorySessionStore. + // FakeSessionAgent tracks turn count in session StateBag so we can verify + // that state survives the serialization round-trip through the session store. + await this.SetupTestServerWithSessionStoreAsync(); + var chatClient = new AGUIChatClient(this._client!, "", null); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + // Act - First turn + ChatMessage firstUserMessage = new(ChatRole.User, "First message"); + List firstTurnUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + firstTurnUpdates.Add(update); + } + + // Act - Second turn (same thread ID to test session persistence) + ChatMessage secondUserMessage = new(ChatRole.User, "Second message"); + List secondTurnUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + secondTurnUpdates.Add(update); + } + + // Assert - Verify turn count proves session state was persisted. + // If session persistence were broken, both turns would return "Turn 1" + // because a fresh session (with turn count 0) would be created each time. + AgentResponse firstResponse = firstTurnUpdates.ToAgentResponse(); + firstResponse.Messages.Should().HaveCount(1); + firstResponse.Messages[0].Role.Should().Be(ChatRole.Assistant); + firstResponse.Messages[0].Text.Should().Contain("Turn 1:"); + + AgentResponse secondResponse = secondTurnUpdates.ToAgentResponse(); + secondResponse.Messages.Should().HaveCount(1); + secondResponse.Messages[0].Role.Should().Be(ChatRole.Assistant); + secondResponse.Messages[0].Text.Should().Contain("Turn 2:"); + } + + [Fact] + public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync() + { + // Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI + await this.SetupTestServerWithSessionStoreAsync(); + var chatClient = new AGUIChatClient(this._client!, "", null); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + ChatMessage userMessage = new(ChatRole.User, "hello"); + + List updates = []; + + // Act + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + updates.Add(update); + } + + // Assert + updates.Should().NotBeEmpty(); + updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant)); + + AgentResponse response = updates.ToAgentResponse(); + response.Messages.Should().HaveCount(1); + response.Messages[0].Role.Should().Be(ChatRole.Assistant); + response.Messages[0].Text.Should().Be("Turn 1: Hello from session agent!"); + } + + private async Task SetupTestServerWithSessionStoreAsync() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddAGUI(); + + // Register agent using hosting DI pattern with InMemorySessionStore + builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name)) + .WithInMemorySessionStore(); + + this._app = builder.Build(); + + // Use the agentName overload of MapAGUI + this._app.MapAGUI("session-test-agent", "/agent"); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._client = testServer.CreateClient(); + this._client.BaseAddress = new Uri("http://localhost/agent"); + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } +} + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] +internal sealed class FakeSessionAgent : AIAgent +{ + private readonly string _name; + + public FakeSessionAgent(string name) + { + this._name = name; + } + + protected override string? IdCore => this._name; + + public override string? Name => this._name; + + public override string? Description => "A fake agent with session support for testing"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new FakeSessionAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(serializedState.Deserialize(jsonSerializerOptions)!); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not FakeSessionAgentSession fakeSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent."); + } + + return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions)); + } + + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (AgentResponseUpdate update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToAgentResponse(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Track turn count in session state to enable persistence verification. + // If the session store works correctly, the turn count increments across requests. + int turnCount = 1; + if (session != null) + { + var counter = session.StateBag.GetValue("turnCounter"); + turnCount = (counter?.Count ?? 0) + 1; + session.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount }); + } + + string messageId = Guid.NewGuid().ToString("N"); + string prefix = $"Turn {turnCount}: "; + + foreach (string chunk in new[] { prefix, "Hello", " ", "from", " ", "session", " ", "agent", "!" }) + { + yield return new AgentResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new TextContent(chunk)] + }; + + await Task.Yield(); + } + } + + internal sealed class TurnCounter + { + public int Count { get; set; } + } + + private sealed class FakeSessionAgentSession : AgentSession + { + public FakeSessionAgentSession() + { + } + + [JsonConstructor] + public FakeSessionAgentSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} 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 84a20e1938..248629b392 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 @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -31,6 +32,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); + serviceProviderMock.As(); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -45,6 +47,155 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests Assert.NotNull(result); } + [Fact] + public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + AIAgent agent = new NamedTestAgent(); + + serviceProviderMock.As() + .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) + .Returns(agent); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent"); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock agentBuilderMock = new(); + AIAgent agent = new NamedTestAgent(); + + agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + + serviceProviderMock.As() + .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) + .Returns(agent); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent"); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock sessionStoreMock = new(); + AIAgent agent = new NamedTestAgent(); + + serviceProviderMock.As() + .Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent")) + .Returns(sessionStoreMock.Object); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + AIAgent agent = new TestAgent(); + + // No session store registered - IKeyedServiceProvider returns null by default + serviceProviderMock.As(); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act - should not throw (falls back to NoopAgentSessionStore) + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AIAgent agent = new TestAgent(); + + // Act & Assert + Assert.Throws(() => + AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent)); + } + + [Fact] + public void MapAGUI_WithNullAgent_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI("/api/agent", (AIAgent)null!)); + } + + [Fact] + public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI((string)null!, "/api/agent")); + } + + [Fact] + public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent")); + } + [Fact] public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync() { @@ -556,4 +707,44 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); } } + + private sealed class NamedTestAgent : AIAgent + { + protected override string? IdCore => "test-agent"; + + public override string? Name => "test-agent"; + + public override string? Description => "Named test agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TestAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(serializedState.Deserialize(jsonSerializerOptions)!); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not TestAgentSession testSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent."); + } + + return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions)); + } + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 2c453f21b6..1dc7d5b3f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -2,63 +2,60 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for and . +/// Unit tests for and . /// public sealed class AgentClassSkillTests { [Fact] - public void Resources_DefaultsToNull_WhenNotOverridden() + public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent() { // Arrange var skill = new MinimalClassSkill(); - // Act & Assert + // Act & Assert — null overrides + Assert.Equal("minimal", skill.Frontmatter.Name); Assert.Null(skill.Resources); - } - - [Fact] - public void Scripts_DefaultsToNull_WhenNotOverridden() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert Assert.Null(skill.Scripts); + + // Act & Assert — synthesized XML content + Assert.Contains("minimal", skill.Content); + Assert.Contains("A minimal skill.", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + Assert.Contains("", skill.Content); } [Fact] - public void Resources_ReturnsOverriddenList_WhenOverridden() + public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent() { // Arrange var skill = new FullClassSkill(); - // Act - var resources = skill.Resources; + // Act & Assert — overridden resources and scripts + Assert.Single(skill.Resources!); + Assert.Equal("test-resource", skill.Resources![0].Name); - // Assert - Assert.Single(resources!); - Assert.Equal("test-resource", resources![0].Name); - } + Assert.Single(skill.Scripts!); + Assert.Equal("TestScript", skill.Scripts![0].Name); - [Fact] - public void Scripts_ReturnsOverriddenList_WhenOverridden() - { - // Arrange - var skill = new FullClassSkill(); + // Act & Assert — Content is cached + Assert.Same(skill.Content, skill.Content); - // Act - var scripts = skill.Scripts; - - // Assert - Assert.Single(scripts!); - Assert.Equal("TestScript", scripts![0].Name); + // Act & Assert — Content includes parameter schema from typed script + Assert.Contains("parameters_schema", skill.Content); + Assert.Contains("value", skill.Content); } [Fact] @@ -84,37 +81,11 @@ public sealed class AgentClassSkillTests Assert.Equal(1, skill.ScriptCreationCount); } - [Fact] - public void Name_Content_ReturnClassDefinedValues() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert - Assert.Equal("minimal", skill.Frontmatter.Name); - Assert.Contains("", skill.Content); - Assert.Contains("Minimal skill body.", skill.Content); - Assert.Contains("", skill.Content); - } - - [Fact] - public void Content_ReturnsSynthesizedXmlDocument() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert - Assert.Contains("minimal", skill.Content); - Assert.Contains("A minimal skill.", skill.Content); - Assert.Contains("", skill.Content); - Assert.Contains("Minimal skill body.", skill.Content); - } - [Fact] public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync() { // Arrange - var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() }; + var skills = new AgentSkill[] { new MinimalClassSkill(), new FullClassSkill() }; var source = new AgentInMemorySkillsSource(skills); // Act @@ -134,203 +105,559 @@ public sealed class AgentClassSkillTests } [Fact] - public void SkillWithOnlyResources_HasNullScripts() + public void PartialOverrides_OneCollectionNull_OtherHasValues() { // Arrange - var skill = new ResourceOnlySkill(); + var resourceOnly = new ResourceOnlySkill(); + var scriptOnly = new ScriptOnlySkill(); // Act & Assert - Assert.Single(skill.Resources!); - Assert.Null(skill.Scripts); + Assert.Single(resourceOnly.Resources!); + Assert.Null(resourceOnly.Scripts); + Assert.Null(scriptOnly.Resources); + Assert.Single(scriptOnly.Scripts!); } [Fact] - public void SkillWithOnlyScripts_HasNullResources() + public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync() { // Arrange - var skill = new ScriptOnlySkill(); + var skill = new CustomTypeSkill(); + var jso = SkillTestJsonContext.Default.Options; + + // Act — script with custom type deserialization + var script = skill.Scripts![0]; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(scriptResult); + var resultText = scriptResult!.ToString()!; + Assert.Contains("result for test", resultText); + Assert.Contains("5", resultText); + + // Act — resource with custom type serialization + var resourceResult = await skill.Resources![0].ReadAsync(); + + // Assert + Assert.NotNull(resourceResult); + Assert.Contains("dark", resourceResult!.ToString()!); + } + + [Fact] + public void Scripts_DiscoveredViaAttribute_WithCorrectNamesAndDescriptions() + { + // Arrange + var skill = new AttributedScriptsSkill(); + + // Act + var scripts = skill.Scripts; + + // Assert — all scripts discovered with correct metadata + Assert.NotNull(scripts); + Assert.Equal(4, scripts!.Count); + Assert.Contains(scripts, s => s.Name == "do-work"); + Assert.Contains(scripts, s => s.Name == "DefaultNamed"); + Assert.Contains(scripts, s => s.Name == "append"); + + var processScript = scripts.First(s => s.Name == "process"); + Assert.Equal("Processes the input.", processScript.Description); + } + + [Fact] + public async Task Scripts_DiscoveredViaAttribute_StaticAndInstance_CanBeInvokedAsync() + { + // Arrange + var skill = new AttributedScriptsSkill(); + + // Act & Assert — static method + var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work"); + var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None); + Assert.Equal("HELLO", doWorkResult?.ToString()); + + // Act & Assert — instance method + var appendScript = skill.Scripts!.First(s => s.Name == "append"); + var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None); + Assert.Equal("test-suffix", appendResult?.ToString()); + } + + [Fact] + public void Resources_DiscoveredViaAttribute_OnProperties_WithCorrectMetadata() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); + + // Act + var resources = skill.Resources; + + // Assert — all resources discovered with correct metadata + Assert.NotNull(resources); + Assert.Equal(4, resources!.Count); + Assert.Contains(resources, r => r.Name == "ref-data"); + Assert.Contains(resources, r => r.Name == "DefaultNamed"); + Assert.Contains(resources, r => r.Name == "static-data"); + + var describedResource = resources.First(r => r.Name == "data"); + Assert.Equal("Some important data.", describedResource.Description); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnProperties_CanBeReadAsync() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); + + // Act & Assert — instance property + var refData = skill.Resources!.First(r => r.Name == "ref-data"); + Assert.Equal("Reference content.", (await refData.ReadAsync())?.ToString()); + + // Act & Assert — static property + var staticData = skill.Resources!.First(r => r.Name == "static-data"); + Assert.Equal("Static content.", (await staticData.ReadAsync())?.ToString()); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnProperty_InvokedEachTimeAsync() + { + // Arrange + var skill = new AttributedResourceDynamicPropertySkill(); + var resource = skill.Resources![0]; + + // Act + var first = await resource.ReadAsync(); + var second = await resource.ReadAsync(); + + // Assert — property getter is called on each ReadAsync, producing different values + Assert.Equal("call-1", first?.ToString()); + Assert.Equal("call-2", second?.ToString()); + Assert.Equal(2, skill.CallCount); + } + + [Fact] + public void Resources_DiscoveredViaAttribute_OnMethods_WithCorrectMetadata() + { + // Arrange + var skill = new AttributedResourceMethodsSkill(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.NotNull(resources); + Assert.Equal(4, resources!.Count); + Assert.Contains(resources, r => r.Name == "dynamic"); + Assert.Contains(resources, r => r.Name == "GetData"); + Assert.Contains(resources, r => r.Name == "instance-dynamic"); + + var describedResource = resources.First(r => r.Name == "info"); + Assert.Equal("Returns runtime info.", describedResource.Description); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnMethods_CanBeReadAsync() + { + // Arrange + var skill = new AttributedResourceMethodsSkill(); + + // Act & Assert — static method + var dynamicResource = skill.Resources!.First(r => r.Name == "dynamic"); + Assert.Equal("dynamic-value", (await dynamicResource.ReadAsync())?.ToString()); + + // Act & Assert — instance method + var instanceResource = skill.Resources!.First(r => r.Name == "instance-dynamic"); + Assert.Equal("instance-method-value", (await instanceResource.ReadAsync())?.ToString()); + } + + [Fact] + public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers() + { + // Arrange + var skill = new AttributedFullSkill(); + + // Act & Assert — Content includes reflected resources and scripts + Assert.Contains("", skill.Content); + Assert.Contains("conversion-table", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("convert", skill.Content); + + // Act & Assert — discovered members are cached + Assert.Same(skill.Resources, skill.Resources); + Assert.Same(skill.Scripts, skill.Scripts); + + // Act & Assert — script has parameters schema + var script = skill.Scripts![0]; + Assert.NotNull(script.ParametersSchema); + Assert.Contains("value", script.ParametersSchema!.Value.GetRawText()); + } + + [Fact] + public void NoAttributedMembers_NoOverrides_ReturnsNull() + { + // Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list) + var skill = new NoAttributesNoOverridesSkill(); + var baseType = typeof(AgentClassSkill); + var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic); + var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic); + var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic); + var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(resourcesDiscoveredField); + Assert.NotNull(scriptsDiscoveredField); + Assert.NotNull(reflectedResourcesField); + Assert.NotNull(reflectedScriptsField); + Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!); + Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!); // Act & Assert Assert.Null(skill.Resources); - Assert.Single(skill.Scripts!); + Assert.Null(skill.Scripts); + Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!); + Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!); + Assert.Null(reflectedResourcesField!.GetValue(skill)); + Assert.Null(reflectedScriptsField!.GetValue(skill)); + + // Repeated access should not re-trigger discovery even when discovered value is null. + Assert.Null(skill.Resources); + Assert.Null(skill.Scripts); + Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!); + Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!); + Assert.Null(reflectedResourcesField.GetValue(skill)); + Assert.Null(reflectedScriptsField.GetValue(skill)); } [Fact] - public void Content_ReturnsCachedInstance_OnRepeatedAccess() + public void SubclassOverride_TakesPrecedence_OverAttributes() { - // Arrange - var skill = new FullClassSkill(); + // Arrange — skill has attributes AND overrides Resources/Scripts + var skill = new AttributedWithOverrideSkill(); // Act - var first = skill.Content; - var second = skill.Content; + var resources = skill.Resources; + var scripts = skill.Scripts; - // Assert - Assert.Same(first, second); + // Assert — overrides win, not reflected members + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("manual-resource", resources![0].Name); + Assert.NotNull(scripts); + Assert.Single(scripts!); + Assert.Equal("ManualScript", scripts![0].Name); } [Fact] - public void Content_IncludesParametersSchema_WhenScriptsHaveParameters() + public async Task MixedStaticAndInstance_AllDiscoveredAndInvocableAsync() { // Arrange - var skill = new FullClassSkill(); + var skill = new MixedStaticInstanceSkill(); + + // Act & Assert — correct counts + Assert.NotNull(skill.Resources); + Assert.Equal(2, skill.Resources!.Count); + Assert.NotNull(skill.Scripts); + Assert.Equal(2, skill.Scripts!.Count); + + // Act & Assert — all resources produce values + foreach (var resource in skill.Resources!) + { + var value = await resource.ReadAsync(); + Assert.NotNull(value); + } + + // Act & Assert — all scripts produce values + foreach (var script in skill.Scripts!) + { + var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + Assert.NotNull(result); + } + } + + [Fact] + public async Task SerializerOptions_UsedForReflectedMembersAsync() + { + // Arrange + var skill = new AttributedSkillWithCustomSerializer(); + var jso = SkillTestJsonContext.Default.Options; + + // Act & Assert — script with custom JSO + var script = skill.Scripts![0]; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + Assert.NotNull(scriptResult); + Assert.Contains("test", scriptResult!.ToString()!); + Assert.Contains("3", scriptResult!.ToString()!); + + // Act & Assert — resource with custom JSO + var resourceResult = await skill.Resources![0].ReadAsync(); + Assert.NotNull(resourceResult); + Assert.Contains("light", resourceResult!.ToString()!); + } + + [Fact] + public void Content_IncludesDescription_ForReflectedResources() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); // Act var content = skill.Content; - // Assert — scripts with typed parameters should have their schema included - Assert.Contains("parameters_schema", content); - Assert.Contains("value", content); + // Assert — descriptions from [Description] attribute appear in synthesized content + Assert.Contains("Some important data.", content); } [Fact] - public void Content_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides() + public void IndexerPropertyWithResourceAttribute_ThrowsInvalidOperationException() { // Arrange - var skill = new DerivedResourceSkill(); + var skill = new IndexerResourceSkill(); - // Act - var content = skill.Content; - - // Assert - Assert.Contains("", content); - Assert.Contains("custom-resource", content); - Assert.Contains("Custom resource description.", content); + // Act & Assert — accessing Resources triggers discovery which should throw + var ex = Assert.Throws(() => skill.Resources); + Assert.Contains("indexer", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IndexerResourceSkill", ex.Message); } [Fact] - public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides() + public void ResourceMethodWithUnsupportedParameters_ThrowsInvalidOperationException() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new UnsupportedParamResourceMethodSkill(); - // Act - var content = skill.Content; - - // Assert - Assert.Contains("", content); - Assert.Contains("custom-script", content); - Assert.Contains("Custom script description.", content); + // Act & Assert — accessing Resources triggers discovery which should throw + var ex = Assert.Throws(() => skill.Resources); + Assert.Contains("content", ex.Message); + Assert.Contains("String", ex.Message); } [Fact] - public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne() + public async Task ResourceMethodWithServiceProviderParam_IsDiscoveredSuccessfullyAsync() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new ServiceProviderResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); // Act - var content = skill.Content; + var resources = skill.Resources; // Assert - Assert.DoesNotContain("parameters_schema", content); + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("sp-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(sp); + Assert.Equal("from-sp-method", value?.ToString()); + } + + [Fact] + public async Task ResourceMethodWithCancellationTokenParam_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new CancellationTokenResourceMethodSkill(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("ct-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(); + Assert.Equal("from-ct-method", value?.ToString()); + } + + [Fact] + public async Task ResourceMethodWithBothServiceProviderAndCancellationToken_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new BothParamsResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("both-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(sp); + Assert.Equal("from-both-method", value?.ToString()); + } + + [Fact] + public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() + { + // Arrange + var skill = new CreateMethodsFallbackSkill(); + + // Act — invoke script that uses custom types, relying on SerializerOptions fallback + var script = skill.Scripts!.First(s => s.Name == "Lookup"); + var jso = SkillTestJsonContext.Default.Options; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Contains("fallback", result!.ToString()!); + Assert.Contains("7", result!.ToString()!); + } + + [Fact] + public async Task CreateResource_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() + { + // Arrange + var skill = new CreateMethodsFallbackSkill(); + + // Act — read resource that uses custom types, relying on SerializerOptions fallback + var resource = skill.Resources!.First(r => r.Name == "config"); + var result = await resource.ReadAsync(); + + // Assert + Assert.NotNull(result); + Assert.Contains("dark", result!.ToString()!); + } + + [Fact] + public async Task CreateScript_UsesExplicitJso_OverSerializerOptionsAsync() + { + // Arrange + var skill = new CreateMethodsExplicitJsoSkill(); + + // Act — invoke script that passes explicit JSO (should take precedence over SerializerOptions) + var script = skill.Scripts!.First(s => s.Name == "Lookup"); + var jso = SkillTestJsonContext.Default.Options; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Contains("explicit", result!.ToString()!); + Assert.Contains("2", result!.ToString()!); + } + + [Fact] + public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync() + { + // Arrange + var skill = new CreateMethodsExplicitJsoSkill(); + + // Act — read resource that passes explicit JSO (should take precedence over SerializerOptions) + var resource = skill.Resources!.First(r => r.Name == "config"); + var result = await resource.ReadAsync(); + + // Assert + Assert.NotNull(result); + Assert.Contains("explicit-theme", result!.ToString()!); + } + + [Fact] + public void DuplicateResourceNames_FromProperties_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourcePropertiesSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateResourceNames_FromPropertyAndMethod_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourcePropertyAndMethodSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateResourceNames_FromMethods_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourceMethodsSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateScriptNames_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateScriptsSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Scripts); + Assert.Contains("do-work", ex.Message); + Assert.Contains("already has a script", ex.Message); } #region Test skill classes - private sealed class MinimalClassSkill : AgentClassSkill + private sealed class MinimalClassSkill : AgentClassSkill { public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); protected override string Instructions => "Minimal skill body."; - - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => null; } - private sealed class FullClassSkill : AgentClassSkill + private sealed class FullClassSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts."); protected override string Instructions => "Full skill body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - CreateResource("test-resource", "resource content"), + this.CreateResource("test-resource", "resource content"), ]; - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ - CreateScript("TestScript", TestScript), + this.CreateScript("TestScript", TestScript), ]; private static string TestScript(double value) => JsonSerializer.Serialize(new { result = value * 2 }); } - private sealed class ResourceOnlySkill : AgentClassSkill + private sealed class ResourceOnlySkill : AgentClassSkill { - private IReadOnlyList? _resources; - public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - CreateResource("data", "some data"), + this.CreateResource("data", "some data"), ]; - - public override IReadOnlyList? Scripts => null; } - private sealed class ScriptOnlySkill : AgentClassSkill + private sealed class ScriptOnlySkill : AgentClassSkill { - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ - CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), + this.CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), ]; } - private sealed class DerivedResourceSkill : AgentClassSkill + private sealed class LazyLoadedSkill : AgentClassSkill { - private IReadOnlyList? _resources; - - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type."); - - protected override string Instructions => "Body."; - - public override IReadOnlyList? Resources => this._resources ??= - [ - new CustomResource("custom-resource", "Custom resource description."), - ]; - - public override IReadOnlyList? Scripts => null; - } - - private sealed class DerivedScriptSkill : AgentClassSkill - { - private IReadOnlyList? _scripts; - - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-script", "Skill with a derived script type."); - - protected override string Instructions => "Body."; - - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => this._scripts ??= - [ - new CustomScript("custom-script", "Custom script description."), - ]; - } - - private sealed class LazyLoadedSkill : AgentClassSkill - { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts."); protected override string Instructions => "Body."; @@ -339,6 +666,9 @@ public sealed class AgentClassSkillTests public int ScriptCreationCount { get; private set; } + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + public override IReadOnlyList? Resources => this._resources ??= this.CreateResources(); public override IReadOnlyList? Scripts => this._scripts ??= this.CreateScripts(); @@ -346,37 +676,353 @@ public sealed class AgentClassSkillTests private IReadOnlyList CreateResources() { this.ResourceCreationCount++; - return [CreateResource("lazy-resource", "resource content")]; + return [this.CreateResource("lazy-resource", "resource content")]; } private IReadOnlyList CreateScripts() { this.ScriptCreationCount++; - return [CreateScript("LazyScript", () => "done")]; + return [this.CreateScript("LazyScript", () => "done")]; } } - private sealed class CustomResource : AgentSkillResource + private sealed class CustomTypeSkill : AgentClassSkill { - public CustomResource(string name, string? description = null) - : base(name, description) - { - } + public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources."); - public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) - => Task.FromResult("resource-value"); + protected override string Instructions => "Body."; + + public override IReadOnlyList? Resources => + [ + this.CreateResource("config", () => new SkillConfig + { + Theme = "dark", + Verbose = true + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; } - private sealed class CustomScript : AgentSkillScript +#pragma warning disable IDE0051 // Remove unused private members + private sealed class AttributedScriptsSkill : AgentClassSkill { - public CustomScript(string name, string? description = null) - : base(name, description) - { - } + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-scripts", "Skill with various attributed scripts."); - public override Task RunAsync(AgentSkill skill, Extensions.AI.AIFunctionArguments arguments, CancellationToken cancellationToken = default) - => Task.FromResult("script-result"); + protected override string Instructions => "Body."; + + [AgentSkillScript("do-work")] + private static string DoWork(string input) => input.ToUpperInvariant(); + + [AgentSkillScript] + private static string DefaultNamed(string input) => input.ToUpperInvariant(); + + [AgentSkillScript("process")] + [Description("Processes the input.")] + private static string Process(string input) => input; + + [AgentSkillScript("append")] + private string Append(string input) => input + "-suffix"; } + private sealed class AttributedResourcePropertiesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-props", "Skill with various attributed resource properties."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("ref-data")] + public string ReferenceData => "Reference content."; + + [AgentSkillResource] + public string DefaultNamed => "Some data."; + + [AgentSkillResource("data")] + [Description("Some important data.")] + public string DescribedData => "content"; + + [AgentSkillResource("static-data")] + public static string StaticData => "Static content."; + } + + private sealed class AttributedResourceMethodsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-methods", "Skill with various attributed resource methods."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("dynamic")] + private static string GetDynamic() => "dynamic-value"; + + [AgentSkillResource] + private static string GetData() => "data"; + + [AgentSkillResource("info")] + [Description("Returns runtime info.")] + private static string GetInfo() => "runtime-info"; + + [AgentSkillResource("instance-dynamic")] + private string GetValue() => "instance-method-value"; + } + + private sealed class AttributedFullSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-full", "Full skill with attributed resources and scripts."); + + protected override string Instructions => "Convert units using the table."; + + [AgentSkillResource("conversion-table")] + public string ConversionTable => "miles -> km: 1.60934"; + + [AgentSkillScript("convert")] + private static string Convert(double value, double factor) => + JsonSerializer.Serialize(new { result = value * factor }); + } + + private sealed class NoAttributesNoOverridesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("no-attrs", "Skill with no attributes or overrides."); + + protected override string Instructions => "Body."; + } + + private sealed class AttributedWithOverrideSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-override", "Skill with attributes and overrides."); + + protected override string Instructions => "Body."; + + // These attributes should be ignored because Resources/Scripts are overridden. + [AgentSkillResource("ignored-resource")] + public string IgnoredData => "ignored"; + + [AgentSkillScript("ignored-script")] + private static string IgnoredScript() => "ignored"; + + public override IReadOnlyList? Resources => + [ + this.CreateResource("manual-resource", "manual content"), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("ManualScript", () => "manual result"), + ]; + } + + private sealed class AttributedResourceDynamicPropertySkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-dynamic-prop", "Skill with dynamic property resource."); + + protected override string Instructions => "Body."; + + public int CallCount { get; private set; } + + [AgentSkillResource("counter")] + public string Counter => $"call-{++this.CallCount}"; + } + + private sealed class AttributedSkillWithCustomSerializer : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-custom-jso", "Skill with custom serializer options."); + + protected override string Instructions => "Body."; + + protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options; + + [AgentSkillResource("config")] + public SkillConfig Config => new() { Theme = "light", Verbose = false }; + + [AgentSkillScript("lookup")] + private static LookupResponse Lookup(LookupRequest request) => new() + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }; + } + + private sealed class MixedStaticInstanceSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("mixed-static-instance", "Skill with both static and instance members."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("static-resource")] + public static string StaticResource => "static-value"; + + [AgentSkillResource("instance-resource")] + public string InstanceResource => "instance-data"; + + [AgentSkillScript("static-script")] + private static string StaticScript() => "static-result"; + + [AgentSkillScript("instance-script")] + private string InstanceScript() => "instance-data"; + } + + private sealed class CreateMethodsFallbackSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-fallback", "Skill testing SerializerOptions fallback for CreateScript/CreateResource."); + + protected override string Instructions => "Body."; + + protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options; + + public override IReadOnlyList? Resources => + [ + this.CreateResource("config", () => new SkillConfig + { + Theme = "dark", + Verbose = true, + }), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }), + ]; + } + + private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-explicit-jso", "Skill testing explicit JSO overrides SerializerOptions."); + + protected override string Instructions => "Body."; + + // SerializerOptions is intentionally null — explicit JSO passed to CreateScript/CreateResource should be used. + public override IReadOnlyList? Resources => + [ + this.CreateResource("config", () => new SkillConfig + { + Theme = "explicit-theme", + Verbose = false, + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; + } + + private sealed class IndexerResourceSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("indexer-skill", "Skill with indexer resource."); + + protected override string Instructions => "Body."; + + private readonly Dictionary _data = new() { ["key"] = "value" }; + + [AgentSkillResource("indexed")] + public string this[string key] => this._data[key]; + } + + private sealed class UnsupportedParamResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("unsupported-param-skill", "Skill with unsupported param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("bad-resource")] + private static string GetData(string content) => content; + } + + private sealed class ServiceProviderResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("sp-param-skill", "Skill with IServiceProvider param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("sp-resource")] + private static string GetData(IServiceProvider? sp) => "from-sp-method"; + } + + private sealed class CancellationTokenResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("ct-param-skill", "Skill with CancellationToken param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("ct-resource")] + private static string GetData(CancellationToken ct) => "from-ct-method"; + } + + private sealed class BothParamsResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("both-param-skill", "Skill with both IServiceProvider and CancellationToken param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("both-resource")] + private static string GetData(IServiceProvider? sp, CancellationToken ct) => "from-both-method"; + } + private sealed class DuplicateResourcePropertiesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-props", "Skill with duplicate resource property names."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + public string Data1 => "value1"; + + [AgentSkillResource("data")] + public string Data2 => "value2"; + } + + private sealed class DuplicateResourcePropertyAndMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-prop-method", "Skill with duplicate resource from property and method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + public string Data => "property-value"; + + [AgentSkillResource("data")] + private static string GetData() => "method-value"; + } + + private sealed class DuplicateResourceMethodsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-methods", "Skill with duplicate resource method names."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + private static string GetData1() => "value1"; + + [AgentSkillResource("data")] + private static string GetData2() => "value2"; + } + + private sealed class DuplicateScriptsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-scripts", "Skill with duplicate script names."); + + protected override string Instructions => "Body."; + + [AgentSkillScript("do-work")] + private static string DoWork1(string input) => input.ToUpperInvariant(); + + [AgentSkillScript("do-work")] + private static string DoWork2(string input) => input + "-suffix"; + } +#pragma warning restore IDE0051 // Remove unused private members + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs index ef8f7780a6..f1ca662202 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -114,9 +114,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync() + public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync() { - // Arrange — scripts at any depth in the skill directory are discovered + // Arrange — scripts outside configured directories are not discovered; only files directly + // inside the configured directory are picked up (no subdirectory recursion) string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); CreateFile(skillDir, "convert.py", "print('root')"); CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); @@ -125,12 +126,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert + // Assert — neither file is in the default scripts/ directory, so no scripts are discovered Assert.Single(skills); - var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); - Assert.Equal(2, scriptNames.Count); - Assert.Contains("convert.py", scriptNames); - Assert.Contains("tools/helper.sh", scriptNames); + Assert.Empty(skills[0].Scripts!); } [Fact] @@ -230,6 +228,55 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable Assert.Equal(1.60934, capturedArgs["factor"]); } + [Fact] + public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync() + { + // Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3) + string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body."); + CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert — script file inside the deeply nested directory is discovered + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name); + } + + [Theory] + [InlineData("./scripts")] + [InlineData("./scripts/f1")] + [InlineData("./scripts/f1", "./f2")] + public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories) + { + // Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix; + // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration. + string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body."); + foreach (string directory in directories) + { + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" + CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')"); + } + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ScriptDirectories = directories }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert — scripts are discovered with names identical to using directories without "./" + Assert.Single(skills); + Assert.Equal(directories.Length, skills[0].Scripts!.Count); + foreach (string directory in directories) + { + string expectedName = $"{directory.Substring(2)}/run.py"; + Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName); + } + } + private static string CreateSkillDir(string root, string name, string description, string body) { string skillDir = Path.Combine(root, name); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs index 202a90d460..46724ca9b5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -139,6 +140,21 @@ public sealed class AgentInlineSkillResourceTests Assert.Equal("Dynamic resource.", resource.Description); } + [Fact] + public async Task ReadAsync_WithSerializerOptions_SerializesReturnCustomTypeAsync() + { + // Arrange — delegate resource returns a custom type; the JSO includes a source-generated context for it + var jso = SkillTestJsonContext.Default.Options; + var resource = new AgentInlineSkillResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: jso); + + // Act + var result = await resource.ReadAsync(); + + // Assert — the custom type was returned successfully + Assert.NotNull(result); + Assert.Contains("dark", result!.ToString()!); + } + [Fact] public async Task ReadAsync_SupportsCancellationTokenAsync() { @@ -152,4 +168,58 @@ public sealed class AgentInlineSkillResourceTests // Assert Assert.Equal("value", result); } + + [Fact] + public void Constructor_MethodInfo_SetsNameAndDescription() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + var resource = new AgentInlineSkillResource("method-resource", method, target: null, description: "A method resource."); + + // Assert + Assert.Equal("method-resource", resource.Name); + Assert.Equal("A method resource.", resource.Description); + } + + [Fact] + public async Task ReadAsync_MethodInfo_StaticMethod_ReturnsValueAsync() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var resource = new AgentInlineSkillResource("static-method-res", method, target: null); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("static-resource-value", result?.ToString()); + } + + [Fact] + public async Task ReadAsync_MethodInfo_InstanceMethod_ReturnsValueAsync() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(InstanceResourceHelper), BindingFlags.NonPublic | BindingFlags.Instance)!; + var resource = new AgentInlineSkillResource("instance-method-res", method, target: this); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("instance-resource-value", result?.ToString()); + } + + [Fact] + public void Constructor_MethodInfo_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource("my-res", null!, target: null)); + } + + private static string StaticResourceHelper() => "static-resource-value"; + + private string InstanceResourceHelper() => "instance-resource-value"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs index 61f42ca66d..efab3b2c7d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -115,6 +117,28 @@ public sealed class AgentInlineSkillScriptTests new AgentInlineSkillScript("my-script", null!)); } + [Fact] + public async Task RunAsync_WithSerializerOptions_MarshalsCustomTypesAsync() + { + // Arrange — script accepts a custom type; the JSO includes a source-generated context for it + var jso = SkillTestJsonContext.Default.Options; + var script = new AgentInlineSkillScript("lookup", (LookupRequest request) => new LookupResponse + { + Items = ["result-1", "result-2"], + TotalCount = request.MaxResults, + }, serializerOptions: jso); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert — the custom input type was deserialized and the response was produced + Assert.NotNull(result); + Assert.Contains("5", result!.ToString()!); + } + [Fact] public async Task RunAsync_StringParameter_WorksAsync() { @@ -129,4 +153,77 @@ public sealed class AgentInlineSkillScriptTests // Assert Assert.Equal("hello world", result?.ToString()); } + + [Fact] + public void Constructor_MethodInfo_SetsNameAndDescription() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + var script = new AgentInlineSkillScript("method-script", method, target: null, description: "A method script."); + + // Assert + Assert.Equal("method-script", script.Name); + Assert.Equal("A method script.", script.Description); + } + + [Fact] + public async Task RunAsync_MethodInfo_StaticMethod_InvokesAndReturnsAsync() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var script = new AgentInlineSkillScript("static-method-script", method, target: null); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["input"] = "hello" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("HELLO", result?.ToString()); + } + + [Fact] + public async Task RunAsync_MethodInfo_InstanceMethod_InvokesAndReturnsAsync() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!; + var script = new AgentInlineSkillScript("instance-method-script", method, target: this); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["input"] = "test" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("test-suffix", result?.ToString()); + } + + [Fact] + public void Constructor_MethodInfo_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillScript("my-script", null!, target: null)); + } + + [Fact] + public void ParametersSchema_MethodInfo_ContainsParameterNames() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var script = new AgentInlineSkillScript("param-script", method, target: null); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + Assert.Contains("input", schema!.Value.GetRawText()); + } + + private static string StaticScriptHelper(string input) => input.ToUpperInvariant(); + + private string InstanceScriptHelper(string input) => input + "-suffix"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs index 9a1f6a4951..5aad5ef2f3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -417,4 +420,82 @@ public sealed class AgentInlineSkillTests Assert.Contains("description=\"A described resource.\"", content); Assert.DoesNotContain("no-desc\" description", content); } + + [Fact] + public async Task AddScript_SkillLevelSerializerOptions_AppliedToScriptAsync() + { + // Arrange — skill-level JSO with source-generated context for custom types + var jso = SkillTestJsonContext.Default.Options; + var skill = new AgentInlineSkill("jso-skill", "JSO test.", "Instructions.", serializerOptions: jso); + skill.AddScript("lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }); + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + + // Act + var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None); + + // Assert — the custom input was deserialized via skill-level JSO and response was produced + Assert.NotNull(result); + Assert.Contains("result for test", result!.ToString()!); + } + + [Fact] + public async Task AddScript_PerScriptSerializerOptions_OverridesSkillLevelAsync() + { + // Arrange — skill-level JSO uses snake_case naming; per-script JSO overrides with source-generated context + var skillJso = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + var scriptJso = SkillTestJsonContext.Default.Options; + var skill = new AgentInlineSkill("override-skill", "Override test.", "Instructions.", serializerOptions: skillJso); + skill.AddScript("lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"found {request.Query}"], + TotalCount = request.MaxResults, + }, serializerOptions: scriptJso); + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "override", MaxResults = 7 }, scriptJso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + + // Act + var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None); + + // Assert — per-script JSO takes effect and custom types are properly marshaled + Assert.NotNull(result); + Assert.Contains("found override", result!.ToString()!); + } + + [Fact] + public async Task AddResource_SkillLevelSerializerOptions_AppliedToDelegateResourceAsync() + { + // Arrange — skill-level JSO with source-generated context; delegate resource returns a custom type + var jso = SkillTestJsonContext.Default.Options; + var skill = new AgentInlineSkill("custom-type-resource-skill", "Custom type resource test.", "Instructions.", serializerOptions: jso); + skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }); + + // Act + var result = await skill.Resources![0].ReadAsync(); + + // Assert — the custom type was returned successfully via skill-level JSO + Assert.NotNull(result); + Assert.Contains("dark", result!.ToString()!); + } + + [Fact] + public async Task AddResource_PerResourceSerializerOptions_OverridesSkillLevelAsync() + { + // Arrange — skill-level JSO uses snake_case naming; per-resource JSO overrides with source-generated context + var skillJso = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + var resourceJso = SkillTestJsonContext.Default.Options; + var skill = new AgentInlineSkill("override-resource-skill", "Override resource test.", "Instructions.", serializerOptions: skillJso); + skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: resourceJso); + + // Act + var result = await skill.Resources![0].ReadAsync(); + + // Assert — per-resource JSO takes effect and custom type is properly marshaled + Assert.NotNull(result); + Assert.Contains("dark", result!.ToString()!); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs new file mode 100644 index 0000000000..e4dee59c88 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillResourceAttributeTests +{ + [Fact] + public void DefaultConstructor_NameIsNull() + { + // Arrange & Act + var attr = new AgentSkillResourceAttribute(); + + // Assert + Assert.Null(attr.Name); + } + + [Fact] + public void NamedConstructor_SetsName() + { + // Arrange & Act + var attr = new AgentSkillResourceAttribute("my-resource"); + + // Assert + Assert.Equal("my-resource", attr.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs new file mode 100644 index 0000000000..937c05e8a1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillScriptAttributeTests +{ + [Fact] + public void DefaultConstructor_NameIsNull() + { + // Arrange & Act + var attr = new AgentSkillScriptAttribute(); + + // Assert + Assert.Null(attr.Name); + } + + [Fact] + public void NamedConstructor_SetsName() + { + // Arrange & Act + var attr = new AgentSkillScriptAttribute("my-script"); + + // Assert + Assert.Equal("my-script", attr.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index e86eb0894a..23c2745247 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -871,7 +871,7 @@ public sealed class AgentSkillsProviderTests : IDisposable public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync() { // Arrange - var skills = new List + var skills = new List { new TestClassSkill("enum-class-a", "Class A", "Instructions A."), new TestClassSkill("enum-class-b", "Class B", "Instructions B."), @@ -928,7 +928,7 @@ public sealed class AgentSkillsProviderTests : IDisposable } } - private sealed class TestClassSkill : AgentClassSkill + private sealed class TestClassSkill : AgentClassSkill { private readonly string _instructions; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index e9dc2e0358..c43568acd9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -199,12 +199,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { - // Arrange — create resource files in the skill directory + // Arrange — create resource files in spec-defined subdirectories string skillDir = Path.Combine(this._testRoot, "resource-skill"); - string refsDir = Path.Combine(skillDir, "refs"); + string refsDir = Path.Combine(skillDir, "references"); + string assetsDir = Path.Combine(skillDir, "assets"); Directory.CreateDirectory(refsDir); + Directory.CreateDirectory(assetsDir); File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content"); - File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}"); + File.WriteAllText(Path.Combine(assetsDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details."); @@ -217,18 +219,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Assert.Single(skills); var skill = skills[0]; Assert.Equal(2, skill.Resources!.Count); - Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { - // Arrange — create a file with an extension not in the default list + // Arrange — create a file with an extension not in the default list inside a spec directory string skillDir = Path.Combine(this._testRoot, "ext-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText(Path.Combine(skillDir, "image.png"), "fake image"); - File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "image.png"), "fake image"); + File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: ext-skill\ndescription: Extension test\n---\nBody."); @@ -241,7 +244,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); - Assert.Equal("data.json", skill.Resources![0].Name); + Assert.Equal("references/data.json", skill.Resources![0].Name); } [Fact] @@ -249,8 +252,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable { // Arrange — the SKILL.md file itself should not be in the resource list string skillDir = Path.Combine(this._testRoot, "selfref-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: selfref-skill\ndescription: Self ref test\n---\nBody."); @@ -263,15 +267,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); - Assert.Equal("notes.md", skill.Resources![0].Name); + Assert.Equal("references/notes.md", skill.Resources![0].Name); } [Fact] public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() { - // Arrange — resource files in nested subdirectories + // Arrange — resource files directly in references/ are discovered; subdirectories are not scanned string skillDir = Path.Combine(this._testRoot, "nested-res-skill"); - string deepDir = Path.Combine(skillDir, "level1", "level2"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "top.md"), "top content"); + string deepDir = Path.Combine(refsDir, "level1", "level2"); Directory.CreateDirectory(deepDir); File.WriteAllText(Path.Combine(deepDir, "deep.md"), "deep content"); File.WriteAllText( @@ -282,21 +289,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable // Act var skills = await source.GetSkillsAsync(); - // Assert + // Assert — only the file directly in references/ is discovered; the nested file is not Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); - Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a source with custom extensions + // Arrange — use a source with custom extensions; files placed in spec directory string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data"); - File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "data.custom"), "custom data"); + File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody."); @@ -309,7 +318,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); - Assert.Equal("data.custom", skill.Resources![0].Name); + Assert.Equal("references/data.custom", skill.Resources![0].Name); } [Theory] @@ -327,7 +336,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable { // Arrange & Act string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body."); - File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "notes.md"), "notes"); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Assert — default extensions include .md @@ -351,9 +362,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync() + public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync() { - // Arrange — resource file directly in the skill directory (not in a subdirectory) + // Arrange — resource files directly in the skill directory (not in a spec subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content"); @@ -366,7 +377,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable // Act var skills = await source.GetSkillsAsync(); - // Assert — both root-level resource files should be discovered + // Assert — root-level files are NOT discovered unless "." is in ResourceDirectories + Assert.Single(skills); + Assert.Empty(skills[0].Resources!); + } + + [Fact] + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() + { + // Arrange — "." in ResourceDirectories opts into root-level resource discovery + string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content"); + File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — both root-level resource files (and SKILL.md excluded) should be discovered Assert.Single(skills); var skill = skills[0]; Assert.Equal(2, skill.Resources!.Count); @@ -374,6 +407,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync() + { + // Arrange — resource in a non-spec directory (neither references/ nor assets/) + string skillDir = Path.Combine(this._testRoot, "non-spec-skill"); + string customDir = Path.Combine(skillDir, "docs"); + Directory.CreateDirectory(customDir); + File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: non-spec-skill\ndescription: Non-spec directory\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — non-spec directories are not scanned by default + Assert.Single(skills); + Assert.Empty(skills[0].Resources!); + } + + [Fact] + public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync() + { + // Arrange — custom ResourceDirectories replaces the spec defaults + string skillDir = Path.Combine(this._testRoot, "custom-directory-skill"); + string customDir = Path.Combine(skillDir, "docs"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(customDir); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content"); + File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — only docs/ is scanned; references/ is NOT scanned + Assert.Single(skills); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("docs/readme.md", skill.Resources![0].Name); + } + [Fact] public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync() { @@ -437,14 +518,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() { - // Arrange — create a skill with a resource file discovered from the directory + // Arrange — create a skill with a resource file discovered from the references directory string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details."); - string refsDir = Path.Combine(skillDir, "refs"); + string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); var skills = await source.GetSkillsAsync(); - var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); + var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md"); // Act var content = await resource.ReadAsync(); @@ -495,16 +576,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() { - // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory + // Arrange — references/ is a symlink pointing outside the skill directory; + // a legitimate file lives in assets/ and should still be discovered. string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText(Path.Combine(skillDir, "legit.md"), "legit content"); + string assetsDir = Path.Combine(skillDir, "assets"); + Directory.CreateDirectory(assetsDir); + File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content"); string outsideDir = Path.Combine(this._testRoot, "outside"); Directory.CreateDirectory(outsideDir); File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content"); - string refsLink = Path.Combine(skillDir, "refs"); + string refsLink = Path.Combine(skillDir, "references"); try { Directory.CreateSymbolicLink(refsLink, outsideDir); @@ -523,11 +606,129 @@ public sealed class FileAgentSkillLoaderTests : IDisposable // Act var skills = await source.GetSkillsAsync(); - // Assert — skill should still load, but symlinked resources should be excluded + // Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill"); Assert.NotNull(skill); Assert.Single(skill.Resources!); - Assert.Equal("legit.md", skill.Resources![0].Name); + Assert.Equal("assets/legit.md", skill.Resources![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_SymlinkedResourceDirectory_SkipsWithoutEnumeratingAsync() + { + // Arrange — references/ is a symlink pointing outside the skill directory. + // The directory-level check should skip it entirely (no file enumeration), + // so even files with valid extensions in the target are not discovered. + string skillDir = Path.Combine(this._testRoot, "symlink-directory-skip"); + string assetsDir = Path.Combine(skillDir, "assets"); + Directory.CreateDirectory(assetsDir); + File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content"); + + string outsideDir = Path.Combine(this._testRoot, "outside-resources"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "external.md"), "external content"); + File.WriteAllText(Path.Combine(outsideDir, "data.json"), "{}"); + + string refsLink = Path.Combine(skillDir, "references"); + try + { + Directory.CreateSymbolicLink(refsLink, outsideDir); + } + catch (IOException) + { + // Symlink creation requires elevation on some platforms; skip gracefully. + return; + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: symlink-directory-skip\ndescription: Symlinked directory skip\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("assets/legit.md", skill.Resources![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync() + { + // Arrange — scripts/ is a symlink pointing outside the skill directory. + // The directory-level check should skip it entirely. + string skillDir = Path.Combine(this._testRoot, "symlink-script-skip"); + Directory.CreateDirectory(skillDir); + + string outsideDir = Path.Combine(this._testRoot, "outside-scripts"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "malicious.py"), "import os; os.system('rm -rf /')"); + + string scriptsLink = Path.Combine(skillDir, "scripts"); + try + { + Directory.CreateSymbolicLink(scriptsLink, outsideDir); + } + catch (IOException) + { + return; + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: symlink-script-skip\ndescription: Symlinked script directory\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — skill loads but scripts from the symlinked directory are not discovered + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip"); + Assert.NotNull(skill); + Assert.Empty(skill.Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync() + { + // Arrange — custom resource directory "sub/resources" where "sub" is a symlink. + // The directory-level HasSymlinkInPath check should detect the intermediate symlink. + string skillDir = Path.Combine(this._testRoot, "symlink-intermediate"); + Directory.CreateDirectory(skillDir); + + string outsideDir = Path.Combine(this._testRoot, "outside-intermediate"); + string outsideResources = Path.Combine(outsideDir, "resources"); + Directory.CreateDirectory(outsideResources); + File.WriteAllText(Path.Combine(outsideResources, "data.md"), "data"); + + string subLink = Path.Combine(skillDir, "sub"); + try + { + Directory.CreateSymbolicLink(subLink, outsideDir); + } + catch (IOException) + { + return; + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: symlink-intermediate\ndescription: Intermediate symlink\n---\nBody."); + var source = new AgentFileSkillsSource( + this._testRoot, + s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — the symlinked intermediate segment causes the directory to be skipped + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate"); + Assert.NotNull(skill); + Assert.Empty(skill.Resources!); } #endif @@ -693,6 +894,170 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Assert.Null(fm.Metadata); } + [Theory] + [InlineData("..")] + [InlineData("../escape")] + [InlineData("sub/../escape")] + [InlineData("/absolute")] + [InlineData("\\absolute")] + public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory) + { + // Arrange & Act — invalid directories are skipped with a warning rather than throwing + var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] }); + var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] }); + + // Assert + Assert.NotNull(source1); + Assert.NotNull(source2); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory) + { + // Arrange & Act & Assert — null/whitespace is a contract violation, not a config error + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] })); + } + + [Theory] + [InlineData("scripts")] + [InlineData("my-scripts")] + [InlineData("sub/directory")] + [InlineData(".")] + [InlineData("./scripts")] + [InlineData("./scripts/f1")] + [InlineData("my..scripts")] + public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory) + { + // Arrange & Act & Assert + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] }); + Assert.NotNull(source); + } + + [Fact] + public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync() + { + // Arrange — "references" and "./references" refer to the same directory; + // after normalization they should be deduplicated so resources appear only once. + string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — only one copy of the resource despite two equivalent directory entries + Assert.Single(skills); + Assert.Single(skills[0].Resources!); + Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync() + { + // Arrange — "references/" should be normalized to "references" + string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — trailing slash variant deduplicated + Assert.Single(skills); + Assert.Single(skills[0].Resources!); + Assert.Equal("references/data.json", skills[0].Resources![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync() + { + // Arrange — ".\\scripts" should be normalized to "scripts" + string skillDir = Path.Combine(this._testRoot, "backslash-skill"); + string scriptsDir = Path.Combine(skillDir, "scripts"); + Directory.CreateDirectory(scriptsDir); + File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: backslash-skill\ndescription: Backslash test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — backslash variant deduplicated + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name); + } + + [Theory] + [InlineData("./references")] + [InlineData("./assets/docs")] + public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory) + { + // Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs"; + // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration. + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" + string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill"); + string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(targetDir); + File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — the resource is discovered with a name identical to using the directory without "./" + Assert.Single(skills); + Assert.Single(skills[0].Resources!); + Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync() + { + // Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3) + string skillDir = Path.Combine(this._testRoot, "nested-directory-skill"); + string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3"); + Directory.CreateDirectory(nestedDir); + File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — resource file inside the deeply nested directory is discovered + Assert.Single(skills); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name); + } + private string CreateSkillDirectory(string name, string description, string body) { string skillDir = Path.Combine(this._testRoot, name); @@ -710,4 +1075,99 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent); return skillDir; } + + [Theory] + [InlineData("txt")] + [InlineData("")] + [InlineData(" ")] + public void Constructor_InvalidScriptExtension_ThrowsArgumentException(string badExtension) + { + // Arrange & Act & Assert + Assert.Throws(() => new AgentFileSkillsSource( + this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { AllowedScriptExtensions = new string[] { badExtension } })); + } + + [Fact] + public async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync() + { + // Arrange — create a skill at depth 3 (exceeds MaxSearchDepth = 2) + string deepDir = Path.Combine(this._testRoot, "l1", "l2", "l3", "deep-skill"); + Directory.CreateDirectory(deepDir); + File.WriteAllText( + Path.Combine(deepDir, "SKILL.md"), + "---\nname: deep-skill\ndescription: Too deep\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — skill at depth 3 should not be discovered + Assert.DoesNotContain(skills, s => s.Frontmatter.Name == "deep-skill"); + } + + [Fact] + public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() + { + // Arrange — script file directly in the skill directory with ScriptDirectories = ["."] + string skillDir = Path.Combine(this._testRoot, "root-script-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: root-script-skill\ndescription: Root script\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, + new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] }); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — script at the skill root should be discovered + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Scripts!); + Assert.Equal("run.py", skill.Scripts![0].Name); + } + +#if NET + [Fact] + public async Task GetSkillsAsync_SymlinkedFileInRealDirectory_SkipsSymlinkedFileAsync() + { + // Arrange — references/ is a real directory, but one file inside it is a symlink + // pointing outside the skill directory. The per-file symlink check should skip it. + string skillDir = Path.Combine(this._testRoot, "symlink-file-skill"); + string refsDir = Path.Combine(skillDir, "references"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "legit.md"), "legit content"); + + string outsideDir = Path.Combine(this._testRoot, "outside-file"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content"); + + string symlinkFile = Path.Combine(refsDir, "leak.md"); + try + { + File.CreateSymbolicLink(symlinkFile, Path.Combine(outsideDir, "secret.md")); + } + catch (IOException) + { + // Symlink creation requires elevation on some platforms; skip gracefully. + return; + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: symlink-file-skill\ndescription: Symlinked file\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert — only legit.md should be discovered; the symlinked leak.md is skipped + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("references/legit.md", skill.Resources![0].Name); + } +#endif } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs index 8c97a31ae4..a2fb4155e4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -70,3 +71,52 @@ internal sealed class TestAgentSkillsSource : AgentSkillsSource return Task.FromResult(this._skills); } } + +/// +/// Custom input type accepted by skill script delegates in JSO tests. +/// +internal sealed class LookupRequest +{ + /// Gets or sets the search query. + public string Query { get; set; } = string.Empty; + + /// Gets or sets the maximum number of results. + public int MaxResults { get; set; } +} + +/// +/// Custom output type returned by skill script delegates in JSO tests. +/// +internal sealed class LookupResponse +{ + /// Gets or sets the items found. + public IList Items { get; set; } = []; + + /// Gets or sets the total number of matches. + public int TotalCount { get; set; } +} + +/// +/// Custom output type returned by skill resource delegates in JSO tests. +/// +internal sealed class SkillConfig +{ + /// Gets or sets the theme name. + public string Theme { get; set; } = string.Empty; + + /// Gets or sets whether verbose mode is enabled. + public bool Verbose { get; set; } +} + +/// +/// Source-generated JSON serializer context for skill test types. +/// Provides serialization support for , , +/// and without requiring runtime reflection. +/// +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(LookupRequest))] +[JsonSerializable(typeof(LookupResponse))] +[JsonSerializable(typeof(SkillConfig))] +internal sealed partial class SkillTestJsonContext : JsonSerializerContext +{ +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 317f7d86ed..e4d4fa2378 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -362,5 +362,86 @@ public sealed class CompactionProviderTests Assert.Single(state.MessageGroups); } + [Fact] + public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync() + { + // Arrange — no-compaction strategy so we can observe marking behavior only + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + CompactionProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + + // --- First invocation: [Q1, A1, Q2] --- + ChatMessage q1 = new(ChatRole.User, "Q1"); + ChatMessage a1 = new(ChatRole.Assistant, "A1"); + ChatMessage q2 = new(ChatRole.User, "Q2"); + + AIContextProvider.InvokingContext context1 = new( + mockAgent.Object, + session, + new AIContext { Messages = new List { q1, a1, q2 } }); + + AIContext result1 = await provider.InvokingAsync(context1); + + // Assert — on first invocation, no messages should be marked as ChatHistory + List resultList1 = [.. result1.Messages!]; + Assert.Equal(3, resultList1.Count); + foreach (ChatMessage message in resultList1) + { + Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, message.GetAgentRequestMessageSourceType()); + } + + // --- Second invocation: [Q1, A1, Q2, A2, Q3] --- + ChatMessage a2 = new(ChatRole.Assistant, "A2"); + ChatMessage q3 = new(ChatRole.User, "Q3"); + + AIContextProvider.InvokingContext context2 = new( + mockAgent.Object, + session, + new AIContext { Messages = new List { q1, a1, q2, a2, q3 } }); + + AIContext result2 = await provider.InvokingAsync(context2); + + // Assert — messages from the first invocation should be marked as ChatHistory, + // while new messages should not. + List resultList2 = [.. result2.Messages!]; + Assert.Equal(5, resultList2.Count); + + // Q1, A1, Q2 were already in the provider state — they should be ChatHistory + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[0].GetAgentRequestMessageSourceType()); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[1].GetAgentRequestMessageSourceType()); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[2].GetAgentRequestMessageSourceType()); + + // A2, Q3 are new to the provider — they should NOT be ChatHistory + Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[3].GetAgentRequestMessageSourceType()); + Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[4].GetAgentRequestMessageSourceType()); + + // --- Third invocation: [Q1, A1, Q2, A2, Q3, A3, Q4] --- + ChatMessage a3 = new(ChatRole.Assistant, "A3"); + ChatMessage q4 = new(ChatRole.User, "Q4"); + + AIContextProvider.InvokingContext context3 = new( + mockAgent.Object, + session, + new AIContext { Messages = new List { q1, a1, q2, a2, q3, a3, q4 } }); + + AIContext result3 = await provider.InvokingAsync(context3); + + // Assert — all previously seen messages should be ChatHistory, only brand-new ones should not + List resultList3 = [.. result3.Messages!]; + Assert.Equal(7, resultList3.Count); + + // Q1, A1, Q2, A2, Q3 were already in the provider state — they should be ChatHistory + for (int i = 0; i < 5; i++) + { + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList3[i].GetAgentRequestMessageSourceType()); + } + + // A3, Q4 are new — they should NOT be ChatHistory + Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[5].GetAgentRequestMessageSourceType()); + Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType()); + } + private sealed class TestAgentSession : AgentSession; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 7f06145a8e..c857811b08 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -9,6 +9,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; @@ -147,7 +148,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +226,7 @@ public class AgentWorkflowBuilderTests barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +259,7 @@ public class AgentWorkflowBuilderTests }), description: "nop")) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +297,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +407,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +605,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -634,6 +635,232 @@ public class AgentWorkflowBuilderTests Assert.Contains("thirdAgent", result[5].AuthorName); } + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionCallId = "call2first"; + + AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction)); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + ToolApprovalRequestContent approvalRequest = + request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId); + + ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied")); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(10, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation (and user denial) + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.User, result[4].Role); + Assert.Equal("", result[4].Text); + + // Rejected tool call + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("", result[7].Text); + Assert.Contains("secondAgent", result[7].AuthorName); + + Assert.Equal(ChatRole.Tool, result[8].Role); + Assert.Contains("secondAgent", result[8].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[9].Role); + Assert.Equal("Hello from agent3", result[9].Text); + Assert.Contains("thirdAgent", result[9].AuthorName); + + static bool SomeOtherFunction() => true; + } + + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionName = "SomeOtherFunction"; + const string SomeOtherFunctionCallId = "call2first"; + + JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema; + AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + FunctionCallContent functionCall = request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + functionCall.CallId.Should().Be(SomeOtherFunctionCallId); + functionCall.Name.Should().Be(SomeOtherFunctionName); + + ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true)); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(8, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.Tool, result[4].Role); + Assert.Contains("secondAgent", result[4].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("Hello from agent3", result[7].Text); + Assert.Contains("thirdAgent", result[7].AuthorName); + } + [Theory] [InlineData(1)] [InlineData(2)] @@ -651,7 +878,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -832,7 +1059,7 @@ public class AgentWorkflowBuilderTests Assert.Equal(1, specialistCallCount); // specialist NOT called } - private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); private static Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) @@ -843,6 +1070,15 @@ public class AgentWorkflowBuilderTests return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); } + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint); + } + private static async Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) { @@ -853,15 +1089,39 @@ public class AgentWorkflowBuilderTests await run.TrySendMessageAsync(input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + return await ProcessWorkflowRunAsync(run); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.SendResponseAsync(response); + + return await ProcessWorkflowRunAsync(run); + } + + private static async Task ProcessWorkflowRunAsync(StreamingRun run) + { StringBuilder sb = new(); WorkflowOutputEvent? output = null; CheckpointInfo? lastCheckpoint = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + + List pendingRequests = []; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) { switch (evt) { - case AgentResponseUpdateEvent executorComplete: - sb.Append(executorComplete.Data); + case AgentResponseUpdateEvent responseUpdate: + sb.Append(responseUpdate.Data); + break; + + case RequestInfoEvent requestInfo: + pendingRequests.Add(requestInfo); break; case WorkflowOutputEvent e: @@ -878,7 +1138,7 @@ public class AgentWorkflowBuilderTests } } - return new(sb.ToString(), output?.As>(), lastCheckpoint); + return new(sb.ToString(), output?.As>(), lastCheckpoint, pendingRequests); } private static Task RunWorkflowAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs index 10fbfddd64..f0058e7390 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs @@ -178,4 +178,23 @@ public sealed class FileSystemJsonCheckpointStoreTests Func createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData); await createCheckpointAction.Should().NotThrowAsync(); } + + [Fact] + public async Task RetrieveCheckpointAsync_ShouldReturnPersistedDataAsync() + { + // Arrange + using TempDirectory tempDirectory = new(); + using FileSystemJsonCheckpointStore store = new(tempDirectory); + + string sessionId = Guid.NewGuid().ToString("N"); + JsonElement originalData = JsonSerializer.SerializeToElement(new { name = "test", value = 42 }); + + // Act + CheckpointInfo checkpoint = await store.CreateCheckpointAsync(sessionId, originalData); + JsonElement retrieved = await store.RetrieveCheckpointAsync(sessionId, checkpoint); + + // Assert + retrieved.GetProperty("name").GetString().Should().Be("test"); + retrieved.GetProperty("value").GetInt32().Should().Be(42); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 8bdbe23c5f..1a5b2ea4d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -29,7 +29,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase emitAgentResponseUpdateEvents: executorSetting, HandoffToolCallFilteringBehavior.None); - HandoffAgentExecutor executor = new(agent, options); + HandoffAgentExecutor executor = new(agent, [], options); testContext.ConfigureExecutor(executor); // Act @@ -57,7 +57,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase emitAgentResponseUpdateEvents: false, HandoffToolCallFilteringBehavior.None); - HandoffAgentExecutor executor = new(agent, options); + HandoffAgentExecutor executor = new(agent, [], options); testContext.ConfigureExecutor(executor); // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutorEventsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutorEventsTests.cs new file mode 100644 index 0000000000..7e37131e87 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutorEventsTests.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.InProc; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class InProcessExecutorEventsTests +{ + [SendsMessage(typeof(string[]))] + private sealed class EventTrackingExecutor(bool forwardMessages, string id) : Executor>(id) + { + public List> ReceivedMessages { get; } = []; + + private int _checkpointingCalls; + public int CheckpointingCalls => this._checkpointingCalls; + + private int _checkpointRestoredCalls; + public int CheckpointRestoredCalls => this._checkpointRestoredCalls; + + private int _deliveryStartingCalls; + public int DeliveryStartingCalls => this._deliveryStartingCalls; + + private int _deliveryFinishedAsyncCalls; + public int DeliveryFinishedCalls => this._deliveryFinishedAsyncCalls; + + protected internal override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._checkpointingCalls); + return base.OnCheckpointingAsync(context, cancellationToken); + } + + protected internal override ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._checkpointRestoredCalls); + return base.OnCheckpointRestoredAsync(context, cancellationToken); + } + + protected internal override ValueTask OnMessageDeliveryStartingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._deliveryStartingCalls); + return base.OnMessageDeliveryStartingAsync(context, cancellationToken); + } + + protected internal override ValueTask OnMessageDeliveryFinishedAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._deliveryFinishedAsyncCalls); + return base.OnMessageDeliveryFinishedAsync(context, cancellationToken); + } + + public override async ValueTask HandleAsync(IEnumerable message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + this.ReceivedMessages.Add(message); + + if (forwardMessages) + { + foreach (string packedMessage in message) + { + await context.SendMessageAsync(new[] { packedMessage }, cancellationToken); + } + } + } + } + + private sealed class TestFixture + { + public EventTrackingExecutor StartingExecutor { get; } = new(true, nameof(StartingExecutor)); + public EventTrackingExecutor ReceivesMessage { get; } = new(false, nameof(ReceivesMessage)); + public EventTrackingExecutor UninvokedExecutor { get; } = new(false, nameof(UninvokedExecutor)); + + public Workflow Workflow { get; } + + public TestFixture() + { + this.Workflow = new WorkflowBuilder(this.StartingExecutor) + .AddEdge(this.StartingExecutor, this.ReceivesMessage) + // The uninvoked executor remains uninvoked because ReceivesMessage does not forward its incoming message + .AddEdge(this.ReceivesMessage, this.UninvokedExecutor) + .Build(); + } + + public const int StepsPerInputBatch = 2; + } + + [Theory] + [InlineData(1, ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(1, ExecutionEnvironment.InProcess_OffThread)] + internal async Task Test_InProcessExecution_InvokesDeliveryEventsOnceAsync(int messageCount, ExecutionEnvironment environment) + { + // Arrange + TestFixture fixture = new(); + InProcessExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment(); + + // Act + IEnumerable batch = Enumerable.Range(1, messageCount).Select(i => $"Message_{i}"); + await using StreamingRun streamingRun = await executionEnvironment.OpenStreamingAsync(fixture.Workflow); + + await streamingRun.TrySendMessageAsync(batch); + await streamingRun.RunToCompletionAsync(ThrowOnError); + + // Assert + fixture.StartingExecutor.DeliveryStartingCalls.Should().Be(1); + fixture.StartingExecutor.DeliveryFinishedCalls.Should().Be(1); + + fixture.ReceivesMessage.DeliveryStartingCalls.Should().Be(1); + fixture.ReceivesMessage.DeliveryFinishedCalls.Should().Be(1); + + fixture.UninvokedExecutor.DeliveryStartingCalls.Should().Be(0); + fixture.UninvokedExecutor.DeliveryFinishedCalls.Should().Be(0); + + ExternalResponse? ThrowOnError(WorkflowEvent workflowEvent) + { + switch (workflowEvent) + { + case WorkflowErrorEvent workflowError: + Assert.Fail(workflowError.Exception?.ToString() ?? "Unknown error occurred while executing workflow."); + break; + + case ExecutorFailedEvent executorFailed: + Assert.Fail(executorFailed.Data != null + ? $"Executor {executorFailed.ExecutorId} failed with exception: {executorFailed.Data}" + : $"Executor {executorFailed.ExecutorId} failed with unknown error"); + break; + } + + return null; + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_InProcessExecution_InvokesCheckpointingEventIFFCheckpointingEnabledAsync(bool useCheckpointing) + { + // Arrange + TestFixture fixture = new(); + + InProcessExecutionEnvironment executionEnvironment = InProcessExecution.Default; + + if (useCheckpointing) + { + executionEnvironment = executionEnvironment.WithCheckpointing(CheckpointManager.CreateInMemory()); + } + + // Act + string sessionId = Guid.NewGuid().ToString(); + await using Run run = await executionEnvironment.RunAsync(fixture.Workflow, ["Message"], sessionId); + + // Assert + run.OutgoingEvents.OfType().Should().BeEmpty(); + run.OutgoingEvents.OfType().Should().BeEmpty(); + + const int ExpectedSteps = TestFixture.StepsPerInputBatch; + run.OutgoingEvents.OfType().Should().HaveCount(ExpectedSteps); + + int expectedCheckpoints = useCheckpointing ? ExpectedSteps : 0; + run.Checkpoints.Should().HaveCount(expectedCheckpoints); + + fixture.StartingExecutor.CheckpointingCalls.Should().Be(expectedCheckpoints); + fixture.StartingExecutor.CheckpointRestoredCalls.Should().Be(0); + + fixture.ReceivesMessage.CheckpointingCalls.Should().Be(expectedCheckpoints); + fixture.ReceivesMessage.CheckpointRestoredCalls.Should().Be(0); + + fixture.UninvokedExecutor.CheckpointingCalls.Should().Be(0); // Uninvoked executors don't get "instantiated" in the workflow context + fixture.UninvokedExecutor.CheckpointRestoredCalls.Should().Be(0); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + //[InlineData(false, true)] - impossible to restore checkpoint with checkpointing disabled, will throw + public async Task Test_InProcessExecution_InvokesRestoredEventIFFRestoringCheckpointAsync(bool restoreCheckpoint) + { + // Arrange + TestFixture runFixture = new(); + InProcessExecutionEnvironment executionEnvironment = InProcessExecution.Default.WithCheckpointing(CheckpointManager.CreateInMemory()); + + // Act + string sessionId = Guid.NewGuid().ToString(); + Run run = await executionEnvironment.RunAsync(runFixture.Workflow, ["Message"], sessionId); + + // Assert + run.OutgoingEvents.OfType().Should().BeEmpty(); + run.OutgoingEvents.OfType().Should().BeEmpty(); + + TestFixture validateFixture = runFixture; + + // Act 2 + int expectedCheckpoints = TestFixture.StepsPerInputBatch; + + if (restoreCheckpoint) + { + expectedCheckpoints--; // We are restoring from the first one, so skip one + + validateFixture = new(); + run.Checkpoints.Should().HaveCount(TestFixture.StepsPerInputBatch); + + CheckpointInfo firstCheckpoint = run.Checkpoints[0]; + + await run.DisposeAsync(); + run = await executionEnvironment.ResumeAsync(validateFixture.Workflow, firstCheckpoint); + } + + // Assert 2 + if (restoreCheckpoint) + { + // Make sure the second run did not have failures + run.OutgoingEvents.OfType().Should().BeEmpty(); + run.OutgoingEvents.OfType().Should().BeEmpty(); + } + + int expectedRestoreCalls = restoreCheckpoint ? 1 : 0; + + validateFixture.StartingExecutor.CheckpointingCalls.Should().Be(expectedCheckpoints); + validateFixture.StartingExecutor.CheckpointRestoredCalls.Should().Be(expectedRestoreCalls); + + validateFixture.ReceivesMessage.CheckpointingCalls.Should().Be(expectedCheckpoints); + validateFixture.ReceivesMessage.CheckpointRestoredCalls.Should().Be(expectedRestoreCalls); + + validateFixture.UninvokedExecutor.CheckpointingCalls.Should().Be(0); // Uninvoked executors don't get "instantiated" in the workflow context + validateFixture.UninvokedExecutor.CheckpointRestoredCalls.Should().Be(0); + + // Cleanup + await run.DisposeAsync(); + } +} diff --git a/python/.env.example b/python/.env.example index e8644ea003..bff78961aa 100644 --- a/python/.env.example +++ b/python/.env.example @@ -38,6 +38,9 @@ COPILOTSTUDIOAGENT__AGENTAPPID="" # Anthropic ANTHROPIC_API_KEY="" ANTHROPIC_MODEL="" +# Google Gemini +GEMINI_API_KEY="" +GEMINI_MODEL="" # Ollama OLLAMA_ENDPOINT="" OLLAMA_MODEL="" diff --git a/python/.github/skills/python-feature-lifecycle/SKILL.md b/python/.github/skills/python-feature-lifecycle/SKILL.md index d9b654a9da..80e8af17c2 100644 --- a/python/.github/skills/python-feature-lifecycle/SKILL.md +++ b/python/.github/skills/python-feature-lifecycle/SKILL.md @@ -1,5 +1,3 @@ -# Copyright (c) Microsoft. All rights reserved. - --- name: python-feature-lifecycle description: > diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index bbb2683c5c..adf7e6e5b3 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -1,7 +1,8 @@ fail_fast: true exclude: ^scripts/ repos: - - repo: builtin + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 hooks: - id: check-toml name: Check TOML files @@ -34,9 +35,6 @@ repos: - id: no-commit-to-branch name: Protect main branch args: [--branch, main] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - id: check-ast name: Check Valid Python Samples types: ["python"] diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index bd9ff53f18..0ae0df1454 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200)) + +## [1.0.1] - 2026-04-09 + +### Added +- **samples**: Add sample documentation for two separate Neo4j context providers for retrieval and memory ([#4010](https://github.com/microsoft/agent-framework/pull/4010)) +- **agent-framework-azure-cosmos**: Add Cosmos DB NoSQL checkpoint storage for Python workflows ([#4916](https://github.com/microsoft/agent-framework/pull/4916)) + +### Changed +- **docs**: Remove pre-release flag from agent-framework installation instructions ([#5082](https://github.com/microsoft/agent-framework/pull/5082)) +- **samples**: Revise agent examples in `README.md` ([#5067](https://github.com/microsoft/agent-framework/pull/5067)) +- **repo**: Update `CHANGELOG` with v1.0.0 release ([#5069](https://github.com/microsoft/agent-framework/pull/5069)) +- **agent-framework-orchestrations**: [BREAKING] Fix handoff workflow context management and improve AG-UI demo ([#5136](https://github.com/microsoft/agent-framework/pull/5136)) +- **agent-framework-core**: Restrict persisted checkpoint deserialization by default ([#4941](https://github.com/microsoft/agent-framework/pull/4941)) +- **samples**: Bump `vite` from 7.3.1 to 7.3.2 in `/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend` ([#5132](https://github.com/microsoft/agent-framework/pull/5132)) +- **python**: Bump `cryptography` from 46.0.6 to 46.0.7 ([#5176](https://github.com/microsoft/agent-framework/pull/5176)) +- **python**: Bump `mcp` from 1.26.0 to 1.27.0 ([#5117](https://github.com/microsoft/agent-framework/pull/5117)) +- **python**: Bump `mcp[ws]` from 1.26.0 to 1.27.0 ([#5119](https://github.com/microsoft/agent-framework/pull/5119)) + +### Fixed +- **agent-framework-core**: Raise clear handler registration error for unresolved `TypeVar` annotations ([#4944](https://github.com/microsoft/agent-framework/pull/4944)) +- **agent-framework-openai**: Fix `response_format` crash on background polling with empty text ([#5146](https://github.com/microsoft/agent-framework/pull/5146)) +- **agent-framework-foundry**: Strip tools from `FoundryAgent` request when `agent_reference` is present ([#5101](https://github.com/microsoft/agent-framework/pull/5101)) +- **agent-framework-core**: Fix test compatibility for entity key validation ([#5179](https://github.com/microsoft/agent-framework/pull/5179)) +- **agent-framework-openai**: Stop emitting duplicate reasoning content from `response.reasoning_text.done` and `response.reasoning_summary_text.done` events ([#5162](https://github.com/microsoft/agent-framework/pull/5162)) + + +## [1.0.0] - 2026-04-02 + +### Added + +- **repo**: Add `PACKAGE_STATUS.md` to track lifecycle status of all Python packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + +### Changed + +- **agent-framework**, **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Promote from `1.0.0rc6` to `1.0.0` (Production/Stable) ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Dependency floors now require released `>=1.0.0,<2` packages, breaking compatibility with older RC installs ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-a2a**, **agent-framework-ag-ui**, **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**, **agent-framework-azurefunctions**, **agent-framework-bedrock**, **agent-framework-chatkit**, **agent-framework-claude**, **agent-framework-copilotstudio**, **agent-framework-declarative**, **agent-framework-devui**, **agent-framework-durabletask**, **agent-framework-foundry-local**, **agent-framework-github-copilot**, **agent-framework-lab**, **agent-framework-mem0**, **agent-framework-ollama**, **agent-framework-orchestrations**, **agent-framework-purview**, **agent-framework-redis**: Bump beta versions from `1.0.0b260330` to `1.0.0b260402` ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **docs**: Update install instructions to drop `--pre` flag for released packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + +### Removed + +- **agent-framework-core**: [BREAKING] Remove deprecated `BaseContextProvider` and `BaseHistoryProvider` aliases ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-core**: [BREAKING] Remove deprecated `text` parameter from `Message` constructor ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + +### Fixed + +- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**, **agent-framework-azurefunctions**, **agent-framework-devui**, **agent-framework-orchestrations**, **agent-framework-azure-ai-search**: Migrate message construction from `Message(text=...)` to `Message(contents=[...])` throughout codebase ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-devui**: Accept legacy payload formats (`text`, `message`, `content`, `input`, `data`) and convert to framework-native `Message(contents=...)` ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **samples**: Fix Foundry samples to use env vars consistently and update install guidance ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + ## [1.0.0rc6] - 2026-03-30 ### Added @@ -846,7 +898,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** 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.0rc6...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...HEAD +[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1 +[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0 [1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6 [1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5 [1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4 diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 7a726812ff..e6b5f403ce 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -31,6 +31,7 @@ Status is grouped into these buckets: | `agent-framework-durabletask` | `python/packages/durabletask` | `beta` | | `agent-framework-foundry` | `python/packages/foundry` | `released` | | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | +| `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 4171f72c4c..787016a287 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.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index a0919cbda4..442960a7ee 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -1284,13 +1284,11 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_ final=True, ) - mock_a2a_client.responses.extend( - [ - (working_task, first_chunk), - (working_task, second_chunk), - (terminal_task, terminal_event), - ] - ) + mock_a2a_client.responses.extend([ + (working_task, first_chunk), + (working_task, second_chunk), + (terminal_task, terminal_event), + ]) stream = a2a_agent.run("Hello", stream=True) updates: list[AgentResponseUpdate] = [] @@ -1371,12 +1369,10 @@ async def test_streaming_terminal_task_only_emits_unstreamed_artifacts( final=True, ) - mock_a2a_client.responses.extend( - [ - (working_task, streamed_chunk), - (terminal_task, terminal_event), - ] - ) + mock_a2a_client.responses.extend([ + (working_task, streamed_chunk), + (terminal_task, terminal_event), + ]) stream = a2a_agent.run("Hello", stream=True) updates: list[AgentResponseUpdate] = [] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py index 7d5bfc951b..c787de5167 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py @@ -9,6 +9,7 @@ from ._client import AGUIChatClient from ._endpoint import add_agent_framework_fastapi_endpoint from ._event_converters import AGUIEventConverter from ._http_service import AGUIHttpService +from ._state import state_update from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata from ._workflow import AgentFrameworkWorkflow, WorkflowFactory @@ -34,5 +35,6 @@ __all__ = [ "PredictStateConfig", "RunMetadata", "DEFAULT_TAGS", + "state_update", "__version__", ] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index e9ce610b10..639a3f89b3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -46,6 +46,7 @@ from ._orchestration._tooling import collect_server_tools, merge_tools, register from ._run_common import ( FlowState, _build_run_finished_event, # type: ignore + _close_reasoning_block, # type: ignore _emit_content, # type: ignore _extract_resume_payload, # type: ignore _has_only_tool_calls, # type: ignore @@ -1058,6 +1059,10 @@ async def run_agent_stream( } ) + # Close any open reasoning block + for event in _close_reasoning_block(flow): + yield event + # Close any open message if flow.message_id: logger.debug(f"End of run: closing text message message_id={flow.message_id}") diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 155f559a94..58236cdf0e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -6,6 +6,7 @@ from __future__ import annotations import json import logging +from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any, cast @@ -31,6 +32,7 @@ from ag_ui.core import ( from agent_framework import Content from ._orchestration._predictive_state import PredictiveStateHandler +from ._state import TOOL_RESULT_STATE_KEY from ._utils import generate_event_id, make_json_safe logger = logging.getLogger(__name__) @@ -128,6 +130,7 @@ class FlowState: interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] + reasoning_message_id: str | None = None def get_tool_name(self, call_id: str | None) -> str | None: """Get tool name by call ID.""" @@ -232,16 +235,66 @@ def _emit_tool_call( return events +def _extract_tool_result_state(content: Content) -> dict[str, Any] | None: + """Extract a deterministic AG-UI state update from a tool-result ``Content``. + + Tools using :func:`agent_framework_ag_ui.state_update` carry the state + payload in ``additional_properties[TOOL_RESULT_STATE_KEY]`` on the inner + text item produced by ``parse_result``. We also check the outer + function_result content's ``additional_properties`` for robustness. + + If multiple items carry state, they are merged in order so later items + override earlier ones (plain ``dict.update`` semantics). + + Returns: + The merged state dict to apply, or ``None`` if no state update is + present. + """ + merged: dict[str, Any] | None = None + + outer_ap = getattr(content, "additional_properties", None) or {} + outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY) + if isinstance(outer_state, dict): + merged = dict(outer_state) + + for item in content.items or (): + item_ap = getattr(item, "additional_properties", None) or {} + item_state = item_ap.get(TOOL_RESULT_STATE_KEY) + if isinstance(item_state, dict): + if merged is None: + merged = dict(item_state) + else: + merged.update(item_state) + + return merged + + def _emit_tool_result_common( call_id: str, raw_result: Any, flow: FlowState, predictive_handler: PredictiveStateHandler | None = None, + *, + state_update: Mapping[str, Any] | None = None, ) -> list[BaseEvent]: """Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup. Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result`` (MCP server tool results) delegate to this function. + + Args: + call_id: Tool call identifier. + raw_result: The stringified tool result content sent back to the LLM. + flow: Current ``FlowState``. + predictive_handler: Optional predictive state handler driven by + ``predict_state_config``. + state_update: Optional deterministic state snapshot produced by a tool + returning :func:`agent_framework_ag_ui.state_update`. When present, + it is merged into ``flow.current_state`` and a ``StateSnapshotEvent`` + is emitted after the ``ToolCallResult`` event. When both + ``predictive_handler`` and ``state_update`` are active, predictive + updates are applied first, then the deterministic merge, and a + single coalesced ``StateSnapshotEvent`` is emitted. """ events: list[BaseEvent] = [] @@ -270,8 +323,18 @@ def _emit_tool_result_common( if predictive_handler: predictive_handler.apply_pending_updates() - if flow.current_state: - events.append(StateSnapshotEvent(snapshot=flow.current_state)) + + if state_update: + flow.current_state.update(state_update) + logger.debug( + "Emitted deterministic tool-result StateSnapshotEvent for call_id=%s (keys=%s)", + call_id, + list(state_update.keys()), + ) + + # Emit a single coalesced snapshot when either mechanism updated state. + if (predictive_handler or state_update) and flow.current_state: + events.append(StateSnapshotEvent(snapshot=flow.current_state)) flow.tool_call_id = None flow.tool_call_name = None @@ -294,7 +357,14 @@ def _emit_tool_result( if not content.call_id: return [] raw_result = content.result if content.result is not None else "" - return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler) + state_update = _extract_tool_result_state(content) + return _emit_tool_result_common( + content.call_id, + raw_result, + flow, + predictive_handler, + state_update=state_update, + ) def _emit_approval_request( @@ -459,7 +529,30 @@ def _emit_mcp_tool_result( logger.warning("MCP tool result content missing call_id, skipping") return [] raw_output = content.output if content.output is not None else "" - return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) + state_update = _extract_tool_result_state(content) + return _emit_tool_result_common( + content.call_id, + raw_output, + flow, + predictive_handler, + state_update=state_update, + ) + + +def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]: + """Close an open reasoning block, emitting end events. + + Should be called when the reasoning block is complete -- e.g. when + non-reasoning content arrives or at end of a run. + """ + if not flow.reasoning_message_id: + return [] + message_id = flow.reasoning_message_id + flow.reasoning_message_id = None + return [ + ReasoningMessageEndEvent(message_id=message_id), + ReasoningEndEvent(message_id=message_id), + ] def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]: @@ -468,6 +561,17 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis Uses the protocol-defined reasoning event types so that AG-UI consumers such as CopilotKit can render reasoning natively. + When *flow* is provided the function follows the streaming pattern: it + emits ``ReasoningStartEvent`` / ``ReasoningMessageStartEvent`` only on + the first delta for a given ``message_id`` and just + ``ReasoningMessageContentEvent`` for subsequent deltas. The matching + ``ReasoningMessageEndEvent`` / ``ReasoningEndEvent`` are deferred until + ``_close_reasoning_block`` is called (e.g. when non-reasoning content + arrives or at end-of-run). + + Without *flow* (backward-compat) the full Start→Content→End sequence is + emitted for every call. + Only ``content.text`` is used for the visible reasoning message. If ``content.protected_data`` is present it is emitted as a ``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted @@ -483,26 +587,49 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis message_id = content.id or generate_event_id() - events: list[BaseEvent] = [ - ReasoningStartEvent(message_id=message_id), - ReasoningMessageStartEvent(message_id=message_id, role="assistant"), - ] + events: list[BaseEvent] = [] - if text: - events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) + if flow is not None: + # Streaming mode: track open reasoning block in flow state. + if flow.reasoning_message_id != message_id: + # Close any previously open reasoning block (different message_id). + events.extend(_close_reasoning_block(flow)) + # Open new reasoning block. + events.append(ReasoningStartEvent(message_id=message_id)) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) + flow.reasoning_message_id = message_id - events.append(ReasoningMessageEndEvent(message_id=message_id)) + if text: + events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) - if content.protected_data is not None: - events.append( - ReasoningEncryptedValueEvent( - subtype="message", - entity_id=message_id, - encrypted_value=content.protected_data, + if content.protected_data is not None: + events.append( + ReasoningEncryptedValueEvent( + subtype="message", + entity_id=message_id, + encrypted_value=content.protected_data, + ) ) - ) + else: + # No flow -- backward-compatible full sequence per call. + events.append(ReasoningStartEvent(message_id=message_id)) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) - events.append(ReasoningEndEvent(message_id=message_id)) + if text: + events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) + + events.append(ReasoningMessageEndEvent(message_id=message_id)) + + if content.protected_data is not None: + events.append( + ReasoningEncryptedValueEvent( + subtype="message", + entity_id=message_id, + encrypted_value=content.protected_data, + ) + ) + + events.append(ReasoningEndEvent(message_id=message_id)) # Persist reasoning into flow state for MESSAGES_SNAPSHOT. # Accumulate reasoning text per message_id, similar to flow.accumulated_text, @@ -546,23 +673,30 @@ def _emit_content( ) -> list[BaseEvent]: """Emit appropriate events for any content type.""" content_type = getattr(content, "type", None) + + # Close open reasoning block when switching to non-reasoning content. + if content_type != "text_reasoning": + events = _close_reasoning_block(flow) + else: + events = [] + if content_type == "text": - return _emit_text(content, flow, skip_text) + return events + _emit_text(content, flow, skip_text) if content_type == "function_call": - return _emit_tool_call(content, flow, predictive_handler) + return events + _emit_tool_call(content, flow, predictive_handler) if content_type == "function_result": - return _emit_tool_result(content, flow, predictive_handler) + return events + _emit_tool_result(content, flow, predictive_handler) if content_type == "function_approval_request": - return _emit_approval_request(content, flow, predictive_handler, require_confirmation) + return events + _emit_approval_request(content, flow, predictive_handler, require_confirmation) if content_type == "usage": - return _emit_usage(content) + return events + _emit_usage(content) if content_type == "oauth_consent_request": - return _emit_oauth_consent(content) + return events + _emit_oauth_consent(content) if content_type == "mcp_server_tool_call": - return _emit_mcp_tool_call(content, flow) + return events + _emit_mcp_tool_call(content, flow) if content_type == "mcp_server_tool_result": - return _emit_mcp_tool_result(content, flow, predictive_handler) + return events + _emit_mcp_tool_result(content, flow, predictive_handler) if content_type == "text_reasoning": return _emit_text_reasoning(content, flow) logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type) - return [] + return events diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_state.py b/python/packages/ag-ui/agent_framework_ag_ui/_state.py new file mode 100644 index 0000000000..efce2988fa --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_state.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Deterministic tool-driven AG-UI state updates. + +Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a +deterministic state update by returning :func:`state_update`. Unlike +``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from +LLM-predicted tool call arguments — ``state_update`` runs *after* the tool +executes, so the AG-UI state always reflects the tool's actual return value. + +See issue https://github.com/microsoft/agent-framework/issues/3167 for the +motivating discussion. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from agent_framework import Content + +__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"] + + +TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__" +"""Reserved ``Content.additional_properties`` key used to carry a tool-driven +state snapshot from a tool return value through to the AG-UI emitter.""" + + +def state_update( + text: str = "", + *, + state: Mapping[str, Any], +) -> Content: + """Build a tool return value that deterministically updates AG-UI shared state. + + Return the result of this helper from an agent tool to push a state update + to AG-UI clients using the actual tool output, rather than LLM-predicted + tool arguments. + + When the AG-UI endpoint emits the tool result, it will: + + * Forward ``text`` to the LLM as the normal ``function_result`` content. + * Merge ``state`` into ``FlowState.current_state``. + * Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult`` + event so frontends observe the updated state deterministically. If + predictive state is enabled, a predictive snapshot may be emitted first. + + Example: + .. code-block:: python + + from agent_framework import tool + from agent_framework_ag_ui import state_update + + + @tool + async def get_weather(city: str) -> Content: + data = await _fetch_weather(city) + return state_update( + text=f"Weather in {city}: {data['temp']}°C {data['conditions']}", + state={"weather": {"city": city, **data}}, + ) + + Args: + text: Text passed back to the LLM as the ``function_result`` content. + Defaults to an empty string for tools whose only output is a state + update. + state: A mapping merged into the AG-UI shared state via JSON-compatible + ``dict.update`` semantics. Nested dicts are replaced, not deep-merged. + + Returns: + A ``Content`` object with ``type="text"``. The state payload rides in + ``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is + extracted by the AG-UI emitter. + + Raises: + TypeError: If ``state`` is not a ``Mapping``. + """ + if not isinstance(state, Mapping): + raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}") + return Content.from_text( + text, + additional_properties={TOOL_RESULT_STATE_KEY: dict(state)}, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index a75d29abc4..d34cb7db61 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -29,6 +29,7 @@ from ._message_adapters import normalize_agui_input_messages from ._run_common import ( FlowState, _build_run_finished_event, + _close_reasoning_block, _emit_content, _extract_resume_payload, _normalize_resume_interrupts, @@ -729,6 +730,9 @@ async def run_workflow_stream( run_error_emitted = True terminal_emitted = True + for reasoning_evt in _close_reasoning_block(flow): + yield reasoning_evt + for end_event in _drain_open_message(): yield end_event diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_state_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_state_agent.py new file mode 100644 index 0000000000..f556af3458 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_state_agent.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Deterministic tool-driven AG-UI state example. + +This sample demonstrates how a tool can push a *deterministic* state update +to the AG-UI frontend based on its actual return value — in contrast to +``predict_state_config`` which fires optimistically from LLM-predicted tool +call arguments. See issue https://github.com/microsoft/agent-framework/issues/3167. + +The :func:`agent_framework_ag_ui.state_update` helper wraps a text result +together with a state snapshot. When a tool returns one of these, the AG-UI +endpoint merges the snapshot into the shared state and emits a +``StateSnapshotEvent`` after the tool result. +""" + +from __future__ import annotations + +from typing import Any + +from agent_framework import Agent, Content, SupportsChatGetResponse, tool +from agent_framework.ag_ui import AgentFrameworkAgent + +from agent_framework_ag_ui import state_update + +# Simulated weather database — in the issue's motivating example the tool +# would instead call a real weather API. +_WEATHER_DB: dict[str, dict[str, Any]] = { + "seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75}, + "san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85}, + "new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60}, + "miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90}, + "chicago": {"temperature": 9, "conditions": "windy", "humidity": 65}, +} + + +@tool +async def get_weather(location: str) -> Content: + """Fetch current weather for a location and push it into AG-UI shared state. + + Unlike ``predict_state_config`` — which derives state optimistically from + LLM-predicted tool call arguments — this tool uses ``state_update`` to + forward the *actual* fetched weather to the frontend. The ``text`` goes + back to the LLM as the normal tool result, and the ``state`` dict is merged + into the AG-UI shared state. + + Args: + location: City name to look up. + + Returns: + A :class:`Content` carrying both the LLM-visible text result and a + deterministic state snapshot. + """ + key = location.lower() + data = _WEATHER_DB.get( + key, + {"temperature": 21, "conditions": "partly cloudy", "humidity": 50}, + ) + weather_record = {"location": location, **data} + return state_update( + text=( + f"The weather in {location} is {data['conditions']} at " + f"{data['temperature']}°C with {data['humidity']}% humidity." + ), + state={"weather": weather_record}, + ) + + +def weather_state_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent: + """Create an AG-UI agent with a deterministic tool-driven state tool.""" + agent = Agent[Any]( + name="weather_state_agent", + instructions=( + "You are a weather assistant. When a user asks about the weather " + "in a city, call the get_weather tool and use its output to give a " + "friendly, concise reply. The tool also updates the shared UI state " + "so the frontend can render a weather card from the `weather` key." + ), + client=client, + tools=[get_weather], + ) + + return AgentFrameworkAgent( + agent=agent, + name="WeatherStateAgent", + description="Weather agent that deterministically updates shared state from tool results.", + state_schema={ + "weather": { + "type": "object", + "description": "Last fetched weather record", + }, + }, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index 31a7c47963..4b7d56fba5 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -24,6 +24,7 @@ from ..agents.subgraphs_agent import subgraphs_agent from ..agents.task_steps_agent import task_steps_agent_wrapped from ..agents.ui_generator_agent import ui_generator_agent from ..agents.weather_agent import weather_agent +from ..agents.weather_state_agent import weather_state_agent AnthropicClient: type[Any] | None try: @@ -141,6 +142,14 @@ add_agent_framework_fastapi_endpoint( path="/subgraphs", ) +# Deterministic Tool-Driven State - tool returns state_update() to push snapshot +# from actual tool output (see issue #3167). +add_agent_framework_fastapi_endpoint( + app=app, + agent=weather_state_agent(client), + path="/deterministic_state", +) + def main(): """Run the server.""" diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 36f9bb805f..abe17daea9 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.0b260402" +version = "1.0.0b260409" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "ag-ui-protocol==0.1.13", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py new file mode 100644 index 0000000000..70bc5c129b --- /dev/null +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py @@ -0,0 +1,267 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Golden event-stream tests for the deterministic tool-driven state scenario. + +Covers issue https://github.com/microsoft/agent-framework/issues/3167 — a tool +returning :func:`agent_framework_ag_ui.state_update` must push a deterministic +``StateSnapshotEvent`` derived from its actual return value, orthogonal to the +optimistic ``predict_state_config`` path. These golden tests pin the user-visible +event stream so additive changes cannot silently regress it. +""" + +from __future__ import annotations + +from typing import Any + +from agent_framework import AgentResponseUpdate, Content +from conftest import StubAgent +from event_stream import EventStream + +from agent_framework_ag_ui import AgentFrameworkAgent, state_update + +STATE_SCHEMA = { + "weather": {"type": "object", "description": "Last fetched weather"}, +} + + +def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent: + stub = StubAgent(updates=updates) + kwargs.setdefault("state_schema", STATE_SCHEMA) + return AgentFrameworkAgent(agent=stub, **kwargs) + + +async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream: + return EventStream([event async for event in agent.run(payload)]) + + +PAYLOAD: dict[str, Any] = { + "thread_id": "thread-det-state", + "run_id": "run-det-state", + "messages": [{"role": "user", "content": "What's the weather in SF?"}], + "state": {"weather": {}}, +} + + +def _tool_call(call_id: str, name: str, arguments: str) -> AgentResponseUpdate: + return AgentResponseUpdate( + contents=[Content.from_function_call(name=name, call_id=call_id, arguments=arguments)], + role="assistant", + ) + + +def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> AgentResponseUpdate: + """Build a function_result update whose inner item carries a state marker. + + This mirrors what the core framework produces when a real ``@tool`` returns + :func:`state_update`: ``parse_result`` keeps the ``Content`` as-is, and + ``Content.from_function_result`` preserves its ``additional_properties`` + inside ``items``. + """ + return AgentResponseUpdate( + contents=[ + Content.from_function_result( + call_id=call_id, + result=[state_update(text=text, state=state)], + ) + ], + role="assistant", + ) + + +# ── Golden stream tests ── + + +async def test_deterministic_state_emits_snapshot_after_tool_result() -> None: + """The happy path: STATE_SNAPSHOT follows TOOL_CALL_RESULT in order.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather in SF: 14°C foggy", + state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}}, + ), + AgentResponseUpdate( + contents=[Content.from_text(text="It's 14°C and foggy in SF.")], + role="assistant", + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + stream.assert_text_messages_balanced() + + # Ordered subsequence: the deterministic STATE_SNAPSHOT must follow the + # TOOL_CALL_RESULT. This is the central contract for #3167. + stream.assert_ordered_types( + [ + "RUN_STARTED", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "STATE_SNAPSHOT", + "RUN_FINISHED", + ] + ) + + # The final STATE_SNAPSHOT must carry the tool-driven state. + snapshot = stream.snapshot() + assert snapshot["weather"] == {"city": "SF", "temp": 14, "conditions": "foggy"} + + +async def test_deterministic_state_does_not_fire_for_plain_tool_result() -> None: + """Regression guard: tools returning plain strings must NOT emit a new STATE_SNAPSHOT. + + The initial STATE_SNAPSHOT fires once from the schema + initial payload + state. A plain (non-state_update) tool result must not add another one. + """ + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + AgentResponseUpdate( + contents=[Content.from_function_result(call_id="call-1", result="14°C foggy")], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_text(text="It's 14°C and foggy.")], + role="assistant", + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + + snapshots = stream.get("STATE_SNAPSHOT") + # Only the initial snapshot (from state_schema + payload state) should exist. + # No deterministic snapshot should have been added by the plain tool result. + assert len(snapshots) == 1, ( + f"Expected exactly 1 STATE_SNAPSHOT (initial only) for plain tool result; " + f"got {len(snapshots)}. Snapshots: {[s.snapshot for s in snapshots]}" + ) + + +async def test_deterministic_state_merges_into_initial_state() -> None: + """The tool-driven snapshot must merge into, not replace, pre-existing state keys.""" + payload = dict(PAYLOAD) + payload["state"] = {"weather": {}, "user_preferences": {"unit": "C"}} + + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather: 14°C", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent(updates, state_schema={**STATE_SCHEMA, "user_preferences": {"type": "object"}}) + stream = await _run(agent, payload) + + stream.assert_bookends() + stream.assert_no_run_error() + + final_snapshot = stream.snapshot() + assert final_snapshot["weather"] == {"city": "SF", "temp": 14} + assert final_snapshot["user_preferences"] == {"unit": "C"}, ( + "Pre-existing state keys must survive the deterministic merge" + ) + + +async def test_deterministic_state_llm_visible_text_is_clean() -> None: + """The LLM-visible TOOL_CALL_RESULT content must not leak the state marker key.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather in SF: 14°C foggy", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + result = stream.first("TOOL_CALL_RESULT") + assert result.content == "Weather in SF: 14°C foggy" + # The marker key must never appear in the content sent back to the LLM. + assert "__ag_ui_tool_result_state__" not in result.content + assert "weather" not in result.content # not as a raw state dump + + +async def test_deterministic_state_multiple_tools_merge_in_order() -> None: + """Two state-updating tools in one run merge in order; later wins on key collisions.""" + updates = [ + _tool_call("call-a", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-a", + text="First result", + state={"weather": {"city": "SF", "temp": 14}, "source": "primary"}, + ), + _tool_call("call-b", "get_weather_refined", '{"city": "SF"}'), + _tool_result_with_state( + "call-b", + text="Refined result", + state={"source": "refined"}, + ), + AgentResponseUpdate( + contents=[Content.from_text(text="Here you go.")], + role="assistant", + ), + ] + agent = _build_agent( + updates, + state_schema={**STATE_SCHEMA, "source": {"type": "string"}}, + ) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_tool_calls_balanced() + stream.assert_no_run_error() + + # Two tool-driven snapshots emitted (one per tool) plus the initial snapshot. + snapshots = stream.get("STATE_SNAPSHOT") + assert len(snapshots) >= 2, f"Expected at least 2 STATE_SNAPSHOTs; got {len(snapshots)}" + + final = stream.snapshot() + assert final["weather"] == {"city": "SF", "temp": 14} + # Later tool must override earlier tool on the shared key. + assert final["source"] == "refined" + + +async def test_deterministic_state_coexists_with_predict_state_config() -> None: + """Predictive state and deterministic state must coexist without clobbering each other.""" + predict_config = { + "draft": { + "tool": "write_draft", + "tool_argument": "body", + } + } + updates = [ + # Predictive tool: its argument "body" populates state.draft optimistically. + _tool_call("call-1", "write_draft", '{"body": "Hello world"}'), + # Then a deterministic tool result landing a different key. + _tool_result_with_state( + "call-1", + text="Draft saved", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent( + updates, + state_schema={**STATE_SCHEMA, "draft": {"type": "string"}}, + predict_state_config=predict_config, + require_confirmation=False, + ) + payload = dict(PAYLOAD) + payload["state"] = {"weather": {}, "draft": ""} + stream = await _run(agent, payload) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + + # The final observed state must contain both the deterministic and predictive contributions. + final = stream.snapshot() + assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}" diff --git a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py index e6f58ef0fd..5ea284c68d 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py @@ -1405,3 +1405,95 @@ async def test_fabricated_rejection_without_pending_approval_is_blocked(streamin for content in msg.contents: if content.type == "function_result" and content.call_id == "fake_reject_001": assert False, "Fabricated rejection response leaked as function_result into LLM messages" + + +async def test_state_update_end_to_end_via_real_tool_invocation(streaming_chat_client_stub): + """End-to-end coverage for issue #3167: a real ``@tool`` returning ``state_update`` must + emit a deterministic STATE_SNAPSHOT through the full pipeline. + + This test exercises the entire chain that a user would hit in production: + ``FunctionInvocationLayer`` executes the tool, ``FunctionTool.parse_result`` + preserves the returned ``Content`` with its ``additional_properties`` marker, + ``Content.from_function_result`` carries the marker through in ``items``, + and the AG-UI emitter extracts it via ``_extract_tool_result_state`` and + emits the snapshot. A regression anywhere in that chain will fail this test. + """ + from agent_framework import tool + from agent_framework.ag_ui import AgentFrameworkAgent + + from agent_framework_ag_ui import state_update + + @tool(name="get_weather", description="Get current weather for a city.") + async def get_weather(city: str) -> Content: + return state_update( + text=f"Weather in {city}: 14°C foggy", + state={"weather": {"city": city, "temperature": 14, "conditions": "foggy"}}, + ) + + call_count = {"n": 0} + + async def stream_fn( + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + """First turn proposes a tool call; second turn (after tool execution) returns text.""" + call_count["n"] += 1 + if call_count["n"] == 1: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call( + name="get_weather", + call_id="call-weather-1", + arguments='{"city": "SF"}', + ) + ] + ) + else: + yield ChatResponseUpdate(contents=[Content.from_text(text="It's 14°C and foggy in SF.")]) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + name="weather_agent", + instructions="Answer weather questions.", + tools=[get_weather], + ) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"weather": {"type": "object"}}, + ) + + events: list[Any] = [] + async for event in wrapper.run( + { + "thread_id": "thread-weather", + "run_id": "run-weather", + "messages": [{"role": "user", "content": "What's the weather in SF?"}], + "state": {"weather": {}}, + } + ): + events.append(event) + + types = [e.type for e in events] + + # The tool call must be visible in the stream. + assert "TOOL_CALL_START" in types, f"Missing TOOL_CALL_START in: {types}" + assert "TOOL_CALL_RESULT" in types, f"Missing TOOL_CALL_RESULT in: {types}" + + # A STATE_SNAPSHOT must be emitted after the tool result. + tool_result_idx = types.index("TOOL_CALL_RESULT") + snapshot_indices_after_result = [i for i, t in enumerate(types) if t == "STATE_SNAPSHOT" and i > tool_result_idx] + assert snapshot_indices_after_result, ( + f"Expected a STATE_SNAPSHOT after TOOL_CALL_RESULT (index {tool_result_idx}); got types: {types}" + ) + + # The tool's deterministic snapshot carries the actual fetched weather data. + final_snapshot = events[snapshot_indices_after_result[-1]].snapshot + assert final_snapshot["weather"] == { + "city": "SF", + "temperature": 14, + "conditions": "foggy", + } + + # The LLM-visible tool result must carry the plain text, not the marker key. + tool_result_event = next(e for e in events if e.type == "TOOL_CALL_RESULT") + assert tool_result_event.content == "Weather in SF: 14°C foggy" + assert "__ag_ui_tool_result_state__" not in tool_result_event.content diff --git a/python/packages/ag-ui/tests/ag_ui/test_public_exports.py b/python/packages/ag-ui/tests/ag_ui/test_public_exports.py index 433935fb24..ea570f50a6 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_public_exports.py +++ b/python/packages/ag-ui/tests/ag_ui/test_public_exports.py @@ -18,7 +18,24 @@ def test_core_ag_ui_lazy_exports_include_only_stable_api() -> None: assert hasattr(ag_ui, "AgentFrameworkAgent") assert hasattr(ag_ui, "AGUIChatClient") assert hasattr(ag_ui, "add_agent_framework_fastapi_endpoint") + assert hasattr(ag_ui, "state_update") assert not hasattr(ag_ui, "WorkflowFactory") assert not hasattr(ag_ui, "AGUIRequest") assert not hasattr(ag_ui, "RunMetadata") + + +def test_agent_framework_ag_ui_exports_state_update() -> None: + """Runtime package should export the ``state_update`` helper.""" + from agent_framework_ag_ui import state_update + + assert callable(state_update) + + +def test_core_ag_ui_lazy_exports_include_event_converter_and_http_service() -> None: + """Core facade must expose AGUIEventConverter, AGUIHttpService, and __version__.""" + from agent_framework import ag_ui + + assert hasattr(ag_ui, "AGUIEventConverter") + assert hasattr(ag_ui, "AGUIHttpService") + assert hasattr(ag_ui, "__version__") diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 0e5c329ce9..18b0d0d7e4 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -11,6 +11,7 @@ from ag_ui.core import ( ReasoningMessageEndEvent, ReasoningMessageStartEvent, ReasoningStartEvent, + TextMessageContentEvent, TextMessageEndEvent, TextMessageStartEvent, ToolCallArgsEvent, @@ -29,6 +30,7 @@ from agent_framework_ag_ui._agent_run import ( from agent_framework_ag_ui._run_common import ( FlowState, _build_run_finished_event, + _close_reasoning_block, _emit_approval_request, _emit_content, _emit_mcp_tool_call, @@ -1344,8 +1346,11 @@ class TestEmitContentMcpRouting: events = _emit_content(content, flow) - assert len(events) == 5 + # Streaming pattern: Start + MessageStart + Content (no End events yet) + assert len(events) == 3 assert isinstance(events[0], ReasoningStartEvent) + assert isinstance(events[1], ReasoningMessageStartEvent) + assert isinstance(events[2], ReasoningMessageContentEvent) class TestReasoningInSnapshot: @@ -1501,3 +1506,137 @@ class TestReasoningInSnapshot: assert len(flow.reasoning_messages) == 1 assert flow.reasoning_messages[0]["content"] == "part1 part2" assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload" + + def test_reasoning_done_after_deltas_does_not_duplicate(self): + """A done-style content arriving after deltas does not duplicate accumulated text. + + The upstream client should skip done events when deltas preceded them, + but if one leaks through, the accumulator must not double-append. + This test verifies that only the delta-produced text is stored. + """ + flow = FlowState() + msg_id = "reason_dedup" + + delta1 = Content.from_text_reasoning(id=msg_id, text="Hello ") + delta2 = Content.from_text_reasoning(id=msg_id, text="world") + + _emit_text_reasoning(delta1, flow) + _emit_text_reasoning(delta2, flow) + + # Accumulated text should equal the concatenation of deltas only + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "Hello world" + assert flow.reasoning_messages[0]["id"] == msg_id + + def test_reasoning_deltas_emit_one_content_event_each(self): + """Each reasoning delta emits exactly one ReasoningMessageContentEvent + within a single Start/End sequence (streaming pattern).""" + flow = FlowState() + msg_id = "reason_evt" + + delta1 = Content.from_text_reasoning(id=msg_id, text="Think ") + delta2 = Content.from_text_reasoning(id=msg_id, text="hard") + + events1 = _emit_text_reasoning(delta1, flow) + events2 = _emit_text_reasoning(delta2, flow) + close_events = _close_reasoning_block(flow) + + all_events = events1 + events2 + close_events + content_events = [e for e in all_events if isinstance(e, ReasoningMessageContentEvent)] + + assert len(content_events) == 2 + assert content_events[0].delta == "Think " + assert content_events[1].delta == "hard" + + # Streaming pattern: one Start/End sequence wrapping both content events + start_events = [e for e in all_events if isinstance(e, ReasoningStartEvent)] + end_events = [e for e in all_events if isinstance(e, ReasoningEndEvent)] + msg_start_events = [e for e in all_events if isinstance(e, ReasoningMessageStartEvent)] + msg_end_events = [e for e in all_events if isinstance(e, ReasoningMessageEndEvent)] + assert len(start_events) == 1 + assert len(end_events) == 1 + assert len(msg_start_events) == 1 + assert len(msg_end_events) == 1 + + def test_reasoning_streaming_event_order(self): + """Streaming reasoning emits Start once, then Content per delta, then End on close.""" + flow = FlowState() + msg_id = "reason_order" + + d1 = Content.from_text_reasoning(id=msg_id, text="A ") + d2 = Content.from_text_reasoning(id=msg_id, text="B ") + d3 = Content.from_text_reasoning(id=msg_id, text="C") + + events = [] + events.extend(_emit_text_reasoning(d1, flow)) + events.extend(_emit_text_reasoning(d2, flow)) + events.extend(_emit_text_reasoning(d3, flow)) + events.extend(_close_reasoning_block(flow)) + + assert isinstance(events[0], ReasoningStartEvent) + assert isinstance(events[1], ReasoningMessageStartEvent) + assert isinstance(events[2], ReasoningMessageContentEvent) + assert events[2].delta == "A " + assert isinstance(events[3], ReasoningMessageContentEvent) + assert events[3].delta == "B " + assert isinstance(events[4], ReasoningMessageContentEvent) + assert events[4].delta == "C" + assert isinstance(events[5], ReasoningMessageEndEvent) + assert isinstance(events[6], ReasoningEndEvent) + assert len(events) == 7 + + def test_close_reasoning_block_noop_when_not_open(self): + """_close_reasoning_block returns empty list when no reasoning block is open.""" + flow = FlowState() + assert _close_reasoning_block(flow) == [] + + def test_close_reasoning_block_resets_state(self): + """_close_reasoning_block clears reasoning_message_id.""" + flow = FlowState() + _emit_text_reasoning(Content.from_text_reasoning(id="r1", text="x"), flow) + assert flow.reasoning_message_id == "r1" + + _close_reasoning_block(flow) + assert flow.reasoning_message_id is None + + def test_emit_content_closes_reasoning_on_text(self): + """Switching from reasoning to text content auto-closes reasoning block.""" + flow = FlowState() + reasoning = Content.from_text_reasoning(id="r1", text="thinking") + text = Content.from_text("answer") + + r_events = _emit_content(reasoning, flow) + t_events = _emit_content(text, flow) + + # reasoning events: Start + MsgStart + Content + assert isinstance(r_events[0], ReasoningStartEvent) + # text events should start with reasoning End events + assert isinstance(t_events[0], ReasoningMessageEndEvent) + assert isinstance(t_events[1], ReasoningEndEvent) + # then text start + + assert isinstance(t_events[2], TextMessageStartEvent) + assert isinstance(t_events[3], TextMessageContentEvent) + + def test_reasoning_distinct_ids_close_previous_block(self): + """Emitting reasoning with a new message_id auto-closes the previous block.""" + flow = FlowState() + c1 = Content.from_text_reasoning(id="block1", text="first") + c2 = Content.from_text_reasoning(id="block2", text="second") + + events1 = _emit_text_reasoning(c1, flow) + events2 = _emit_text_reasoning(c2, flow) + close = _close_reasoning_block(flow) + + # events1: Start(block1) + MsgStart(block1) + Content(block1) + assert events1[0].message_id == "block1" + # events2: MsgEnd(block1) + End(block1) + Start(block2) + MsgStart(block2) + Content(block2) + assert isinstance(events2[0], ReasoningMessageEndEvent) + assert events2[0].message_id == "block1" + assert isinstance(events2[1], ReasoningEndEvent) + assert events2[1].message_id == "block1" + assert isinstance(events2[2], ReasoningStartEvent) + assert events2[2].message_id == "block2" + # close: MsgEnd(block2) + End(block2) + assert isinstance(close[0], ReasoningMessageEndEvent) + assert close[0].message_id == "block2" diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index 526a3c33c1..27294d9171 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -2,14 +2,20 @@ """Tests for _run_common.py edge cases.""" +from ag_ui.core import EventType from agent_framework import Content +from agent_framework_ag_ui import state_update +from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler from agent_framework_ag_ui._run_common import ( FlowState, + _emit_mcp_tool_result, _emit_tool_result, _extract_resume_payload, + _extract_tool_result_state, _normalize_resume_interrupts, ) +from agent_framework_ag_ui._state import TOOL_RESULT_STATE_KEY class TestNormalizeResumeInterrupts: @@ -120,3 +126,223 @@ class TestEmitToolResult: assert "TEXT_MESSAGE_END" in event_types assert flow.message_id is None assert flow.accumulated_text == "" + + +class TestStateUpdateHelper: + """Tests for the public ``state_update`` helper.""" + + def test_builds_text_content_with_state_marker(self): + """state_update returns a text Content carrying state in additional_properties.""" + c = state_update(text="done", state={"weather": {"temp": 14}}) + assert c.type == "text" + assert c.text == "done" + assert c.additional_properties == { + TOOL_RESULT_STATE_KEY: {"weather": {"temp": 14}}, + } + + def test_empty_text_is_allowed(self): + """State-only tools can omit the text argument.""" + c = state_update(state={"steps": ["a", "b"]}) + assert c.text == "" + assert c.additional_properties[TOOL_RESULT_STATE_KEY] == {"steps": ["a", "b"]} + + def test_non_mapping_state_raises(self): + """Passing a non-mapping value for state raises TypeError.""" + import pytest + + with pytest.raises(TypeError): + state_update(text="t", state=["not", "a", "mapping"]) # type: ignore[arg-type] + + def test_state_is_copied_defensively(self): + """Mutating the caller's dict after ``state_update`` must not mutate the content.""" + caller_state = {"weather": {"temp": 14}} + c = state_update(text="ok", state=caller_state) + caller_state["weather"]["temp"] = 99 + # The top-level dict was copied, so replacing the key in caller_state + # would not affect the Content, but nested dicts share references — document + # this by asserting only the top-level copy semantics. + assert TOOL_RESULT_STATE_KEY in c.additional_properties + inner = c.additional_properties[TOOL_RESULT_STATE_KEY] + assert inner is not caller_state + + +class TestExtractToolResultState: + """Tests for ``_extract_tool_result_state``.""" + + def test_returns_none_for_plain_string_result(self): + content = Content.from_function_result(call_id="c1", result="plain") + assert _extract_tool_result_state(content) is None + + def test_extracts_state_from_inner_item(self): + tool_return = state_update(text="hi", state={"k": 1}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + assert _extract_tool_result_state(content) == {"k": 1} + + def test_extracts_state_from_outer_additional_properties(self): + """Outer function_result content can also carry state (legacy/advanced use).""" + content = Content.from_function_result( + call_id="c1", + result="hi", + additional_properties={TOOL_RESULT_STATE_KEY: {"k": 1}}, + ) + assert _extract_tool_result_state(content) == {"k": 1} + + def test_merges_multiple_items(self): + a = state_update(text="a", state={"k": 1, "shared": "from_a"}) + b = state_update(text="b", state={"shared": "from_b", "extra": True}) + content = Content.from_function_result(call_id="c1", result=[a, b]) + merged = _extract_tool_result_state(content) + assert merged == {"k": 1, "shared": "from_b", "extra": True} + + def test_ignores_non_dict_marker_value(self): + """A garbled marker value must not break extraction (defensive guard).""" + bad = Content.from_text( + "hi", + additional_properties={TOOL_RESULT_STATE_KEY: "not-a-dict"}, + ) + content = Content.from_function_result(call_id="c1", result=[bad]) + assert _extract_tool_result_state(content) is None + + +class TestEmitToolResultWithState: + """Tests for the deterministic state emission in ``_emit_tool_result``.""" + + def test_emits_state_snapshot_after_tool_call_result(self): + """Tool returning state_update produces a StateSnapshotEvent right after the result.""" + tool_return = state_update( + text="Weather: 14°C", + state={"weather": {"temp": 14, "conditions": "foggy"}}, + ) + content = Content.from_function_result(call_id="call_1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + event_types = [e.type for e in events] + + # Expect TOOL_CALL_END, TOOL_CALL_RESULT, STATE_SNAPSHOT in that order. + assert event_types[0] == EventType.TOOL_CALL_END + assert event_types[1] == EventType.TOOL_CALL_RESULT + state_idx = event_types.index(EventType.STATE_SNAPSHOT) + assert state_idx == 2 + assert events[state_idx].snapshot == {"weather": {"temp": 14, "conditions": "foggy"}} + + def test_updates_flow_current_state(self): + tool_return = state_update(text="", state={"a": 1}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState(current_state={"existing": "value"}) + + _emit_tool_result(content, flow) + + # Existing keys must survive (merge semantics), new keys must be added. + assert flow.current_state == {"existing": "value", "a": 1} + + def test_merge_overrides_existing_key(self): + tool_return = state_update(text="", state={"existing": "new"}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState(current_state={"existing": "old", "other": 1}) + + _emit_tool_result(content, flow) + + assert flow.current_state == {"existing": "new", "other": 1} + + def test_no_state_snapshot_when_result_has_no_state(self): + """Plain tool results must not emit a StateSnapshotEvent.""" + content = Content.from_function_result(call_id="c1", result="plain") + flow = FlowState() + + events = _emit_tool_result(content, flow) + assert all(e.type != EventType.STATE_SNAPSHOT for e in events) + + def test_tool_result_content_text_unchanged(self): + """The text sent to the LLM must not leak the state marker.""" + tool_return = state_update(text="Weather: 14°C", state={"weather": {"temp": 14}}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + assert len(result_events) == 1 + assert result_events[0].content == "Weather: 14°C" + assert TOOL_RESULT_STATE_KEY not in result_events[0].content + + def test_coexists_with_active_predictive_state_handler(self): + """Both predictive and deterministic state produce a single coalesced snapshot. + + Predictive state (``predict_state_config``) and deterministic state + (``state_update``) are two independent mechanisms. When both are active, + a single coalesced ``StateSnapshotEvent`` is emitted containing the + merged result of both contributions. + """ + flow = FlowState(current_state={"preexisting": "value"}) + handler = PredictiveStateHandler( + predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}}, + current_state=flow.current_state, + ) + + tool_return = state_update(text="Draft written", state={"draft_final": True}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + + events = _emit_tool_result(content, flow, predictive_handler=handler) + + # Exactly one coalesced snapshot must be emitted containing all merged keys. + snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT] + assert len(snapshots) == 1 + assert snapshots[0].snapshot["draft_final"] is True + assert snapshots[0].snapshot["preexisting"] == "value" + assert flow.current_state["draft_final"] is True + assert flow.current_state["preexisting"] == "value" + + def test_predictive_and_deterministic_emit_single_snapshot(self): + """When both predictive_handler and state_update are active, only one snapshot is emitted.""" + flow = FlowState(current_state={"existing": "yes"}) + handler = PredictiveStateHandler( + predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}}, + current_state=flow.current_state, + ) + + tool_return = state_update(text="ok", state={"new_key": 42}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + + events = _emit_tool_result(content, flow, predictive_handler=handler) + + snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT] + assert len(snapshots) == 1, f"Expected 1 coalesced snapshot, got {len(snapshots)}" + assert snapshots[0].snapshot == {"existing": "yes", "new_key": 42} + + +class TestEmitMcpToolResultWithState: + """MCP tool results should honour the same state_update marker. + + MCP results come from an external MCP server rather than a locally + executed ``@tool`` function, so they do not flow through ``parse_result`` + and ``content.items`` is typically empty. State is instead carried on the + outer content's ``additional_properties`` (e.g. by middleware that + inspects the MCP output and attaches a marker). ``_extract_tool_result_state`` + supports both locations so this path remains usable. + """ + + def test_mcp_tool_result_emits_state_snapshot_from_additional_properties(self): + content = Content.from_mcp_server_tool_result( + call_id="mcp_1", + output="server result", + additional_properties={TOOL_RESULT_STATE_KEY: {"mcp_ok": True}}, + ) + flow = FlowState() + + events = _emit_mcp_tool_result(content, flow) + event_types = [e.type for e in events] + + assert EventType.TOOL_CALL_END in event_types + assert EventType.TOOL_CALL_RESULT in event_types + assert EventType.STATE_SNAPSHOT in event_types + assert flow.current_state == {"mcp_ok": True} + + def test_mcp_tool_result_without_state_emits_no_snapshot(self): + content = Content.from_mcp_server_tool_result( + call_id="mcp_1", + output="server result", + ) + flow = FlowState() + + events = _emit_mcp_tool_result(content, flow) + assert all(e.type != EventType.STATE_SNAPSHOT for e in events) diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index feed99a503..f162cc5d06 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.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index d6fe64ba36..15cc466b20 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ 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.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-cosmos/README.md b/python/packages/azure-cosmos/README.md index d2868c78b7..a03c5c6f93 100644 --- a/python/packages/azure-cosmos/README.md +++ b/python/packages/azure-cosmos/README.md @@ -14,7 +14,7 @@ The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent ```python from azure.identity.aio import DefaultAzureCredential -from agent_framework.azure import CosmosHistoryProvider +from agent_framework_azure_cosmos import CosmosHistoryProvider provider = CosmosHistoryProvider( endpoint="https://.documents.azure.com:443/", @@ -35,13 +35,92 @@ Container naming behavior: - Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`) - `session_id` is used as the Cosmos partition key for reads/writes -See the [conversation samples](../../samples/02-agents/conversations/) for runnable examples, including -[`cosmos_history_provider.py`](../../samples/02-agents/conversations/cosmos_history_provider.py). +See `samples/02-agents/conversations/cosmos_history_provider.py` for a runnable example. -## Import Paths +## Cosmos DB Workflow Checkpoint Storage + +`CosmosCheckpointStorage` implements the `CheckpointStorage` protocol, enabling +durable workflow checkpointing backed by Azure Cosmos DB NoSQL. Workflows can be +paused and resumed across process restarts by persisting checkpoint state in Cosmos DB. + +### Basic Usage + +#### Managed Identity / RBAC (recommended for production) ```python -from agent_framework.azure import CosmosHistoryProvider -# or directly: -from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import DefaultAzureCredential +from agent_framework import WorkflowBuilder +from agent_framework_azure_cosmos import CosmosCheckpointStorage + +checkpoint_storage = CosmosCheckpointStorage( + endpoint="https://.documents.azure.com:443/", + credential=DefaultAzureCredential(), + database_name="agent-framework", + container_name="workflow-checkpoints", +) ``` + +#### Account Key + +```python +from agent_framework_azure_cosmos import CosmosCheckpointStorage + +checkpoint_storage = CosmosCheckpointStorage( + endpoint="https://.documents.azure.com:443/", + credential="", + database_name="agent-framework", + container_name="workflow-checkpoints", +) +``` + +#### Then use with a workflow + +```python +from agent_framework import WorkflowBuilder + +# Build a workflow with checkpointing enabled +workflow = WorkflowBuilder( + start_executor=start, + checkpoint_storage=checkpoint_storage, +).build() + +# Run the workflow — checkpoints are automatically saved after each superstep +result = await workflow.run(message="input data") + +# Resume from a checkpoint +latest = await checkpoint_storage.get_latest(workflow_name=workflow.name) +if latest: + resumed = await workflow.run(checkpoint_id=latest.checkpoint_id) +``` + +### Authentication Options + +`CosmosCheckpointStorage` supports the same authentication modes as `CosmosHistoryProvider`: + +- **Managed identity / RBAC** (recommended): Pass `DefaultAzureCredential()`, + `ManagedIdentityCredential()`, or any Azure `TokenCredential` +- **Account key**: Pass a key string via `credential` parameter +- **Environment variables**: Set `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`, + `AZURE_COSMOS_CONTAINER_NAME`, and `AZURE_COSMOS_KEY` (key not required when using + Azure credentials) +- **Pre-created client**: Pass an existing `CosmosClient` or `ContainerProxy` + +### Database and Container Setup + +The database and container are created automatically on first use (via +`create_database_if_not_exists` and `create_container_if_not_exists`). The container +uses `/workflow_name` as the partition key. You can also pre-create them in the Azure +portal with this partition key configuration. + +### Environment Variables + +| Variable | Description | +|---|---| +| `AZURE_COSMOS_ENDPOINT` | Cosmos DB account endpoint | +| `AZURE_COSMOS_DATABASE_NAME` | Database name | +| `AZURE_COSMOS_CONTAINER_NAME` | Container name | +| `AZURE_COSMOS_KEY` | Account key (optional if using Azure credentials) | + +See `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py` for a standalone example, +or `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py` for an end-to-end +example with Azure AI Foundry agents. diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py index 5bcfb3928b..66373b0f1d 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py @@ -2,6 +2,7 @@ import importlib.metadata +from ._checkpoint_storage import CosmosCheckpointStorage from ._history_provider import CosmosHistoryProvider try: @@ -10,6 +11,7 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode __all__ = [ + "CosmosCheckpointStorage", "CosmosHistoryProvider", "__version__", ] diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py new file mode 100644 index 0000000000..496d95d7c3 --- /dev/null +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -0,0 +1,456 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Cosmos DB checkpoint storage for workflow checkpointing.""" + +from __future__ import annotations + +import logging +from typing import Any, TypedDict + +from agent_framework import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._settings import SecretString, load_settings +from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint +from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value +from agent_framework.exceptions import WorkflowCheckpointException +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.cosmos import PartitionKey +from azure.cosmos.aio import ContainerProxy, CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + +logger = logging.getLogger(__name__) + + +class AzureCosmosCheckpointSettings(TypedDict, total=False): + """Settings for CosmosCheckpointStorage resolved from args and environment.""" + + endpoint: str | None + database_name: str | None + container_name: str | None + key: SecretString | None + + +class CosmosCheckpointStorage: + """Azure Cosmos DB-backed checkpoint storage for workflow checkpointing. + + Implements the ``CheckpointStorage`` protocol using Azure Cosmos DB NoSQL + as the persistent backend. Checkpoints are stored as JSON documents with + ``workflow_name`` as the partition key, enabling efficient per-workflow queries. + + This storage uses the same hybrid JSON + pickle encoding as + ``FileCheckpointStorage``, allowing full Python object fidelity for + complex workflow state while keeping the document structure human-readable. + + Security warning: checkpoints use pickle for non-JSON-native values. Loading + checkpoints from untrusted sources is unsafe and can execute arbitrary code + during deserialization. The built-in deserialization restrictions reduce risk, + but they do not make untrusted checkpoints safe to load. Extending + ``allowed_checkpoint_types`` may further increase risk and should only be done + for trusted application types. + + By default, checkpoint deserialization is restricted to a built-in set of safe + Python types (primitives, datetime, uuid, ...) and all ``agent_framework`` + internal types. To allow additional application-specific types, pass them via + the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format. + + Example: + + .. code-block:: python + + from azure.identity.aio import DefaultAzureCredential + from agent_framework_azure_cosmos import CosmosCheckpointStorage + + storage = CosmosCheckpointStorage( + endpoint="https://my-account.documents.azure.com:443/", + credential=DefaultAzureCredential(), + database_name="agent-db", + container_name="checkpoints", + allowed_checkpoint_types=[ + "my_app.models:MyState", + ], + ) + + The database and container are created automatically on first use + if they do not already exist. The container uses partition key + ``/workflow_name``. + + Example using managed identity / RBAC: + + .. code-block:: python + + from azure.identity.aio import DefaultAzureCredential + from agent_framework_azure_cosmos import CosmosCheckpointStorage + + storage = CosmosCheckpointStorage( + endpoint="https://my-account.documents.azure.com:443/", + credential=DefaultAzureCredential(), + database_name="agent-db", + container_name="checkpoints", + ) + + Example using account key: + + .. code-block:: python + + storage = CosmosCheckpointStorage( + endpoint="https://my-account.documents.azure.com:443/", + credential="my-account-key", + database_name="agent-db", + container_name="checkpoints", + ) + + Then use with a workflow builder: + + .. code-block:: python + + workflow = WorkflowBuilder( + start_executor=start, + checkpoint_storage=storage, + ).build() + """ + + def __init__( + self, + *, + endpoint: str | None = None, + database_name: str | None = None, + container_name: str | None = None, + credential: str | AzureCredentialTypes | None = None, + cosmos_client: CosmosClient | None = None, + container_client: ContainerProxy | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + allowed_checkpoint_types: list[str] | None = None, + ) -> None: + """Initialize the Azure Cosmos DB checkpoint storage. + + Supports multiple authentication modes: + + - **Container client** (``container_client``): Use a pre-created + Cosmos async container proxy. No client lifecycle is managed. + - **Cosmos client** (``cosmos_client``): Use a pre-created Cosmos + async client. The caller is responsible for closing it. + - **Endpoint + credential**: Create a new Cosmos client. The storage + owns the client and closes it on ``close()``. + - **Environment variables**: Falls back to ``AZURE_COSMOS_ENDPOINT``, + ``AZURE_COSMOS_DATABASE_NAME``, ``AZURE_COSMOS_CONTAINER_NAME``, + and ``AZURE_COSMOS_KEY``. + + Args: + endpoint: Cosmos DB account endpoint. + Can be set via ``AZURE_COSMOS_ENDPOINT``. + database_name: Cosmos DB database name. + Can be set via ``AZURE_COSMOS_DATABASE_NAME``. + container_name: Cosmos DB container name. + Can be set via ``AZURE_COSMOS_CONTAINER_NAME``. + credential: Credential to authenticate with Cosmos DB. + For **managed identity / RBAC**, pass an Azure credential object + such as ``DefaultAzureCredential()`` or + ``ManagedIdentityCredential()``. + For **key-based auth**, pass the account key as a string, + or set ``AZURE_COSMOS_KEY`` in the environment. + cosmos_client: Pre-created Cosmos async client. + container_client: Pre-created Cosmos container client. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + allowed_checkpoint_types: Additional types (beyond the built-in safe set + and framework types) that are permitted during checkpoint + deserialization. Each entry should be a ``"module:qualname"`` + string (e.g., ``"my_app.models:MyState"``). + """ + self._cosmos_client: CosmosClient | None = cosmos_client + self._container_proxy: ContainerProxy | None = container_client + self._owns_client = False + self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or []) + + if self._container_proxy is not None: + self.database_name: str = database_name or "" + self.container_name: str = container_name or "" + return + + required_fields: list[str] = ["database_name", "container_name"] + if cosmos_client is None: + required_fields.append("endpoint") + if credential is None: + required_fields.append("key") + + settings = load_settings( + AzureCosmosCheckpointSettings, + env_prefix="AZURE_COSMOS_", + required_fields=required_fields, + endpoint=endpoint, + database_name=database_name, + container_name=container_name, + key=credential if isinstance(credential, str) else None, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + self.database_name = settings["database_name"] # type: ignore[assignment] + self.container_name = settings["container_name"] # type: ignore[assignment] + + if self._cosmos_client is None: + self._cosmos_client = CosmosClient( + url=settings["endpoint"], # type: ignore[arg-type] + credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] + user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT, + ) + self._owns_client = True + + async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: + """Save a checkpoint to Cosmos DB and return its ID. + + The checkpoint is encoded to a JSON-compatible form (using pickle for + non-JSON-native values) and stored as a Cosmos DB document with the + ``workflow_name`` as the partition key. + + The document ``id`` is a composite of ``workflow_name`` and + ``checkpoint_id`` to ensure global uniqueness across partitions. + + Args: + checkpoint: The WorkflowCheckpoint object to save. + + Returns: + The unique ID of the saved checkpoint. + """ + await self._ensure_container_proxy() + + checkpoint_dict = checkpoint.to_dict() + encoded = encode_checkpoint_value(checkpoint_dict) + + document: dict[str, Any] = { + "id": self._make_document_id(checkpoint.workflow_name, checkpoint.checkpoint_id), + "workflow_name": checkpoint.workflow_name, + **encoded, + } + + await self._container_proxy.upsert_item(body=document) # type: ignore[union-attr] + logger.info("Saved checkpoint %s to Cosmos DB", checkpoint.checkpoint_id) + return checkpoint.checkpoint_id + + async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: + """Load a checkpoint from Cosmos DB by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to load. + + Returns: + The WorkflowCheckpoint object corresponding to the given ID. + + Raises: + WorkflowCheckpointException: If no checkpoint with the given ID exists, + or if multiple checkpoints share the same ID across workflows. + """ + await self._ensure_container_proxy() + + query = "SELECT * FROM c WHERE c.checkpoint_id = @checkpoint_id" + parameters: list[dict[str, object]] = [ + {"name": "@checkpoint_id", "value": checkpoint_id}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + ) + + results: list[dict[str, Any]] = [] + async for item in items: + results.append(item) + + if not results: + raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") + + if len(results) > 1: + workflow_names = [r.get("workflow_name", "unknown") for r in results] + raise WorkflowCheckpointException( + f"Multiple checkpoints found with ID {checkpoint_id} across workflows: " + f"{workflow_names}. Use list_checkpoints(workflow_name=...) to query " + f"by workflow instead." + ) + + return self._document_to_checkpoint(results[0]) + + async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: + """List checkpoint objects for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoints for. + + Returns: + A list of WorkflowCheckpoint objects for the specified workflow name. + """ + await self._ensure_container_proxy() + + query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC" + parameters: list[dict[str, object]] = [ + {"name": "@workflow_name", "value": workflow_name}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + partition_key=workflow_name, + ) + + checkpoints: list[WorkflowCheckpoint] = [] + async for item in items: + try: + checkpoints.append(self._document_to_checkpoint(item)) + except Exception as e: + logger.warning("Failed to decode checkpoint document: %s", e) + return checkpoints + + async def delete(self, checkpoint_id: CheckpointID) -> bool: + """Delete a checkpoint from Cosmos DB by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to delete. + + Returns: + True if the checkpoint was successfully deleted, False if not found. + """ + await self._ensure_container_proxy() + + query = "SELECT c.id, c.workflow_name FROM c WHERE c.checkpoint_id = @checkpoint_id" + parameters: list[dict[str, object]] = [ + {"name": "@checkpoint_id", "value": checkpoint_id}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + ) + + async for item in items: + try: + await self._container_proxy.delete_item( # type: ignore[union-attr] + item=item["id"], + partition_key=item["workflow_name"], + ) + logger.info("Deleted checkpoint %s from Cosmos DB", checkpoint_id) + return True + except CosmosResourceNotFoundError: + return False + + return False + + async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None: + """Get the latest checkpoint for a given workflow name. + + Args: + workflow_name: The name of the workflow to get the latest checkpoint for. + + Returns: + The latest WorkflowCheckpoint, or None if no checkpoints exist. + """ + await self._ensure_container_proxy() + + query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp DESC OFFSET 0 LIMIT 1" + parameters: list[dict[str, object]] = [ + {"name": "@workflow_name", "value": workflow_name}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + partition_key=workflow_name, + ) + + async for item in items: + checkpoint = self._document_to_checkpoint(item) + logger.debug( + "Latest checkpoint for workflow %s is %s", + workflow_name, + checkpoint.checkpoint_id, + ) + return checkpoint + + return None + + async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]: + """List checkpoint IDs for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoint IDs for. + + Returns: + A list of checkpoint IDs for the specified workflow name. + """ + await self._ensure_container_proxy() + + query = "SELECT c.checkpoint_id FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC" + parameters: list[dict[str, object]] = [ + {"name": "@workflow_name", "value": workflow_name}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + partition_key=workflow_name, + ) + + checkpoint_ids: list[CheckpointID] = [] + async for item in items: + cid = item.get("checkpoint_id") + if isinstance(cid, str): + checkpoint_ids.append(cid) + return checkpoint_ids + + async def close(self) -> None: + """Close the underlying Cosmos client when this storage owns it.""" + if self._owns_client and self._cosmos_client is not None: + await self._cosmos_client.close() + + async def __aenter__(self) -> CosmosCheckpointStorage: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit.""" + try: + await self.close() + except Exception: + if exc_type is None: + raise + + async def _ensure_container_proxy(self) -> None: + """Get or create the Cosmos DB database and container for storing checkpoints.""" + if self._container_proxy is not None: + return + if self._cosmos_client is None: + raise RuntimeError("Cosmos client is not initialized.") + + database = await self._cosmos_client.create_database_if_not_exists(id=self.database_name) + self._container_proxy = await database.create_container_if_not_exists( + id=self.container_name, + partition_key=PartitionKey(path="/workflow_name"), + ) + + def _document_to_checkpoint(self, document: dict[str, Any]) -> WorkflowCheckpoint: + """Convert a Cosmos DB document back to a WorkflowCheckpoint. + + Strips Cosmos DB system properties (``_rid``, ``_self``, ``_etag``, + ``_attachments``, ``_ts``) before decoding. + """ + # Remove Cosmos DB system properties and the composite 'id' field + # (checkpoints use 'checkpoint_id', not 'id') + cosmos_keys = {"id", "_rid", "_self", "_etag", "_attachments", "_ts"} + cleaned = {k: v for k, v in document.items() if k not in cosmos_keys} + + decoded = decode_checkpoint_value(cleaned, allowed_types=self._allowed_types) + return WorkflowCheckpoint.from_dict(decoded) + + @staticmethod + def _make_document_id(workflow_name: str, checkpoint_id: str) -> str: + """Create a composite Cosmos DB document ID. + + Combines ``workflow_name`` and ``checkpoint_id`` to ensure global + uniqueness across partitions. + """ + return f"{workflow_name}_{checkpoint_id}" diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 1dc10cdf4a..4193b07014 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py new file mode 100644 index 0000000000..016220e693 --- /dev/null +++ b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py @@ -0,0 +1,737 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +import uuid +from collections.abc import AsyncIterator +from contextlib import suppress +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework._workflows._checkpoint import WorkflowCheckpoint +from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value +from agent_framework.exceptions import SettingNotFoundError, WorkflowCheckpointException +from azure.cosmos.aio import CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +import agent_framework_azure_cosmos._checkpoint_storage as checkpoint_storage_module +from agent_framework_azure_cosmos._checkpoint_storage import CosmosCheckpointStorage + +skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif( + any( + os.getenv(name, "") == "" + for name in ( + "AZURE_COSMOS_ENDPOINT", + "AZURE_COSMOS_KEY", + "AZURE_COSMOS_DATABASE_NAME", + "AZURE_COSMOS_CONTAINER_NAME", + ) + ), + reason=( + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_KEY, AZURE_COSMOS_DATABASE_NAME, and " + "AZURE_COSMOS_CONTAINER_NAME are required for Cosmos integration tests." + ), +) + + +def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]: + async def _iterator() -> AsyncIterator[Any]: + for item in items: + yield item + + return _iterator() + + +def _make_checkpoint( + workflow_name: str = "test-workflow", + checkpoint_id: str | None = None, + previous_checkpoint_id: str | None = None, + timestamp: str | None = None, +) -> WorkflowCheckpoint: + """Create a minimal WorkflowCheckpoint for testing.""" + return WorkflowCheckpoint( + workflow_name=workflow_name, + graph_signature_hash="abc123", + checkpoint_id=checkpoint_id or str(uuid.uuid4()), + previous_checkpoint_id=previous_checkpoint_id, + timestamp=timestamp or "2025-01-01T00:00:00+00:00", + state={"counter": 42}, + iteration_count=1, + ) + + +def _checkpoint_to_cosmos_document(checkpoint: WorkflowCheckpoint) -> dict[str, Any]: + """Simulate what a Cosmos DB document looks like after save.""" + encoded = encode_checkpoint_value(checkpoint.to_dict()) + doc: dict[str, Any] = { + "id": f"{checkpoint.workflow_name}_{checkpoint.checkpoint_id}", + "workflow_name": checkpoint.workflow_name, + **encoded, + # Cosmos system properties + "_rid": "abc", + "_self": "dbs/abc/colls/def/docs/ghi", + "_etag": '"00000000-0000-0000-0000-000000000000"', + "_attachments": "attachments/", + "_ts": 1700000000, + } + return doc + + +@pytest.fixture +def mock_container() -> MagicMock: + container = MagicMock() + container.query_items = MagicMock(return_value=_to_async_iter([])) + container.upsert_item = AsyncMock(return_value={}) + container.delete_item = AsyncMock(return_value={}) + return container + + +@pytest.fixture +def mock_cosmos_client(mock_container: MagicMock) -> MagicMock: + database_client = MagicMock() + database_client.create_container_if_not_exists = AsyncMock(return_value=mock_container) + + client = MagicMock() + client.create_database_if_not_exists = AsyncMock(return_value=database_client) + client.close = AsyncMock() + return client + + +# --- Tests for initialization --- + + +async def test_init_uses_provided_container_client(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + assert storage.database_name == "" + assert storage.container_name == "" + + +async def test_init_uses_provided_cosmos_client(mock_cosmos_client: MagicMock) -> None: + storage = CosmosCheckpointStorage( + cosmos_client=mock_cosmos_client, + database_name="db1", + container_name="checkpoints", + ) + assert storage.database_name == "db1" + assert storage.container_name == "checkpoints" + + +async def test_init_missing_required_settings_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AZURE_COSMOS_ENDPOINT", raising=False) + monkeypatch.delenv("AZURE_COSMOS_DATABASE_NAME", raising=False) + monkeypatch.delenv("AZURE_COSMOS_CONTAINER_NAME", raising=False) + monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) + + with pytest.raises(SettingNotFoundError, match="database_name"): + CosmosCheckpointStorage() + + +async def test_init_constructs_client_with_credential( + monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock +) -> None: + """Uses key-based auth when a key string is provided, otherwise falls back to Azure credential (RBAC).""" + mock_factory = MagicMock(return_value=mock_cosmos_client) + monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) + monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) + + # Simulate real-world pattern: use key if available, else RBAC credential + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + credential: Any = cosmos_key if cosmos_key else MagicMock() # MagicMock simulates DefaultAzureCredential() + + CosmosCheckpointStorage( + endpoint="https://account.documents.azure.com:443/", + credential=credential, + database_name="db1", + container_name="checkpoints", + ) + + mock_factory.assert_called_once() + kwargs = mock_factory.call_args.kwargs + assert kwargs["url"] == "https://account.documents.azure.com:443/" + assert kwargs["credential"] is credential + + +async def test_init_creates_database_and_container(mock_cosmos_client: MagicMock) -> None: + storage = CosmosCheckpointStorage( + cosmos_client=mock_cosmos_client, + database_name="db1", + container_name="custom-checkpoints", + ) + + await storage.list_checkpoint_ids(workflow_name="wf") + + mock_cosmos_client.create_database_if_not_exists.assert_awaited_once_with(id="db1") + database_client = mock_cosmos_client.create_database_if_not_exists.return_value + assert database_client.create_container_if_not_exists.await_count == 1 + kwargs = database_client.create_container_if_not_exists.await_args.kwargs + assert kwargs["id"] == "custom-checkpoints" + + +# --- Tests for save --- + + +async def test_save_upserts_document(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + checkpoint = _make_checkpoint() + + result = await storage.save(checkpoint) + + assert result == checkpoint.checkpoint_id + mock_container.upsert_item.assert_awaited_once() + document = mock_container.upsert_item.await_args.kwargs["body"] + assert document["id"] == f"test-workflow_{checkpoint.checkpoint_id}" + assert document["workflow_name"] == "test-workflow" + assert document["graph_signature_hash"] == "abc123" + assert document["state"]["counter"] == 42 + + +async def test_save_returns_checkpoint_id(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + checkpoint = _make_checkpoint(checkpoint_id="cp-123") + + result = await storage.save(checkpoint) + + assert result == "cp-123" + + +# --- Tests for load --- + + +async def test_load_returns_checkpoint(mock_container: MagicMock) -> None: + checkpoint = _make_checkpoint(checkpoint_id="cp-load") + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + loaded = await storage.load("cp-load") + + assert loaded.checkpoint_id == "cp-load" + assert loaded.workflow_name == "test-workflow" + assert loaded.graph_signature_hash == "abc123" + assert loaded.state["counter"] == 42 + + +async def test_load_nonexistent_raises(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"): + await storage.load("nonexistent-id") + + +async def test_load_queries_without_partition_key(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + with suppress(WorkflowCheckpointException): + await storage.load("cp-id") + + kwargs = mock_container.query_items.call_args.kwargs + assert "partition_key" not in kwargs + + +async def test_load_multiple_workflows_same_checkpoint_id_raises(mock_container: MagicMock) -> None: + cp1 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-a") + cp2 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-b") + mock_container.query_items.return_value = _to_async_iter([ + _checkpoint_to_cosmos_document(cp1), + _checkpoint_to_cosmos_document(cp2), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="Multiple checkpoints found"): + await storage.load("shared-id") + + +# --- Tests for list_checkpoints --- + + +async def test_list_checkpoints_returns_checkpoints_for_workflow(mock_container: MagicMock) -> None: + cp1 = _make_checkpoint(checkpoint_id="cp-1", timestamp="2025-01-01T00:00:00+00:00") + cp2 = _make_checkpoint(checkpoint_id="cp-2", timestamp="2025-01-02T00:00:00+00:00") + mock_container.query_items.return_value = _to_async_iter([ + _checkpoint_to_cosmos_document(cp1), + _checkpoint_to_cosmos_document(cp2), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert len(results) == 2 + assert results[0].checkpoint_id == "cp-1" + assert results[1].checkpoint_id == "cp-2" + + +async def test_list_checkpoints_uses_partition_key(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + await storage.list_checkpoints(workflow_name="my-workflow") + + kwargs = mock_container.query_items.call_args.kwargs + assert kwargs["partition_key"] == "my-workflow" + + +async def test_list_checkpoints_empty_returns_empty(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert results == [] + + +async def test_list_checkpoints_skips_malformed_documents(mock_container: MagicMock) -> None: + valid_cp = _make_checkpoint(checkpoint_id="cp-valid") + mock_container.query_items.return_value = _to_async_iter([ + {"id": "bad_doc", "workflow_name": "test-workflow", "not_a_checkpoint": True}, + _checkpoint_to_cosmos_document(valid_cp), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert len(results) == 1 + assert results[0].checkpoint_id == "cp-valid" + + +# --- Tests for delete --- + + +async def test_delete_existing_returns_true(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([ + {"id": "test-workflow_cp-del", "workflow_name": "test-workflow"}, + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.delete("cp-del") + + assert result is True + mock_container.delete_item.assert_awaited_once_with( + item="test-workflow_cp-del", + partition_key="test-workflow", + ) + + +async def test_delete_nonexistent_returns_false(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.delete("nonexistent") + + assert result is False + mock_container.delete_item.assert_not_awaited() + + +async def test_delete_cosmos_not_found_returns_false(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([ + {"id": "test-workflow_cp-del", "workflow_name": "test-workflow"}, + ]) + mock_container.delete_item = AsyncMock(side_effect=CosmosResourceNotFoundError) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.delete("cp-del") + + assert result is False + + +# --- Tests for get_latest --- + + +async def test_get_latest_returns_latest_checkpoint(mock_container: MagicMock) -> None: + cp = _make_checkpoint(checkpoint_id="cp-latest", timestamp="2025-06-01T00:00:00+00:00") + mock_container.query_items.return_value = _to_async_iter([ + _checkpoint_to_cosmos_document(cp), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.get_latest(workflow_name="test-workflow") + + assert result is not None + assert result.checkpoint_id == "cp-latest" + + +async def test_get_latest_returns_none_when_empty(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.get_latest(workflow_name="test-workflow") + + assert result is None + + +async def test_get_latest_uses_order_by_desc_with_limit(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + await storage.get_latest(workflow_name="test-workflow") + + kwargs = mock_container.query_items.call_args.kwargs + assert "ORDER BY c.timestamp DESC" in kwargs["query"] + assert "OFFSET 0 LIMIT 1" in kwargs["query"] + + +# --- Tests for list_checkpoint_ids --- + + +async def test_list_checkpoint_ids_returns_ids(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([ + {"checkpoint_id": "cp-1"}, + {"checkpoint_id": "cp-2"}, + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + ids = await storage.list_checkpoint_ids(workflow_name="test-workflow") + + assert ids == ["cp-1", "cp-2"] + + +async def test_list_checkpoint_ids_empty_returns_empty(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + ids = await storage.list_checkpoint_ids(workflow_name="test-workflow") + + assert ids == [] + + +# --- Tests for close and context manager --- + + +async def test_close_closes_owned_client(monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock) -> None: + mock_factory = MagicMock(return_value=mock_cosmos_client) + monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) + + storage = CosmosCheckpointStorage( + endpoint="https://account.documents.azure.com:443/", + credential="key-123", + database_name="db1", + container_name="checkpoints", + ) + + await storage.close() + + mock_cosmos_client.close.assert_awaited_once() + + +async def test_close_does_not_close_external_client(mock_cosmos_client: MagicMock) -> None: + storage = CosmosCheckpointStorage( + cosmos_client=mock_cosmos_client, + database_name="db1", + container_name="checkpoints", + ) + + await storage.close() + + mock_cosmos_client.close.assert_not_awaited() + + +async def test_context_manager_closes_owned_client( + monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock +) -> None: + mock_factory = MagicMock(return_value=mock_cosmos_client) + monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) + + async with CosmosCheckpointStorage( + endpoint="https://account.documents.azure.com:443/", + credential="key-123", + database_name="db1", + container_name="checkpoints", + ) as storage: + assert storage is not None + + mock_cosmos_client.close.assert_awaited_once() + + +async def test_context_manager_preserves_original_exception(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + + with ( + patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))), + pytest.raises(ValueError, match="inner error"), + ): + async with storage: + raise ValueError("inner error") + + +async def test_context_manager_reraises_close_error(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + + with ( + patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))), + pytest.raises(RuntimeError, match="close failed"), + ): + async with storage: + pass # no inner exception — close error should propagate + + +# --- Tests for save/load round-trip --- + + +async def test_round_trip_preserves_data(mock_container: MagicMock) -> None: + checkpoint = _make_checkpoint( + checkpoint_id="cp-roundtrip", + previous_checkpoint_id="cp-parent", + ) + checkpoint.state = {"key": "value", "nested": {"a": 1}} + checkpoint.metadata = {"superstep": 3} + checkpoint.iteration_count = 5 + + saved_doc: dict[str, Any] = {} + + async def capture_upsert(body: dict[str, Any]) -> dict[str, Any]: + saved_doc.update(body) + return body + + mock_container.upsert_item = AsyncMock(side_effect=capture_upsert) + + storage = CosmosCheckpointStorage(container_client=mock_container) + await storage.save(checkpoint) + + returned_doc = { + **saved_doc, + "_rid": "abc", + "_self": "dbs/abc/colls/def/docs/ghi", + "_etag": '"etag"', + "_attachments": "attachments/", + "_ts": 1700000000, + } + mock_container.query_items.return_value = _to_async_iter([returned_doc]) + + loaded = await storage.load("cp-roundtrip") + + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + assert loaded.previous_checkpoint_id == "cp-parent" + assert loaded.state == {"key": "value", "nested": {"a": 1}} + assert loaded.metadata == {"superstep": 3} + assert loaded.iteration_count == 5 + assert loaded.version == "1.0" + + +# --- Integration test --- + + +@pytest.mark.integration +@skip_if_cosmos_integration_tests_disabled +async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None: + endpoint = os.getenv("AZURE_COSMOS_ENDPOINT", "") + key = os.getenv("AZURE_COSMOS_KEY", "") + database_prefix = os.getenv("AZURE_COSMOS_DATABASE_NAME", "") + container_prefix = os.getenv("AZURE_COSMOS_CONTAINER_NAME", "") + unique = uuid.uuid4().hex[:8] + database_name = f"{database_prefix}-cp-{unique}" + container_name = f"{container_prefix}-cp-{unique}" + + async with CosmosClient(url=endpoint, credential=key) as cosmos_client: + await cosmos_client.create_database_if_not_exists(id=database_name) + + storage = CosmosCheckpointStorage( + cosmos_client=cosmos_client, + database_name=database_name, + container_name=container_name, + ) + + try: + # Save two checkpoints for the same workflow + cp1 = _make_checkpoint( + checkpoint_id="cp-int-1", + workflow_name="integration-wf", + timestamp="2025-01-01T00:00:00+00:00", + ) + cp2 = _make_checkpoint( + checkpoint_id="cp-int-2", + workflow_name="integration-wf", + previous_checkpoint_id="cp-int-1", + timestamp="2025-01-02T00:00:00+00:00", + ) + cp2.state = {"step": 2} + + await storage.save(cp1) + await storage.save(cp2) + + # Load by ID + loaded = await storage.load("cp-int-1") + assert loaded.checkpoint_id == "cp-int-1" + assert loaded.workflow_name == "integration-wf" + + # List all checkpoints for workflow + all_cps = await storage.list_checkpoints(workflow_name="integration-wf") + assert len(all_cps) == 2 + + # List checkpoint IDs + ids = await storage.list_checkpoint_ids(workflow_name="integration-wf") + assert "cp-int-1" in ids + assert "cp-int-2" in ids + + # Get latest + latest = await storage.get_latest(workflow_name="integration-wf") + assert latest is not None + assert latest.checkpoint_id == "cp-int-2" + assert latest.state == {"step": 2} + + # Delete + assert await storage.delete("cp-int-1") is True + assert await storage.delete("cp-int-1") is False + + remaining = await storage.list_checkpoint_ids(workflow_name="integration-wf") + assert remaining == ["cp-int-2"] + + # Cross-workflow isolation + other_cp = _make_checkpoint( + checkpoint_id="cp-other", + workflow_name="other-wf", + ) + await storage.save(other_cp) + wf_cps = await storage.list_checkpoints(workflow_name="integration-wf") + assert len(wf_cps) == 1 + assert wf_cps[0].checkpoint_id == "cp-int-2" + + finally: + with suppress(Exception): + await cosmos_client.delete_database(database_name) + + +# --- Tests for allowed_checkpoint_types --- + + +@dataclass +class _AppState: + """Application-defined state type used to test allowed_checkpoint_types.""" + + label: str + count: int + + +_APP_STATE_TYPE_KEY = f"{_AppState.__module__}:{_AppState.__qualname__}" + + +def _make_checkpoint_with_state(state: dict[str, Any]) -> WorkflowCheckpoint: + """Create a checkpoint with custom state for serialization tests.""" + return WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="abc123", + timestamp="2025-01-01T00:00:00+00:00", + state=state, + iteration_count=1, + ) + + +async def test_init_accepts_allowed_checkpoint_types(mock_container: MagicMock) -> None: + """CosmosCheckpointStorage.__init__ accepts allowed_checkpoint_types.""" + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=["some.module:SomeType"], + ) + assert storage is not None + + +async def test_load_allows_builtin_safe_types(mock_container: MagicMock) -> None: + """Built-in safe types load without opt-in via allowed_checkpoint_types.""" + from datetime import datetime, timezone + + checkpoint = _make_checkpoint_with_state({ + "ts": datetime(2025, 1, 1, tzinfo=timezone.utc), + "tags": {1, 2, 3}, + }) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["ts"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["tags"] == {1, 2, 3} + + +async def test_load_blocks_unlisted_app_type(mock_container: MagicMock) -> None: + """Application types are blocked when not listed in allowed_checkpoint_types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + await storage.load(checkpoint.checkpoint_id) + + +async def test_load_allows_listed_app_type(mock_container: MagicMock) -> None: + """Application types are allowed when listed in allowed_checkpoint_types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=7)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=[_APP_STATE_TYPE_KEY], + ) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert isinstance(loaded.state["data"], _AppState) + assert loaded.state["data"].label == "ok" + assert loaded.state["data"].count == 7 + + +async def test_list_checkpoints_blocks_unlisted_app_type(mock_container: MagicMock) -> None: + """list_checkpoints skips documents with unlisted application types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + # The document is skipped (logged as warning) because the type is blocked + assert len(results) == 0 + + +async def test_list_checkpoints_allows_listed_app_type(mock_container: MagicMock) -> None: + """list_checkpoints decodes documents with listed application types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=3)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=[_APP_STATE_TYPE_KEY], + ) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert len(results) == 1 + assert isinstance(results[0].state["data"], _AppState) + + +async def test_get_latest_blocks_unlisted_app_type(mock_container: MagicMock) -> None: + """get_latest raises when the checkpoint contains an unlisted application type.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + await storage.get_latest(workflow_name="test-workflow") + + +async def test_get_latest_allows_listed_app_type(mock_container: MagicMock) -> None: + """get_latest decodes checkpoints with listed application types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="latest", count=9)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=[_APP_STATE_TYPE_KEY], + ) + result = await storage.get_latest(workflow_name="test-workflow") + + assert result is not None + assert isinstance(result.state["data"], _AppState) + assert result.state["data"].label == "latest" diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 9d92e51d09..8445c08ed9 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.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index ac81bc66d2..66996ad9ba 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 2cdce32a0d..ae0a795e0d 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.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 7740d553a3..4b2e6059fc 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index c9f6ac7b01..90bb6b107f 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.0b260402" +version = "1.0.0b260409" 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 = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 5c99dbaa60..d371291b21 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -486,8 +486,8 @@ YAML_KV_RE = re.compile( ) # Validates skill names: lowercase letters, numbers, hyphens only; -# must not start or end with a hyphen. -VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$") +# must not start or end with a hyphen, and must not contain consecutive hyphens. +VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$") # Default system prompt template for advertising available skills to the model. # Use {skills} as the placeholder for the generated skills XML list. @@ -1156,7 +1156,8 @@ def _validate_skill_metadata( if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name): return ( f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, " - "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen." + "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen " + "or contain consecutive hyphens." ) if not description or not description.strip(): @@ -1241,6 +1242,17 @@ def _read_and_parse_skill_file( return None name, description = result + + dir_name = Path(skill_dir_path).name + if name != dir_name: + logger.error( + "SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.", + skill_file, + name, + dir_name, + ) + return None + return name, description, content diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 736474de60..584c1f0110 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1957,6 +1957,8 @@ class ContinuationToken(TypedDict): def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None: if response_format is None: return None + if not text: + return None if isinstance(response_format, type) and issubclass(response_format, BaseModel): return response_format.model_validate_json(text) if isinstance(response_format, Mapping): @@ -2814,6 +2816,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): cleanup_hooks if cleanup_hooks is not None else [] ) self._cleanup_run: bool = False + self._stream_error: Exception | None = None self._inner_stream: ResponseStream[Any, Any] | None = None self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None self._wrap_inner: bool = False @@ -2946,8 +2949,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): await self._run_cleanup_hooks() await self.get_final_response() raise - except Exception: - await self._run_cleanup_hooks() + except Exception as exc: + self._stream_error = exc + try: + await self._run_cleanup_hooks() + finally: + self._stream_error = None raise if self._map_update is not None: update = self._map_update(update) # type: ignore[assignment] diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 60c5ec3774..2fd3f35213 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -119,15 +119,11 @@ class WorkflowAgent(BaseAgent): if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types): raise ValueError("Workflow's start executor cannot handle list[Message]") - resolved_context_providers = list(context_providers) if context_providers is not None else [] - if not resolved_context_providers: - resolved_context_providers.append(InMemoryHistoryProvider()) - super().__init__( id=id, name=name, description=description, - context_providers=resolved_context_providers, + context_providers=context_providers, **kwargs, ) self._workflow: Workflow = workflow @@ -261,6 +257,15 @@ class WorkflowAgent(BaseAgent): An AgentResponse representing the workflow execution results. """ input_messages = normalize_messages_input(messages) + + if ( + not any( + provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider) + ) + and session is not None + ): + self.context_providers.append(InMemoryHistoryProvider()) + provider_session = session if provider_session is None and self.context_providers: provider_session = AgentSession() @@ -332,6 +337,15 @@ class WorkflowAgent(BaseAgent): AgentResponseUpdate objects representing the workflow execution progress. """ input_messages = normalize_messages_input(messages) + + if ( + not any( + provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider) + ) + and session is not None + ): + self.context_providers.append(InMemoryHistoryProvider()) + provider_session = session if provider_session is None and self.context_providers: provider_session = AgentSession() diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 986b086fab..2bcc6d355e 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -339,6 +339,13 @@ class AgentExecutor(Executor): ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) ) + if not self._cache: + logger.warning( + "AgentExecutor %s: Running agent with empty message cache. " + "This could lead to service error for some LLM providers.", + self.id, + ) + run_agent = cast(Callable[..., Awaitable[AgentResponse[Any]]], self._agent.run) response = await run_agent( self._cache, @@ -371,6 +378,13 @@ class AgentExecutor(Executor): ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) ) + if not self._cache: + logger.warning( + "AgentExecutor %s: Running agent with empty message cache. " + "This could lead to service error for some LLM providers.", + self.id, + ) + updates: list[AgentResponseUpdate] = [] streamed_user_input_requests: list[Content] = [] run_agent_stream = cast(Callable[..., ResponseStream[AgentResponseUpdate, AgentResponse[Any]]], self._agent.run) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index b442f445f8..f9a940a7db 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -244,14 +244,39 @@ class FileCheckpointStorage: is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows for human-readable checkpoint files while preserving the ability to store complex Python objects. - SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints - from trusted sources. Loading a malicious checkpoint file can execute arbitrary code. + By default, checkpoint deserialization is restricted to a built-in set of safe + Python types (primitives, datetime, uuid, ...) and all ``agent_framework`` + internal types. To allow additional application-specific types, pass them via + the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format. + + Example:: + + storage = FileCheckpointStorage( + "/tmp/checkpoints", + allowed_checkpoint_types=[ + "my_app.models:MyState", + ], + ) """ - def __init__(self, storage_path: str | Path): - """Initialize the file storage.""" + def __init__( + self, + storage_path: str | Path, + *, + allowed_checkpoint_types: list[str] | None = None, + ) -> None: + """Initialize the file storage. + + Args: + storage_path: Directory path where checkpoint files will be stored. + allowed_checkpoint_types: Additional types (beyond the built-in safe set + and framework types) that are permitted during checkpoint + deserialization. Each entry should be a ``"module:qualname"`` + string (e.g., ``"my_app.models:MyState"``). + """ self.storage_path = Path(storage_path) self.storage_path.mkdir(parents=True, exist_ok=True) + self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or []) logger.info(f"Initialized file checkpoint storage at {self.storage_path}") def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path: @@ -327,7 +352,7 @@ class FileCheckpointStorage: from ._checkpoint_encoding import decode_checkpoint_value try: - decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint) + decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint, allowed_types=self._allowed_types) except WorkflowCheckpointException: raise checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict) @@ -352,7 +377,9 @@ class FileCheckpointStorage: encoded_checkpoint = json.load(f) from ._checkpoint_encoding import decode_checkpoint_value - decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint) + decoded_checkpoint_dict = decode_checkpoint_value( + encoded_checkpoint, allowed_types=self._allowed_types + ) checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict) if checkpoint.workflow_name == workflow_name: checkpoints.append(checkpoint) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index 85f9327663..a25a08c66a 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -1,14 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -from __future__ import annotations - -import base64 -import logging -import pickle # nosec # noqa: S403 -from typing import Any - -from ..exceptions import WorkflowCheckpointException - """Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data. This hybrid approach provides: @@ -16,10 +7,23 @@ This hybrid approach provides: - Full Python object fidelity via pickle for data values (non-JSON-native types) - Base64 encoding to embed binary pickle data in JSON strings -SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints -from trusted sources. Loading a malicious checkpoint file can execute arbitrary code. +When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a +``RestrictedUnpickler`` is used that limits which classes may be instantiated +during deserialization. The default built-in safe set covers common Python +value types (primitives, datetime, uuid, ...) and all ``agent_framework`` +internal types. Callers can extend the set by passing additional +``"module:qualname"`` strings. """ +from __future__ import annotations + +import base64 +import io +import logging +import pickle # nosec # noqa: S403 +from typing import Any + +from ..exceptions import WorkflowCheckpointException logger = logging.getLogger("agent_framework") @@ -30,6 +34,82 @@ _TYPE_MARKER = "__type__" # Types that are natively JSON-serializable and don't need pickling _JSON_NATIVE_TYPES = (str, int, float, bool, type(None)) +# Module prefix for framework-internal types that are always allowed +_FRAMEWORK_MODULE_PREFIX = "agent_framework." + +# Built-in types considered safe for checkpoint deserialization. +# Each entry is a ``module:qualname`` string matching the format produced by +# :func:`_type_to_key`. These are the classes for which pickle's +# ``find_class`` will be called when unpickling common Python value types. +_BUILTIN_ALLOWED_TYPE_KEYS: frozenset[str] = frozenset({ + # builtins + "builtins:object", + "builtins:complex", + "builtins:range", + "builtins:slice", + "builtins:int", + "builtins:float", + "builtins:str", + "builtins:bytes", + "builtins:bytearray", + "builtins:bool", + "builtins:set", + "builtins:frozenset", + "builtins:list", + "builtins:dict", + "builtins:tuple", + "builtins:type", + # getattr is used by pickle to reconstruct enum members + "builtins:getattr", + # copyreg helpers used by pickle for object reconstruction + "copyreg:_reconstructor", + # datetime + "datetime:datetime", + "datetime:date", + "datetime:time", + "datetime:timedelta", + "datetime:timezone", + # uuid + "uuid:UUID", + # decimal + "decimal:Decimal", + # collections + "collections:OrderedDict", + "collections:defaultdict", + "collections:deque", +}) + + +class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301 + """Unpickler that restricts which classes may be instantiated. + + Only classes whose ``module:qualname`` key appears in the combined allow + set (built-in safe types + framework types + caller-specified extras) are + permitted. All other classes raise :class:`pickle.UnpicklingError`. + """ + + def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None: + super().__init__(io.BytesIO(data)) + self._allowed_types = allowed_types + + def find_class(self, module: str, name: str) -> type: + type_key = f"{module}:{name}" + + if ( + type_key in _BUILTIN_ALLOWED_TYPE_KEYS + or type_key in self._allowed_types + or module.startswith(_FRAMEWORK_MODULE_PREFIX) + ): + return super().find_class(module, name) # type: ignore[no-any-return] # nosec + + raise pickle.UnpicklingError( + f"Checkpoint deserialization blocked for type '{type_key}'. " + f"To allow this type, either include its 'module:qualname' key in the " + f"'allowed_types' set passed to 'decode_checkpoint_value', or add it to " + f"'allowed_checkpoint_types' on your checkpoint storage " + f"(for example, 'FileCheckpointStorage.allowed_checkpoint_types')." + ) + def encode_checkpoint_value(value: Any) -> Any: """Encode a Python value for checkpoint storage. @@ -48,29 +128,51 @@ def encode_checkpoint_value(value: Any) -> Any: return _encode(value) -def decode_checkpoint_value(value: Any) -> Any: +def decode_checkpoint_value(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any: """Decode a value from checkpoint storage. Reverses the encoding performed by encode_checkpoint_value. Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled. - WARNING: Only call this with trusted data. Pickle can execute - arbitrary code during deserialization. The post-unpickle type verification - detects accidental corruption or type mismatches, but cannot prevent - arbitrary code execution from malicious pickle payloads. - Args: value: A JSON-deserialized value from checkpoint storage. + allowed_types: If not ``None``, restrict pickle deserialization to the + built-in safe set, framework types, and the types listed here. + Each entry should use ``"module:qualname"`` format — that is, the + dotted module path followed by a colon and the class + ``__qualname__``. For example, given a user-defined class:: + + # my_app/models.py + class MyState: ... + + the corresponding entry would be ``"my_app.models:MyState"``:: + + decode_checkpoint_value( + data, + allowed_types=frozenset({"my_app.models:MyState"}), + ) + + When using :class:`FileCheckpointStorage`, pass the same strings + via ``allowed_checkpoint_types``:: + + storage = FileCheckpointStorage( + "/tmp/checkpoints", + allowed_checkpoint_types=["my_app.models:MyState"], + ) + + If ``None``, no restriction is applied (backward-compatible + behavior). Returns: The original Python value. Raises: WorkflowCheckpointException: If the unpickled object's type doesn't match - the recorded type, indicating corruption, or if the base64/pickle - data is malformed. + the recorded type, indicating corruption, if the base64/pickle + data is malformed, or if a disallowed type is encountered during + restricted deserialization. """ - return _decode(value) + return _decode(value, allowed_types=allowed_types) def _encode(value: Any) -> Any: @@ -94,7 +196,7 @@ def _encode(value: Any) -> Any: } -def _decode(value: Any) -> Any: +def _decode(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any: """Recursively decode a value from JSON storage.""" # JSON-native types pass through if isinstance(value, _JSON_NATIVE_TYPES): @@ -104,16 +206,16 @@ def _decode(value: Any) -> Any: if isinstance(value, dict): # Pickled value: decode, unpickle, and verify type if _PICKLE_MARKER in value and _TYPE_MARKER in value: - obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore + obj = _base64_to_unpickle(value[_PICKLE_MARKER], allowed_types=allowed_types) # type: ignore _verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore return obj # Regular dict: decode values recursively - return {k: _decode(v) for k, v in value.items()} # type: ignore + return {k: _decode(v, allowed_types=allowed_types) for k, v in value.items()} # type: ignore # Handle encoded lists if isinstance(value, list): - return [_decode(item) for item in value] # type: ignore + return [_decode(item, allowed_types=allowed_types) for item in value] # type: ignore return value @@ -148,15 +250,23 @@ def _pickle_to_base64(value: Any) -> str: return base64.b64encode(pickled).decode("ascii") -def _base64_to_unpickle(encoded: str) -> Any: +def _base64_to_unpickle(encoded: str, *, allowed_types: frozenset[str] | None = None) -> Any: """Decode base64 string and unpickle. + Args: + encoded: Base64-encoded pickle data. + allowed_types: If not ``None``, use restricted unpickling that only + permits built-in safe types, framework types, and the specified + extra types. + Raises: - WorkflowCheckpointException: If the base64 data is corrupted or the pickle - format is incompatible. + WorkflowCheckpointException: If the base64 data is corrupted, the pickle + format is incompatible, or a disallowed type is encountered. """ try: pickled = base64.b64decode(encoded.encode("ascii")) + if allowed_types is not None: + return _RestrictedUnpickler(pickled, allowed_types).load() return pickle.loads(pickled) # nosec # noqa: S301 except Exception as exc: raise WorkflowCheckpointException(f"Failed to decode pickled checkpoint data: {exc}") from exc diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index d2bb2ac598..f57102b2bc 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -728,9 +728,22 @@ def _validate_handler_signature( # AttributeError, or RecursionError), so registration failures are easier to diagnose. try: type_hints = typing.get_type_hints(func) - except Exception: + except (NameError, AttributeError, RecursionError): type_hints = {p.name: p.annotation for p in params} + message_type = type_hints.get(message_param.name, message_param.annotation) + if message_type == inspect.Parameter.empty: + message_type = None + + # Reject unresolved TypeVar in message annotation -- these are not supported + # for workflow type validation and must be replaced with concrete types. + if not skip_message_annotation and isinstance(message_type, TypeVar): + raise ValueError( + f"Handler {func.__name__} has an unresolved TypeVar '{message_type}' as its message type annotation. " + "Generic TypeVar annotations are not supported for workflow type validation. " + "Use @handler(input=, output=) to specify explicit types." + ) + # Validate ctx parameter is WorkflowContext and extract type args ctx_param = params[2] ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation) @@ -744,10 +757,6 @@ def _validate_handler_signature( ctx_annotation, f"parameter '{ctx_param.name}'", "Handler" ) - message_type = type_hints.get(message_param.name, message_param.annotation) - if message_type == inspect.Parameter.empty: - message_type = None - return message_type, ctx_annotation, output_types, workflow_output_types diff --git a/python/packages/core/agent_framework/_workflows/_function_executor.py b/python/packages/core/agent_framework/_workflows/_function_executor.py index 326145b6c4..038d12cf89 100644 --- a/python/packages/core/agent_framework/_workflows/_function_executor.py +++ b/python/packages/core/agent_framework/_workflows/_function_executor.py @@ -325,11 +325,26 @@ def _validate_function_signature( if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty: raise ValueError(f"Function instance {func.__name__} must have a type annotation for the message parameter") - type_hints = typing.get_type_hints(func) + # Resolve string annotations from `from __future__ import annotations`. + # Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs, + # AttributeError, or RecursionError), so registration failures are easier to diagnose. + try: + type_hints = typing.get_type_hints(func) + except (NameError, AttributeError, RecursionError): + type_hints = {p.name: p.annotation for p in params} message_type = type_hints.get(message_param.name, message_param.annotation) if message_type == inspect.Parameter.empty: message_type = None + # Reject unresolved TypeVar in message annotation -- these are not supported + # for workflow type validation and must be replaced with concrete types. + if not skip_message_annotation and isinstance(message_type, typing.TypeVar): + raise ValueError( + f"Function instance {func.__name__} has an unresolved TypeVar '{message_type}' as its message type " + "annotation. Generic TypeVar annotations are not supported for workflow type validation. " + "Use @executor(input=, output=) to specify explicit types." + ) + # Check if there's a context parameter if len(params) == 2: ctx_param = params[1] diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py index c548e76e53..d58d9b99a9 100644 --- a/python/packages/core/agent_framework/_workflows/_runner.py +++ b/python/packages/core/agent_framework/_workflows/_runner.py @@ -186,6 +186,12 @@ class Runner: """Inner loop to deliver a single message through an edge runner.""" return await edge_runner.send_message(message, self._state, self._ctx) + async def _deliver_messages_for_edge_runner(edge_runner: EdgeRunner) -> None: + # Preserve message order per edge runner (and therefore per routed target path) + # while still allowing parallelism across different edge runners. + for message in source_messages: + await _deliver_message_inner(edge_runner, message) + # Route all messages through normal workflow edges associated_edge_runners = self._edge_runner_map.get(source_executor_id, []) if not associated_edge_runners: @@ -193,12 +199,6 @@ class Runner: logger.debug(f"No outgoing edges found for executor {source_executor_id}; dropping messages.") return - async def _deliver_messages_for_edge_runner(edge_runner: EdgeRunner) -> None: - # Preserve message order per edge runner (and therefore per routed target path) - # while still allowing parallelism across different edge runners. - for message in source_messages: - await _deliver_message_inner(edge_runner, message) - tasks = [_deliver_messages_for_edge_runner(edge_runner) for edge_runner in associated_edge_runners] await asyncio.gather(*tasks) diff --git a/python/packages/core/agent_framework/ag_ui/__init__.py b/python/packages/core/agent_framework/ag_ui/__init__.py index 8e1385a26c..91754e01b4 100644 --- a/python/packages/core/agent_framework/ag_ui/__init__.py +++ b/python/packages/core/agent_framework/ag_ui/__init__.py @@ -7,10 +7,13 @@ This module lazily re-exports objects from: Supported classes and functions: - AgentFrameworkAgent +- AgentFrameworkWorkflow - AGUIChatClient - AGUIEventConverter - AGUIHttpService - add_agent_framework_fastapi_endpoint +- state_update +- __version__ """ import importlib @@ -23,6 +26,10 @@ _IMPORTS = [ "AgentFrameworkWorkflow", "add_agent_framework_fastapi_endpoint", "AGUIChatClient", + "AGUIEventConverter", + "AGUIHttpService", + "state_update", + "__version__", ] diff --git a/python/packages/core/agent_framework/ag_ui/__init__.pyi b/python/packages/core/agent_framework/ag_ui/__init__.pyi index 17a5b3a4db..1f6636ae81 100644 --- a/python/packages/core/agent_framework/ag_ui/__init__.pyi +++ b/python/packages/core/agent_framework/ag_ui/__init__.pyi @@ -8,6 +8,7 @@ from agent_framework_ag_ui import ( AGUIHttpService, __version__, add_agent_framework_fastapi_endpoint, + state_update, ) __all__ = [ @@ -18,4 +19,5 @@ __all__ = [ "AgentFrameworkWorkflow", "__version__", "add_agent_framework_fastapi_endpoint", + "state_update", ] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 8d2eb05136..6998e5994f 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1323,6 +1323,12 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): from ._types import ChatResponse try: + if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage] + # Stream errored; skip get_final_response() to avoid firing + # result hooks such as after_run context providers on error + # paths. Capture the error on the span before returning. + capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage] + return response: ChatResponse[Any] = await result_stream.get_final_response() duration = duration_state.get("duration") response_attributes = _get_response_attributes(attributes, response) @@ -1579,6 +1585,12 @@ class AgentTelemetryLayer: from ._types import AgentResponse try: + if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage] + # Stream errored; skip get_final_response() to avoid firing + # result hooks such as after_run context providers on error + # paths. Capture the error on the span before returning. + capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage] + return response: AgentResponse[Any] = await result_stream.get_final_response() duration = duration_state.get("duration") response_attributes = _get_response_attributes( diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index e4aa1f5e40..e28245d6e7 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.0" +version = "1.0.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -34,14 +34,13 @@ all = [ "mcp>=1.24.0,<2", "agent-framework-a2a", "agent-framework-ag-ui", + "agent-framework-anthropic", "agent-framework-azure-ai-search", "agent-framework-azure-cosmos", - "agent-framework-anthropic", - "agent-framework-openai", - "agent-framework-claude", "agent-framework-azurefunctions", "agent-framework-bedrock", "agent-framework-chatkit", + "agent-framework-claude", "agent-framework-copilotstudio", "agent-framework-declarative", "agent-framework-devui", @@ -52,6 +51,7 @@ all = [ "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", + "agent-framework-openai", "agent-framework-orchestrations", "agent-framework-purview", "agent-framework-redis", diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 16cae57dcd..225b12d9a1 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -296,6 +296,15 @@ class TestDiscoverAndLoadSkills: skills = _discover_file_skills([str(tmp_path)]) assert len(skills) == 0 + def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "wrong-dir-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8" + ) + skills = _discover_file_skills([str(tmp_path)]) + assert len(skills) == 0 + def test_deduplicates_skill_names(self, tmp_path: Path) -> None: dir1 = tmp_path / "dir1" dir2 = tmp_path / "dir2" @@ -904,6 +913,11 @@ class TestSkill: provider = SkillsProvider(skills=[invalid_skill]) assert len(provider._skills) == 0 + def test_name_with_consecutive_hyphens_skipped(self) -> None: + invalid_skill = Skill(name="consecutive--hyphens", description="A skill.", content="Body") + provider = SkillsProvider(skills=[invalid_skill]) + assert len(provider._skills) == 0 + def test_name_too_long_skipped(self) -> None: invalid_skill = Skill(name="a" * 65, description="A skill.", content="Body") provider = SkillsProvider(skills=[invalid_skill]) @@ -1421,6 +1435,11 @@ class TestValidateSkillMetadata: assert result is not None assert "invalid name" in result + def test_name_with_consecutive_hyphens(self) -> None: + result = _validate_skill_metadata("consecutive--hyphens", "desc", "source") + assert result is not None + assert "invalid name" in result + def test_single_char_name(self) -> None: assert _validate_skill_metadata("a", "desc", "source") is None @@ -1526,6 +1545,15 @@ class TestReadAndParseSkillFile: result = _read_and_parse_skill_file(str(skill_dir)) assert result is None + def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "wrong-dir-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8" + ) + result = _read_and_parse_skill_file(str(skill_dir)) + assert result is None + # --------------------------------------------------------------------------- # Tests: _create_resource_element diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 23c7dd8cd6..cf945dae0e 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -39,6 +39,7 @@ from agent_framework._types import ( _get_data_bytes, _get_data_bytes_as_str, _parse_content_list, + _parse_structured_response_value, _validate_uri, add_usage_details, validate_tool_mode, @@ -813,6 +814,32 @@ def test_chat_response_with_mapping_response_format() -> None: assert response.value["response"] == "Hello" +def test_parse_structured_response_value_empty_text_with_pydantic_model() -> None: + """Empty text should return None instead of raising when response_format is a Pydantic model.""" + result = _parse_structured_response_value("", OutputModel) + assert result is None + + +def test_parse_structured_response_value_empty_text_with_mapping() -> None: + """Empty text should return None instead of raising when response_format is a mapping.""" + result = _parse_structured_response_value("", {"type": "object"}) + assert result is None + + +def test_chat_response_value_with_empty_text_and_response_format() -> None: + """ChatResponse.value should return None when text is empty and response_format is set.""" + message = Message(role="assistant", contents=[""]) + response = ChatResponse(messages=message, response_format=OutputModel) + assert response.value is None + + +def test_agent_response_value_with_empty_text_and_response_format() -> None: + """AgentResponse.value should return None when text is empty and response_format is set.""" + message = Message(role="assistant", contents=[""]) + response = AgentResponse(messages=message, response_format=OutputModel) + assert response.value is None + + def test_chat_response_value_raises_on_invalid_schema(): """Test that value property raises ValidationError with field constraint details.""" diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index a32489acc0..e395655afa 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -1048,7 +1048,10 @@ async def test_file_checkpoint_storage_roundtrip_datetime(): async def test_file_checkpoint_storage_roundtrip_dataclass(): """Test that dataclass objects roundtrip correctly via pickle encoding.""" with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestCustomData"], + ) custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"]) @@ -1238,7 +1241,10 @@ async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data(): async def test_file_checkpoint_storage_roundtrip_pending_request_info_events(): """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly.""" with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestToolApprovalRequest"], + ) # Create request_info events using the proper WorkflowEvent factory event1 = WorkflowEvent.request_info( @@ -1300,7 +1306,13 @@ async def test_file_checkpoint_storage_roundtrip_pending_request_info_events(): async def test_file_checkpoint_storage_roundtrip_full_checkpoint(): """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types.""" with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_checkpoint:_TestApprovalRequest", + "tests.workflow.test_checkpoint:_TestExecutorState", + ], + ) # Create proper WorkflowMessage objects msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t") diff --git a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py new file mode 100644 index 0000000000..c70d8c85c3 --- /dev/null +++ b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py @@ -0,0 +1,218 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for restricted checkpoint deserialization. + +These tests verify that persisted checkpoint loading uses a restricted +unpickler by default: +- Arbitrary callables are blocked during deserialization +- __reduce__ payloads cannot execute code during deserialization +- FileCheckpointStorage accepts allowed_checkpoint_types for extension +- User-defined types are blocked unless explicitly allowed +- Built-in safe types and framework types are always allowed +""" + +import base64 +import os +import pickle +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone + +import pytest + +from agent_framework import WorkflowCheckpointException +from agent_framework._workflows._checkpoint import FileCheckpointStorage +from agent_framework._workflows._checkpoint_encoding import ( + _PICKLE_MARKER, + _TYPE_MARKER, + decode_checkpoint_value, + encode_checkpoint_value, +) + + +class MaliciousPayload: + """A class whose __reduce__ executes code during unpickling.""" + + def __reduce__(self): + return (os.getpid, ()) + + +def test_restricted_decode_blocks_arbitrary_callable(): + """Restricted decoding blocks arbitrary module-level callables.""" + pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL) + encoded_b64 = base64.b64encode(pickled).decode("ascii") + + checkpoint_value = { + _PICKLE_MARKER: encoded_b64, + _TYPE_MARKER: "builtins:builtin_function_or_method", + } + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(checkpoint_value, allowed_types=frozenset()) + + +def test_restricted_decode_blocks_reduce_payload(): + """__reduce__-based payloads are blocked before code can execute.""" + payload = MaliciousPayload() + pickled = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL) + encoded_b64 = base64.b64encode(pickled).decode("ascii") + + checkpoint_value = { + _PICKLE_MARKER: encoded_b64, + _TYPE_MARKER: f"{MaliciousPayload.__module__}:{MaliciousPayload.__qualname__}", + } + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(checkpoint_value, allowed_types=frozenset()) + + +def test_restricted_decode_prevents_code_execution(): + """Restricted deserialization prevents __reduce__ code from running.""" + with tempfile.TemporaryDirectory() as tmpdir: + marker_file = os.path.join(tmpdir, "checkpoint_test_marker") + + payload_bytes = pickle.dumps( + type( + "Exploit", + (), + { + "__reduce__": lambda self: ( + eval, + (f"open({marker_file!r}, 'w').write('pwned')",), + ) + }, + )(), + protocol=pickle.HIGHEST_PROTOCOL, + ) + encoded_b64 = base64.b64encode(payload_bytes).decode("ascii") + + checkpoint_value = { + _PICKLE_MARKER: encoded_b64, + _TYPE_MARKER: "builtins:int", + } + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(checkpoint_value, allowed_types=frozenset()) + + assert not os.path.exists(marker_file), ( + "Restricted unpickler should have prevented code execution, but the marker file was created." + ) + + +def test_file_checkpoint_storage_accepts_allowed_types(): + """FileCheckpointStorage.__init__ accepts allowed_checkpoint_types.""" + with tempfile.TemporaryDirectory() as tmpdir: + storage = FileCheckpointStorage( + tmpdir, + allowed_checkpoint_types=["some.module:SomeType"], + ) + assert storage is not None + + +@dataclass +class _AllowedTestState: + """Test dataclass that will be explicitly allowed.""" + + name: str + value: int + + +def test_restricted_decode_blocks_unlisted_user_type(): + """User-defined types are blocked when not in allowed_checkpoint_types.""" + original = _AllowedTestState(name="test", value=42) + encoded = encode_checkpoint_value(original) + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(encoded, allowed_types=frozenset()) + + +def test_restricted_decode_allows_listed_user_type(): + """User-defined types are allowed when listed in allowed_types.""" + original = _AllowedTestState(name="test", value=42) + encoded = encode_checkpoint_value(original) + + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + decoded = decode_checkpoint_value(encoded, allowed_types=frozenset({type_key})) + + assert isinstance(decoded, _AllowedTestState) + assert decoded.name == "test" + assert decoded.value == 42 + + +def test_restricted_decode_allows_builtin_safe_types(): + """Built-in safe types (datetime, set, etc.) are always allowed.""" + test_values = [ + datetime(2025, 1, 1, tzinfo=timezone.utc), + {1, 2, 3}, + frozenset({4, 5, 6}), + (1, "two", 3.0), + complex(1, 2), + ] + for original in test_values: + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded, allowed_types=frozenset()) + assert decoded == original + + +def test_unrestricted_decode_allows_arbitrary_types(): + """Without allowed_types, decode_checkpoint_value remains unrestricted.""" + original = _AllowedTestState(name="test", value=42) + encoded = encode_checkpoint_value(original) + + decoded = decode_checkpoint_value(encoded) + + assert isinstance(decoded, _AllowedTestState) + assert decoded.name == "test" + + +async def test_file_storage_blocks_unlisted_user_type(): + """FileCheckpointStorage blocks user types not in allowed_checkpoint_types.""" + from agent_framework import WorkflowCheckpoint + + with tempfile.TemporaryDirectory() as tmpdir: + # Save with a storage that allows the type + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + save_storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test", + graph_signature_hash="hash", + state={"data": _AllowedTestState(name="test", value=1)}, + ) + await save_storage.save(checkpoint) + + # Load with a storage that does NOT allow the type + load_storage = FileCheckpointStorage(tmpdir) + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + await load_storage.load(checkpoint.checkpoint_id) + + +async def test_file_storage_allows_listed_user_type(): + """FileCheckpointStorage allows user types listed in allowed_checkpoint_types.""" + from agent_framework import WorkflowCheckpoint + + with tempfile.TemporaryDirectory() as tmpdir: + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test", + graph_signature_hash="hash", + state={"data": _AllowedTestState(name="allowed", value=99)}, + ) + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert isinstance(loaded.state["data"], _AllowedTestState) + assert loaded.state["data"].name == "allowed" + assert loaded.state["data"].value == 99 + + +def test_restricted_unpickler_raises_pickle_error(): + """_RestrictedUnpickler.find_class raises pickle.UnpicklingError, not a framework exception.""" + from agent_framework._workflows._checkpoint_encoding import _RestrictedUnpickler + + pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL) + + unpickler = _RestrictedUnpickler(pickled, frozenset()) + with pytest.raises(pickle.UnpicklingError, match="deserialization blocked"): + unpickler.load() diff --git a/python/packages/core/tests/workflow/test_executor.py b/python/packages/core/tests/workflow/test_executor.py index e6be2e5bd0..df742b76f6 100644 --- a/python/packages/core/tests/workflow/test_executor.py +++ b/python/packages/core/tests/workflow/test_executor.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from dataclasses import dataclass +from typing import Generic, TypeVar import pytest from typing_extensions import Never @@ -919,3 +920,87 @@ class TestHandlerExplicitTypes: # endregion: Tests for @handler decorator with explicit input_type and output_type + + +# region Tests for unresolved TypeVar rejection in handler registration + +_T = TypeVar("_T") + + +def test_handler_rejects_unresolved_typevar_in_message_annotation(): + """Test that @handler raises ValueError when the message parameter is an unresolved TypeVar.""" + + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class GenericEcho(Executor, Generic[_T]): + @handler + async def echo(self, message: _T, ctx: WorkflowContext) -> None: + pass + + +_BT = TypeVar("_BT", bound=str) + + +def test_handler_rejects_bounded_typevar_in_message_annotation(): + """Test that @handler raises ValueError for a bounded TypeVar in message annotation.""" + + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class BoundedGenericExecutor(Executor, Generic[_BT]): + @handler + async def process(self, message: _BT, ctx: WorkflowContext) -> None: + await ctx.send_message(message) + + +def test_handler_allows_concrete_types(): + """Test that @handler works normally with concrete type annotations.""" + + class ConcreteExecutor(Executor): + @handler + async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: + pass + + exec_instance = ConcreteExecutor(id="concrete") + assert str in exec_instance.input_types + + +def test_handler_explicit_input_bypasses_typevar_check(): + """Test that @handler(input=...) bypasses TypeVar check since explicit types take precedence.""" + + class GenericWithExplicit(Executor, Generic[_T]): + @handler(input=str, output=str) + async def echo(self, message, ctx: WorkflowContext) -> None: + pass + + exec_instance = GenericWithExplicit(id="explicit") + assert str in exec_instance.input_types + + +def test_handler_error_message_recommends_explicit_types(): + """Test that the TypeVar error message recommends @handler(input=..., output=...).""" + + with pytest.raises(ValueError, match=r"@handler\(input=, output=\)"): + + class GenericBad(Executor, Generic[_T]): + @handler + async def echo(self, message: _T, ctx: WorkflowContext) -> None: + pass + + +# endregion: Tests for unresolved TypeVar rejection in handler registration + + +def test_handler_typevar_error_takes_priority_over_context_error(): + """Test that TypeVar message error is raised before WorkflowContext validation. + + When a handler has both a TypeVar message annotation and an unannotated ctx + parameter, the TypeVar error should be reported first since it is the more + actionable issue. + """ + + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class DualBad(Executor, Generic[_T]): + @handler + async def process(self, message: _T, ctx) -> None: # type: ignore[no-untyped-def] + pass diff --git a/python/packages/core/tests/workflow/test_function_executor.py b/python/packages/core/tests/workflow/test_function_executor.py index 2ac083d943..6d292cb6eb 100644 --- a/python/packages/core/tests/workflow/test_function_executor.py +++ b/python/packages/core/tests/workflow/test_function_executor.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from dataclasses import dataclass -from typing import Any +from typing import Any, TypeVar import pytest from typing_extensions import Never @@ -895,3 +895,73 @@ class TestExecutorExplicitTypes: assert str in exec_instance._handlers # pyright: ignore[reportPrivateUsage] assert int in exec_instance.output_types assert bool in exec_instance.workflow_output_types + + +# region Tests for unresolved TypeVar rejection in function executor registration + +_FT = TypeVar("_FT") + + +class TestFunctionExecutorTypeVarRejection: + """Tests that FunctionExecutor rejects unresolved TypeVar in message annotations.""" + + def test_function_executor_rejects_unresolved_typevar(self): + """Test that FunctionExecutor raises ValueError for unresolved TypeVar message annotation.""" + + def echo(message: _FT) -> _FT: + return message + + with pytest.raises(ValueError, match="unresolved TypeVar"): + FunctionExecutor(echo, id="echo") + + def test_function_executor_rejects_typevar_with_context(self): + """Test that FunctionExecutor raises ValueError for TypeVar even with WorkflowContext.""" + + async def echo(message: _FT, ctx: WorkflowContext) -> None: + pass + + with pytest.raises(ValueError, match="unresolved TypeVar"): + FunctionExecutor(echo, id="echo") + + def test_function_executor_explicit_input_bypasses_typevar_check(self): + """Test that explicit input= parameter bypasses TypeVar detection.""" + + async def echo(message: _FT, ctx: WorkflowContext) -> None: + pass + + exec_instance = FunctionExecutor(echo, id="echo", input=str, output=str) + assert str in exec_instance.input_types + + def test_function_executor_allows_concrete_types(self): + """Test that FunctionExecutor works normally with concrete type annotations.""" + + async def handle(message: str, ctx: WorkflowContext[str]) -> None: + pass + + exec_instance = FunctionExecutor(handle, id="concrete") + assert str in exec_instance.input_types + + def test_function_executor_error_recommends_explicit_types(self): + """Test that error message recommends @executor(input=..., output=...).""" + + def echo(message: _FT) -> _FT: + return message + + with pytest.raises(ValueError, match=r"@executor\(input=, output=\)"): + FunctionExecutor(echo, id="echo") + + +# endregion: Tests for unresolved TypeVar rejection in function executor registration + + +_FBT = TypeVar("_FBT", bound=str) + + +def test_function_executor_rejects_bounded_typevar_in_message_annotation(): + """Test that FunctionExecutor raises ValueError for a bounded TypeVar in message annotation.""" + + async def process(message: _FBT, ctx: WorkflowContext) -> None: + await ctx.send_message(message) + + with pytest.raises(ValueError, match="unresolved TypeVar"): + FunctionExecutor(process, id="bounded") diff --git a/python/packages/core/tests/workflow/test_function_executor_future.py b/python/packages/core/tests/workflow/test_function_executor_future.py index 6d1ed32348..f98a150272 100644 --- a/python/packages/core/tests/workflow/test_function_executor_future.py +++ b/python/packages/core/tests/workflow/test_function_executor_future.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +import pytest + from agent_framework import FunctionExecutor, WorkflowContext, executor @@ -37,3 +39,20 @@ class TestFunctionExecutorFutureAnnotations: spec = process_complex._handler_specs[0] # pyright: ignore[reportPrivateUsage] assert spec["message_type"] == dict[str, Any] assert spec["output_types"] == [list[str]] + + def test_handler_unresolvable_annotation_raises(self): + """Test that an unresolvable forward-reference annotation raises ValueError. + + When get_type_hints fails (e.g. NameError for NonExistentType), the code falls back + to raw string annotations. The ctx parameter's raw string annotation is then not + recognised as a valid WorkflowContext type, so a ValueError is still raised. + """ + with pytest.raises(ValueError): + FunctionExecutor( + _func_with_bad_annotation, # pyright: ignore[reportUnknownArgumentType] + id="bad", + ) + + +async def _func_with_bad_annotation(message: NonExistentType, ctx: WorkflowContext[int]) -> None: # noqa: F821 # type: ignore[name-defined] + pass diff --git a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py index 9400084692..d280a788cd 100644 --- a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py +++ b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py @@ -130,7 +130,17 @@ async def test_checkpoint_with_pending_request_info_events(): with tempfile.TemporaryDirectory() as temp_dir: # Use file-based storage to test full serialization - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_request_info_and_response:UserApprovalRequest", + "tests.workflow.test_request_info_and_response:CalculationRequest", + "tests.workflow.test_request_info_event_rehydrate:MockRequest", + "tests.workflow.test_request_info_event_rehydrate:SimpleApproval", + "tests.workflow.test_request_info_event_rehydrate:SlottedApproval", + "tests.workflow.test_request_info_event_rehydrate:TimedApproval", + ], + ) # Create workflow with checkpointing enabled executor = ApprovalRequiredExecutor(id="approval_executor") @@ -225,7 +235,17 @@ async def test_checkpoint_restore_with_responses_does_not_reemit_handled_request with tempfile.TemporaryDirectory() as temp_dir: # Use file-based storage to test full serialization - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_request_info_and_response:UserApprovalRequest", + "tests.workflow.test_request_info_and_response:CalculationRequest", + "tests.workflow.test_request_info_event_rehydrate:MockRequest", + "tests.workflow.test_request_info_event_rehydrate:SimpleApproval", + "tests.workflow.test_request_info_event_rehydrate:SlottedApproval", + "tests.workflow.test_request_info_event_rehydrate:TimedApproval", + ], + ) # Create workflow with checkpointing enabled executor = ApprovalRequiredExecutor(id="approval_executor") @@ -288,7 +308,17 @@ async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_reque import tempfile with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_request_info_and_response:UserApprovalRequest", + "tests.workflow.test_request_info_and_response:CalculationRequest", + "tests.workflow.test_request_info_event_rehydrate:MockRequest", + "tests.workflow.test_request_info_event_rehydrate:SimpleApproval", + "tests.workflow.test_request_info_event_rehydrate:SlottedApproval", + "tests.workflow.test_request_info_event_rehydrate:TimedApproval", + ], + ) # Create workflow with multiple requests executor = MultiRequestExecutor(id="multi_executor") diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index dd2100c1ae..9c7a655d23 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -14,6 +14,7 @@ from agent_framework import ( AgentSession, Content, Executor, + HistoryProvider, InMemoryHistoryProvider, Message, ResponseStream, @@ -678,6 +679,110 @@ class TestWorkflowAgent: assert agent.context_providers == [explicit_provider] + async def test_no_history_provider_injected_when_session_is_none(self) -> None: + """Test that InMemoryHistoryProvider is NOT injected when session is None.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Session Agent") + + await agent.run("hello") + + assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + async def test_no_history_provider_injected_when_session_is_none_streaming(self) -> None: + """Test that InMemoryHistoryProvider is NOT injected when session is None (streaming).""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_stream_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Session Stream Agent") + + async for _ in agent.run("hello", stream=True): + pass + + assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + async def test_no_injection_when_history_provider_with_load_messages_exists(self) -> None: + """Test that no InMemoryHistoryProvider is injected when an existing HistoryProvider has load_messages=True.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="existing_provider_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + existing_provider = InMemoryHistoryProvider("custom", load_messages=True) + agent = WorkflowAgent( + workflow=workflow, + name="Existing Provider Agent", + context_providers=[existing_provider], + ) + session = AgentSession() + + await agent.run("hello", session=session) + + # Should still have only the original provider + history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)] + assert len(history_providers) == 1 + assert history_providers[0] is existing_provider + + async def test_injection_when_history_provider_with_load_messages_false(self) -> None: + """Test that InMemoryHistoryProvider IS injected when existing HistoryProvider has load_messages=False.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_load_provider_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + audit_provider = InMemoryHistoryProvider("audit", load_messages=False) + agent = WorkflowAgent( + workflow=workflow, + name="Audit Provider Agent", + context_providers=[audit_provider], + ) + session = AgentSession() + + await agent.run("hello", session=session) + + # Should have injected an additional InMemoryHistoryProvider with load_messages=True + history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)] + assert len(history_providers) == 2 + loading_providers = [p for p in history_providers if p.load_messages] + assert len(loading_providers) == 1 + assert isinstance(loading_providers[0], InMemoryHistoryProvider) + + async def test_no_duplicate_injection_on_multiple_runs(self) -> None: + """Test that calling run() multiple times does not keep adding InMemoryHistoryProvider.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Dup Agent") + session = AgentSession() + + await agent.run("first", session=session) + await agent.run("second", session=session) + await agent.run("third", session=session) + + history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)] + assert len(history_providers) == 1 + + async def test_no_duplicate_injection_on_multiple_runs_streaming(self) -> None: + """Test that calling run(stream=True) multiple times does not keep adding InMemoryHistoryProvider.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_stream_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Dup Stream Agent") + session = AgentSession() + + async for _ in agent.run("first", stream=True, session=session): + pass + async for _ in agent.run("second", stream=True, session=session): + pass + async for _ in agent.run("third", stream=True, session=session): + pass + + history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)] + assert len(history_providers) == 1 + + async def test_injection_with_session_in_streaming_mode(self) -> None: + """Test that InMemoryHistoryProvider is injected when session is provided in streaming mode.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="stream_inject_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="Stream Inject Agent") + session = AgentSession() + + async for _ in agent.run("hello", stream=True, session=session): + pass + + assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + async def test_checkpoint_storage_passed_to_workflow(self) -> None: """Test that checkpoint_storage parameter is passed through to the workflow.""" from agent_framework import InMemoryCheckpointStorage diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index c502409de6..9018b2676b 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.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] 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 fe3c02b6ee..ae7f718d36 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 KE(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 Cp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var eh={exports:{}},qi={};/** +function XE(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 _p(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var eh={exports:{}},qi={};/** * @license React * react-jsx-runtime.production.js * @@ -6,7 +6,7 @@ function KE(e,n){for(var r=0;r>>1,C=R[P];if(0>>1;P<$;){var Y=2*(P+1)-1,V=R[Y],J=Y+1,ce=R[J];if(0>l(V,I))Jl(ce,V)?(R[P]=ce,R[J]=I,P=J):(R[P]=V,R[Y]=I,P=Y);else if(Jl(ce,I))R[P]=ce,R[J]=I,P=J;else break e}}return L}function l(R,L){var I=R.sortIndex-L.sortIndex;return I!==0?I:R.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,x=null,y=3,b=!1,j=!1,N=!1,S=!1,_=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function M(R){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=R)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function T(R){if(N=!1,M(R),!j)if(r(m)!==null)j=!0,D||(D=!0,G());else{var L=r(h);L!==null&&U(T,L.startTime-R)}}var D=!1,z=-1,H=5,q=-1;function X(){return S?!0:!(e.unstable_now()-qR&&X());){var P=x.callback;if(typeof P=="function"){x.callback=null,y=x.priorityLevel;var C=P(x.expirationTime<=R);if(R=e.unstable_now(),typeof C=="function"){x.callback=C,M(R),L=!0;break t}x===r(m)&&a(m),M(R)}else a(m);x=r(m)}if(x!==null)L=!0;else{var $=r(h);$!==null&&U(T,$.startTime-R),L=!1}}break e}finally{x=null,y=I,b=!1}L=void 0}}finally{L?G():D=!1}}}var G;if(typeof E=="function")G=function(){E(W)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,B=ne.port2;ne.port1.onmessage=W,G=function(){B.postMessage(null)}}else G=function(){_(W,0)};function U(R,L){z=_(function(){R(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(R){R.callback=null},e.unstable_forceFrameRate=function(R){0>R||125P?(R.sortIndex=I,n(h,R),r(m)===null&&R===r(h)&&(N?(A(z),z=-1):N=!0,U(T,I-P))):(R.sortIndex=C,n(m,R),j||b||(j=!0,D||(D=!0,G()))),R},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(R){var L=y;return function(){var I=y;y=L;try{return R.apply(this,arguments)}finally{y=I}}}})(rh)),rh}var vv;function nC(){return vv||(vv=1,sh.exports=tC()),sh.exports}var oh={exports:{}},Jt={};/** + */var gv;function QE(){return gv||(gv=1,(function(e){function n(R,L){var I=R.length;R.push(L);e:for(;0>>1,C=R[$];if(0>>1;$l(V,I))eel(ie,V)?(R[$]=ie,R[ee]=I,$=ee):(R[$]=V,R[G]=I,$=G);else if(eel(ie,I))R[$]=ie,R[ee]=I,$=ee;else break e}}return L}function l(R,L){var I=R.sortIndex-L.sortIndex;return I!==0?I:R.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,T=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function A(R){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=R)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function M(R){if(N=!1,A(R),!S)if(r(m)!==null)S=!0,D||(D=!0,X());else{var L=r(h);L!==null&&B(M,L.startTime-R)}}var D=!1,z=-1,H=5,q=-1;function Y(){return j?!0:!(e.unstable_now()-qR&&Y());){var $=y.callback;if(typeof $=="function"){y.callback=null,x=y.priorityLevel;var C=$(y.expirationTime<=R);if(R=e.unstable_now(),typeof C=="function"){y.callback=C,A(R),L=!0;break t}y===r(m)&&a(m),A(R)}else a(m);y=r(m)}if(y!==null)L=!0;else{var P=r(h);P!==null&&B(M,P.startTime-R),L=!1}}break e}finally{y=null,x=I,b=!1}L=void 0}}finally{L?X():D=!1}}}var X;if(typeof E=="function")X=function(){E(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,U=ne.port2;ne.port1.onmessage=K,X=function(){U.postMessage(null)}}else X=function(){_(K,0)};function B(R,L){z=_(function(){R(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(R){R.callback=null},e.unstable_forceFrameRate=function(R){0>R||125$?(R.sortIndex=I,n(h,R),r(m)===null&&R===r(h)&&(N?(T(z),z=-1):N=!0,B(M,I-$))):(R.sortIndex=C,n(m,R),S||b||(S=!0,D||(D=!0,X()))),R},e.unstable_shouldYield=Y,e.unstable_wrapCallback=function(R){var L=x;return function(){var I=x;x=L;try{return R.apply(this,arguments)}finally{x=I}}}})(rh)),rh}var xv;function JE(){return xv||(xv=1,sh.exports=QE()),sh.exports}var oh={exports:{}},tn={};/** * @license React * react-dom.production.js * @@ -30,7 +30,7 @@ function KE(e,n){for(var r=0;r"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),oh.exports=sC(),oh.exports}/** + */var yv;function eC(){if(yv)return tn;yv=1;var e=wl();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(),oh.exports=eC(),oh.exports}/** * @license React * react-dom-client.production.js * @@ -38,20 +38,20 @@ function KE(e,n){for(var r=0;rC||(t.current=P[C],P[C]=null,C--)}function V(t,s){C++,P[C]=t.current,t.current=s}var J=$(null),ce=$(null),fe=$(null),ee=$(null);function ie(t,s){switch(V(fe,s),V(ce,t),V(J,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?By(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=By(s),t=Vy(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}Y(J),V(J,t)}function ge(){Y(J),Y(ce),Y(fe)}function Ee(t){t.memoizedState!==null&&V(ee,t);var s=J.current,i=Vy(s,t.type);s!==i&&(V(ce,t),V(J,i))}function Ne(t){ce.current===t&&(Y(J),Y(ce)),ee.current===t&&(Y(ee),Pi._currentValue=I)}var ve=Object.prototype.hasOwnProperty,ze=e.unstable_scheduleCallback,re=e.unstable_cancelCallback,Q=e.unstable_shouldYield,me=e.unstable_requestPaint,be=e.unstable_now,Ce=e.unstable_getCurrentPriorityLevel,we=e.unstable_ImmediatePriority,Me=e.unstable_UserBlockingPriority,je=e.unstable_NormalPriority,Se=e.unstable_LowPriority,Ke=e.unstable_IdlePriority,tt=e.log,Be=e.unstable_setDisableYieldValue,_e=null,xe=null;function $e(t){if(typeof tt=="function"&&Be(t),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(_e,t)}catch{}}var Ge=Math.clz32?Math.clz32:_o,qt=Math.log,rn=Math.LN2;function _o(t){return t>>>=0,t===0?32:31-(qt(t)/rn|0)|0}var Jn=256,vs=4194304;function pe(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 Ae(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,k=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~v,u!==0?p=pe(u):(k&=O,k!==0?p=pe(k):i||(i=O&~t,i!==0&&(p=pe(i))))):(O=u&~v,O!==0?p=pe(O):k!==0?p=pe(k):i||(i=u&~t,i!==0&&(p=pe(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 Ie(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function Ot(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 Ft(){var t=Jn;return Jn<<=1,(Jn&4194048)===0&&(Jn=256),t}function Pe(){var t=vs;return vs<<=1,(vs&62914560)===0&&(vs=4194304),t}function ye(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function dt(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function _t(t,s,i,u,p,v){var k=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,F=t.expirationTimes,se=t.hiddenUpdates;for(i=k&~i;0C||(t.current=$[C],$[C]=null,C--)}function V(t,s){C++,$[C]=t.current,t.current=s}var ee=P(null),ie=P(null),ue=P(null),Q=P(null);function ae(t,s){switch(V(ue,s),V(ie,t),V(ee,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?Hy(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=Hy(s),t=Uy(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}G(ee),V(ee,t)}function ge(){G(ee),G(ie),G(ue)}function Se(t){t.memoizedState!==null&&V(Q,t);var s=ee.current,i=Uy(s,t.type);s!==i&&(V(ie,t),V(ee,i))}function we(t){ie.current===t&&(G(ee),G(ie)),Q.current===t&&(G(Q),Pi._currentValue=I)}var ve=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,Le=e.unstable_cancelCallback,nt=e.unstable_shouldYield,le=e.unstable_requestPaint,Ee=e.unstable_now,W=e.unstable_getCurrentPriorityLevel,xe=e.unstable_ImmediatePriority,be=e.unstable_UserBlockingPriority,pe=e.unstable_NormalPriority,ke=e.unstable_LowPriority,Ce=e.unstable_IdlePriority,De=e.log,Ze=e.unstable_setDisableYieldValue,Pe=null,je=null;function Ne(t){if(typeof De=="function"&&Ze(t),je&&typeof je.setStrictMode=="function")try{je.setStrictMode(Pe,t)}catch{}}var _e=Math.clz32?Math.clz32:sn,Ge=Math.log,dt=Math.LN2;function sn(t){return t>>>=0,t===0?32:31-(Ge(t)/dt|0)|0}var Bt=256,bs=4194304;function he(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 Me(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,k=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~v,u!==0?p=he(u):(k&=O,k!==0?p=he(k):i||(i=O&~t,i!==0&&(p=he(i))))):(O=u&~v,O!==0?p=he(O):k!==0?p=he(k):i||(i=u&~t,i!==0&&(p=he(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 $e(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function It(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 Gt(){var t=Bt;return Bt<<=1,(Bt&4194048)===0&&(Bt=256),t}function Ue(){var t=bs;return bs<<=1,(bs&62914560)===0&&(bs=4194304),t}function ye(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function mt(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Ct(t,s,i,u,p,v){var k=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,F=t.expirationTimes,se=t.hiddenUpdates;for(i=k&~i;0)":-1p||F[u]!==se[p]){var ue=` -`+F[u].replace(" at new "," at ");return t.displayName&&ue.includes("")&&(ue=ue.replace("",t.displayName)),ue}while(1<=u&&0<=p);break}}}finally{Xa=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?js(i):""}function Zd(t){switch(t.tag){case 26:case 27:case 5:return js(t.type);case 16:return js("Lazy");case 13:return js("Suspense");case 19:return js("SuspenseList");case 0:case 15:return Za(t.type,!1);case 11:return Za(t.type.render,!1);case 1:return Za(t.type,!0);case 31:return js("Activity");default:return""}}function Vl(t){try{var s="";do s+=Zd(t),t=t.return;while(t);return s}catch(i){return` +`);for(p=u=0;up||F[u]!==se[p]){var de=` +`+F[u].replace(" at new "," at ");return t.displayName&&de.includes("")&&(de=de.replace("",t.displayName)),de}while(1<=u&&0<=p);break}}}finally{Xa=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?Ss(i):""}function Zd(t){switch(t.tag){case 26:case 27:case 5:return Ss(t.type);case 16:return Ss("Lazy");case 13:return Ss("Suspense");case 19:return Ss("SuspenseList");case 0:case 15:return Za(t.type,!1);case 11:return Za(t.type.render,!1);case 1:return Za(t.type,!0);case 31:return Ss("Activity");default:return""}}function Vl(t){try{var s="";do s+=Zd(t),t=t.return;while(t);return s}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function on(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function ql(t){var s=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(s==="checkbox"||s==="radio")}function Wd(t){var s=ql(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,s),u=""+t[s];if(!t.hasOwnProperty(s)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var p=i.get,v=i.set;return Object.defineProperty(t,s,{configurable:!0,get:function(){return p.call(this)},set:function(k){u=""+k,v.call(this,k)}}),Object.defineProperty(t,s,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(k){u=""+k},stopTracking:function(){t._valueTracker=null,delete t[s]}}}}function ko(t){t._valueTracker||(t._valueTracker=Wd(t))}function Wa(t){if(!t)return!1;var s=t._valueTracker;if(!s)return!0;var i=s.getValue(),u="";return t&&(u=ql(t)?t.checked?"true":"false":t.value),t=u,t!==i?(s.setValue(t),!0):!1}function To(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 Kd=/[\n"\\]/g;function an(t){return t.replace(Kd,function(s){return"\\"+s.charCodeAt(0).toString(16)+" "})}function Vr(t,s,i,u,p,v,k,O){t.name="",k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"?t.type=k:t.removeAttribute("type"),s!=null?k==="number"?(s===0&&t.value===""||t.value!=s)&&(t.value=""+on(s)):t.value!==""+on(s)&&(t.value=""+on(s)):k!=="submit"&&k!=="reset"||t.removeAttribute("value"),s!=null?Ka(t,k,on(s)):i!=null?Ka(t,k,on(i)):u!=null&&t.removeAttribute("value"),p==null&&v!=null&&(t.defaultChecked=!!v),p!=null&&(t.checked=p&&typeof p!="function"&&typeof p!="symbol"),O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?t.name=""+on(O):t.removeAttribute("name")}function Fl(t,s,i,u,p,v,k,O){if(v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(t.type=v),s!=null||i!=null){if(!(v!=="submit"&&v!=="reset"||s!=null))return;i=i!=null?""+on(i):"",s=s!=null?""+on(s):i,O||s===t.value||(t.value=s),t.defaultValue=s}u=u??p,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=O?t.checked:!!u,t.defaultChecked=!!u,k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"&&(t.name=k)}function Ka(t,s,i){s==="number"&&To(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function Ss(t,s,i,u){if(t=t.options,s){s={};for(var p=0;p"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),nf=!1;if(_s)try{var Ja={};Object.defineProperty(Ja,"passive",{get:function(){nf=!0}}),window.addEventListener("test",Ja,Ja),window.removeEventListener("test",Ja,Ja)}catch{nf=!1}var tr=null,sf=null,Gl=null;function Yg(){if(Gl)return Gl;var t,s=sf,i=s.length,u,p="value"in tr?tr.value:tr.textContent,v=p.length;for(t=0;t=ni),Qg=" ",Jg=!1;function ex(t,s){switch(t){case"keyup":return w_.indexOf(s.keyCode)!==-1;case"keydown":return s.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function tx(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Do=!1;function j_(t,s){switch(t){case"compositionend":return tx(s);case"keypress":return s.which!==32?null:(Jg=!0,Qg);case"textInput":return t=s.data,t===Qg&&Jg?null:t;default:return null}}function S_(t,s){if(Do)return t==="compositionend"||!cf&&ex(t,s)?(t=Yg(),Gl=sf=tr=null,Do=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(s.ctrlKey||s.altKey||s.metaKey)||s.ctrlKey&&s.altKey){if(s.char&&1=s)return{node:i,offset:s-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=cx(i)}}function dx(t,s){return t&&s?t===s?!0:t&&t.nodeType===3?!1:s&&s.nodeType===3?dx(t,s.parentNode):"contains"in t?t.contains(s):t.compareDocumentPosition?!!(t.compareDocumentPosition(s)&16):!1:!1}function fx(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var s=To(t.document);s instanceof t.HTMLIFrameElement;){try{var i=typeof s.contentWindow.location.href=="string"}catch{i=!1}if(i)t=s.contentWindow;else break;s=To(t.document)}return s}function ff(t){var s=t&&t.nodeName&&t.nodeName.toLowerCase();return s&&(s==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||s==="textarea"||t.contentEditable==="true")}var R_=_s&&"documentMode"in document&&11>=document.documentMode,Oo=null,mf=null,ai=null,hf=!1;function mx(t,s,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;hf||Oo==null||Oo!==To(u)||(u=Oo,"selectionStart"in u&&ff(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}),ai&&oi(ai,u)||(ai=u,u=Lc(mf,"onSelect"),0>=k,p-=k,Cs=1<<32-Ge(s)+p|i<v?v:8;var k=R.T,O={};R.T=O,Jf(t,!1,s,i);try{var F=p(),se=R.S;if(se!==null&&se(O,F),F!==null&&typeof F=="object"&&typeof F.then=="function"){var ue=U_(F,u);wi(t,s,ue,vn(t))}else wi(t,s,u,vn(t))}catch(he){wi(t,s,{then:function(){},status:"rejected",reason:he},vn())}finally{L.p=v,R.T=k}}function Y_(){}function Kf(t,s,i,u){if(t.tag!==5)throw Error(a(476));var p=h0(t).queue;m0(t,p,s,I,i===null?Y_:function(){return p0(t),i(u)})}function h0(t){var s=t.memoizedState;if(s!==null)return s;s={memoizedState:I,baseState:I,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ms,lastRenderedState:I},next:null};var i={};return s.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ms,lastRenderedState:i},next:null},t.memoizedState=s,t=t.alternate,t!==null&&(t.memoizedState=s),s}function p0(t){var s=h0(t).next.queue;wi(t,s,{},vn())}function Qf(){return Qt(Pi)}function g0(){return Mt().memoizedState}function x0(){return Mt().memoizedState}function G_(t){for(var s=t.return;s!==null;){switch(s.tag){case 24:case 3:var i=vn();t=rr(i);var u=or(s,t,i);u!==null&&(bn(u,s,i),pi(u,s,i)),s={cache:kf()},t.payload=s;return}s=s.return}}function X_(t,s,i){var u=vn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},xc(t)?v0(s,i):(i=yf(t,s,i,u),i!==null&&(bn(i,t,u),b0(i,s,u)))}function y0(t,s,i){var u=vn();wi(t,s,i,u)}function wi(t,s,i,u){var p={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(xc(t))v0(s,p);else{var v=t.alternate;if(t.lanes===0&&(v===null||v.lanes===0)&&(v=s.lastRenderedReducer,v!==null))try{var k=s.lastRenderedState,O=v(k,i);if(p.hasEagerState=!0,p.eagerState=O,hn(O,k))return ec(t,s,p,0),yt===null&&Jl(),!1}catch{}finally{}if(i=yf(t,s,p,u),i!==null)return bn(i,t,u),b0(i,s,u),!0}return!1}function Jf(t,s,i,u){if(u={lane:2,revertLane:Mm(),action:u,hasEagerState:!1,eagerState:null,next:null},xc(t)){if(s)throw Error(a(479))}else s=yf(t,i,u,2),s!==null&&bn(s,t,2)}function xc(t){var s=t.alternate;return t===Qe||s!==null&&s===Qe}function v0(t,s){qo=dc=!0;var i=t.pending;i===null?s.next=s:(s.next=i.next,i.next=s),t.pending=s}function b0(t,s,i){if((i&4194048)!==0){var u=s.lanes;u&=t.pendingLanes,i|=u,s.lanes=i,kn(t,i)}}var yc={readContext:Qt,use:mc,useCallback:Et,useContext:Et,useEffect:Et,useImperativeHandle:Et,useLayoutEffect:Et,useInsertionEffect:Et,useMemo:Et,useReducer:Et,useRef:Et,useState:Et,useDebugValue:Et,useDeferredValue:Et,useTransition:Et,useSyncExternalStore:Et,useId:Et,useHostTransitionStatus:Et,useFormState:Et,useActionState:Et,useOptimistic:Et,useMemoCache:Et,useCacheRefresh:Et},w0={readContext:Qt,use:mc,useCallback:function(t,s){return cn().memoizedState=[t,s===void 0?null:s],t},useContext:Qt,useEffect:r0,useImperativeHandle:function(t,s,i){i=i!=null?i.concat([t]):null,gc(4194308,4,l0.bind(null,s,t),i)},useLayoutEffect:function(t,s){return gc(4194308,4,t,s)},useInsertionEffect:function(t,s){gc(4,2,t,s)},useMemo:function(t,s){var i=cn();s=s===void 0?null:s;var u=t();if(to){$e(!0);try{t()}finally{$e(!1)}}return i.memoizedState=[u,s],u},useReducer:function(t,s,i){var u=cn();if(i!==void 0){var p=i(s);if(to){$e(!0);try{i(s)}finally{$e(!1)}}}else p=s;return u.memoizedState=u.baseState=p,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:p},u.queue=t,t=t.dispatch=X_.bind(null,Qe,t),[u.memoizedState,t]},useRef:function(t){var s=cn();return t={current:t},s.memoizedState=t},useState:function(t){t=Gf(t);var s=t.queue,i=y0.bind(null,Qe,s);return s.dispatch=i,[t.memoizedState,i]},useDebugValue:Zf,useDeferredValue:function(t,s){var i=cn();return Wf(i,t,s)},useTransition:function(){var t=Gf(!1);return t=m0.bind(null,Qe,t.queue,!0,!1),cn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,s,i){var u=Qe,p=cn();if(ct){if(i===void 0)throw Error(a(407));i=i()}else{if(i=s(),yt===null)throw Error(a(349));(it&124)!==0||Bx(u,s,i)}p.memoizedState=i;var v={value:i,getSnapshot:s};return p.queue=v,r0(qx.bind(null,u,v,t),[t]),u.flags|=2048,Yo(9,pc(),Vx.bind(null,u,v,i,s),null),i},useId:function(){var t=cn(),s=yt.identifierPrefix;if(ct){var i=ks,u=Cs;i=(u&~(1<<32-Ge(u)-1)).toString(32)+i,s="«"+s+"R"+i,i=fc++,0qe?(Vt=He,He=null):Vt=He.sibling;var lt=oe(K,He,te[qe],de);if(lt===null){He===null&&(He=Vt);break}t&&He&<.alternate===null&&s(K,He),Z=v(lt,Z,qe),et===null?Re=lt:et.sibling=lt,et=lt,He=Vt}if(qe===te.length)return i(K,He),ct&&Zr(K,qe),Re;if(He===null){for(;qeqe?(Vt=He,He=null):Vt=He.sibling;var Nr=oe(K,He,lt.value,de);if(Nr===null){He===null&&(He=Vt);break}t&&He&&Nr.alternate===null&&s(K,He),Z=v(Nr,Z,qe),et===null?Re=Nr:et.sibling=Nr,et=Nr,He=Vt}if(lt.done)return i(K,He),ct&&Zr(K,qe),Re;if(He===null){for(;!lt.done;qe++,lt=te.next())lt=he(K,lt.value,de),lt!==null&&(Z=v(lt,Z,qe),et===null?Re=lt:et.sibling=lt,et=lt);return ct&&Zr(K,qe),Re}for(He=u(He);!lt.done;qe++,lt=te.next())lt=ae(He,K,qe,lt.value,de),lt!==null&&(t&<.alternate!==null&&He.delete(lt.key===null?qe:lt.key),Z=v(lt,Z,qe),et===null?Re=lt:et.sibling=lt,et=lt);return t&&He.forEach(function(WE){return s(K,WE)}),ct&&Zr(K,qe),Re}function gt(K,Z,te,de){if(typeof te=="object"&&te!==null&&te.type===j&&te.key===null&&(te=te.props.children),typeof te=="object"&&te!==null){switch(te.$$typeof){case y:e:{for(var Re=te.key;Z!==null;){if(Z.key===Re){if(Re=te.type,Re===j){if(Z.tag===7){i(K,Z.sibling),de=p(Z,te.props.children),de.return=K,K=de;break e}}else if(Z.elementType===Re||typeof Re=="object"&&Re!==null&&Re.$$typeof===H&&j0(Re)===Z.type){i(K,Z.sibling),de=p(Z,te.props),ji(de,te),de.return=K,K=de;break e}i(K,Z);break}else s(K,Z);Z=Z.sibling}te.type===j?(de=Gr(te.props.children,K.mode,de,te.key),de.return=K,K=de):(de=nc(te.type,te.key,te.props,null,K.mode,de),ji(de,te),de.return=K,K=de)}return k(K);case b:e:{for(Re=te.key;Z!==null;){if(Z.key===Re)if(Z.tag===4&&Z.stateNode.containerInfo===te.containerInfo&&Z.stateNode.implementation===te.implementation){i(K,Z.sibling),de=p(Z,te.children||[]),de.return=K,K=de;break e}else{i(K,Z);break}else s(K,Z);Z=Z.sibling}de=wf(te,K.mode,de),de.return=K,K=de}return k(K);case H:return Re=te._init,te=Re(te._payload),gt(K,Z,te,de)}if(U(te))return Fe(K,Z,te,de);if(G(te)){if(Re=G(te),typeof Re!="function")throw Error(a(150));return te=Re.call(te),Ve(K,Z,te,de)}if(typeof te.then=="function")return gt(K,Z,vc(te),de);if(te.$$typeof===E)return gt(K,Z,ac(K,te),de);bc(K,te)}return typeof te=="string"&&te!==""||typeof te=="number"||typeof te=="bigint"?(te=""+te,Z!==null&&Z.tag===6?(i(K,Z.sibling),de=p(Z,te),de.return=K,K=de):(i(K,Z),de=bf(te,K.mode,de),de.return=K,K=de),k(K)):i(K,Z)}return function(K,Z,te,de){try{Ni=0;var Re=gt(K,Z,te,de);return Go=null,Re}catch(He){if(He===mi||He===lc)throw He;var et=pn(29,He,null,K.mode);return et.lanes=de,et.return=K,et}finally{}}}var Xo=S0(!0),_0=S0(!1),Dn=$(null),ns=null;function ir(t){var s=t.alternate;V(It,It.current&1),V(Dn,t),ns===null&&(s===null||Vo.current!==null||s.memoizedState!==null)&&(ns=t)}function E0(t){if(t.tag===22){if(V(It,It.current),V(Dn,t),ns===null){var s=t.alternate;s!==null&&s.memoizedState!==null&&(ns=t)}}else lr()}function lr(){V(It,It.current),V(Dn,Dn.current)}function Rs(t){Y(Dn),ns===t&&(ns=null),Y(It)}var It=$(0);function wc(t){for(var s=t;s!==null;){if(s.tag===13){var i=s.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||Vm(i)))return s}else if(s.tag===19&&s.memoizedProps.revealOrder!==void 0){if((s.flags&128)!==0)return s}else if(s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break;for(;s.sibling===null;){if(s.return===null||s.return===t)return null;s=s.return}s.sibling.return=s.return,s=s.sibling}return null}function em(t,s,i,u){s=t.memoizedState,i=i(u,s),i=i==null?s:g({},s,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var tm={enqueueSetState:function(t,s,i){t=t._reactInternals;var u=vn(),p=rr(u);p.payload=s,i!=null&&(p.callback=i),s=or(t,p,u),s!==null&&(bn(s,t,u),pi(s,t,u))},enqueueReplaceState:function(t,s,i){t=t._reactInternals;var u=vn(),p=rr(u);p.tag=1,p.payload=s,i!=null&&(p.callback=i),s=or(t,p,u),s!==null&&(bn(s,t,u),pi(s,t,u))},enqueueForceUpdate:function(t,s){t=t._reactInternals;var i=vn(),u=rr(i);u.tag=2,s!=null&&(u.callback=s),s=or(t,u,i),s!==null&&(bn(s,t,i),pi(s,t,i))}};function C0(t,s,i,u,p,v,k){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,v,k):s.prototype&&s.prototype.isPureReactComponent?!oi(i,u)||!oi(p,v):!0}function k0(t,s,i,u){t=s.state,typeof s.componentWillReceiveProps=="function"&&s.componentWillReceiveProps(i,u),typeof s.UNSAFE_componentWillReceiveProps=="function"&&s.UNSAFE_componentWillReceiveProps(i,u),s.state!==t&&tm.enqueueReplaceState(s,s.state,null)}function no(t,s){var i=s;if("ref"in s){i={};for(var u in s)u!=="ref"&&(i[u]=s[u])}if(t=t.defaultProps){i===s&&(i=g({},i));for(var p in t)i[p]===void 0&&(i[p]=t[p])}return i}var Nc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var s=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(s))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function T0(t){Nc(t)}function A0(t){console.error(t)}function M0(t){Nc(t)}function jc(t,s){try{var i=t.onUncaughtError;i(s.value,{componentStack:s.stack})}catch(u){setTimeout(function(){throw u})}}function R0(t,s,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:s.tag===1?s.stateNode:null})}catch(p){setTimeout(function(){throw p})}}function nm(t,s,i){return i=rr(i),i.tag=3,i.payload={element:null},i.callback=function(){jc(t,s)},i}function D0(t){return t=rr(t),t.tag=3,t}function O0(t,s,i,u){var p=i.type.getDerivedStateFromError;if(typeof p=="function"){var v=u.value;t.payload=function(){return p(v)},t.callback=function(){R0(s,i,u)}}var k=i.stateNode;k!==null&&typeof k.componentDidCatch=="function"&&(t.callback=function(){R0(s,i,u),typeof p!="function"&&(hr===null?hr=new Set([this]):hr.add(this));var O=u.stack;this.componentDidCatch(u.value,{componentStack:O!==null?O:""})})}function W_(t,s,i,u,p){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(s=i.alternate,s!==null&&ui(s,i,p,!0),i=Dn.current,i!==null){switch(i.tag){case 13:return ns===null?Em():i.alternate===null&&St===0&&(St=3),i.flags&=-257,i.flags|=65536,i.lanes=p,u===Mf?i.flags|=16384:(s=i.updateQueue,s===null?i.updateQueue=new Set([u]):s.add(u),km(t,u,p)),!1;case 22:return i.flags|=65536,u===Mf?i.flags|=16384:(s=i.updateQueue,s===null?(s={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=s):(i=s.retryQueue,i===null?s.retryQueue=new Set([u]):i.add(u)),km(t,u,p)),!1}throw Error(a(435,i.tag))}return km(t,u,p),Em(),!1}if(ct)return s=Dn.current,s!==null?((s.flags&65536)===0&&(s.flags|=256),s.flags|=65536,s.lanes=p,u!==Sf&&(t=Error(a(422),{cause:u}),ci(Tn(t,i)))):(u!==Sf&&(s=Error(a(423),{cause:u}),ci(Tn(s,i))),t=t.current.alternate,t.flags|=65536,p&=-p,t.lanes|=p,u=Tn(u,i),p=nm(t.stateNode,u,p),Of(t,p),St!==4&&(St=2)),!1;var v=Error(a(520),{cause:u});if(v=Tn(v,i),Ai===null?Ai=[v]:Ai.push(v),St!==4&&(St=2),s===null)return!0;u=Tn(u,i),i=s;do{switch(i.tag){case 3:return i.flags|=65536,t=p&-p,i.lanes|=t,t=nm(i.stateNode,u,t),Of(i,t),!1;case 1:if(s=i.type,v=i.stateNode,(i.flags&128)===0&&(typeof s.getDerivedStateFromError=="function"||v!==null&&typeof v.componentDidCatch=="function"&&(hr===null||!hr.has(v))))return i.flags|=65536,p&=-p,i.lanes|=p,p=D0(p),O0(p,t,i,u),Of(i,p),!1}i=i.return}while(i!==null);return!1}var z0=Error(a(461)),Ut=!1;function Yt(t,s,i,u){s.child=t===null?_0(s,null,i,u):Xo(s,t.child,i,u)}function I0(t,s,i,u,p){i=i.render;var v=s.ref;if("ref"in u){var k={};for(var O in u)O!=="ref"&&(k[O]=u[O])}else k=u;return Jr(s),u=Pf(t,s,i,k,v,p),O=Hf(),t!==null&&!Ut?(Uf(t,s,p),Ds(t,s,p)):(ct&&O&&Nf(s),s.flags|=1,Yt(t,s,u,p),s.child)}function L0(t,s,i,u,p){if(t===null){var v=i.type;return typeof v=="function"&&!vf(v)&&v.defaultProps===void 0&&i.compare===null?(s.tag=15,s.type=v,$0(t,s,v,u,p)):(t=nc(i.type,null,u,s,s.mode,p),t.ref=s.ref,t.return=s,s.child=t)}if(v=t.child,!um(t,p)){var k=v.memoizedProps;if(i=i.compare,i=i!==null?i:oi,i(k,u)&&t.ref===s.ref)return Ds(t,s,p)}return s.flags|=1,t=Es(v,u),t.ref=s.ref,t.return=s,s.child=t}function $0(t,s,i,u,p){if(t!==null){var v=t.memoizedProps;if(oi(v,u)&&t.ref===s.ref)if(Ut=!1,s.pendingProps=u=v,um(t,p))(t.flags&131072)!==0&&(Ut=!0);else return s.lanes=t.lanes,Ds(t,s,p)}return sm(t,s,i,u,p)}function P0(t,s,i){var u=s.pendingProps,p=u.children,v=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((s.flags&128)!==0){if(u=v!==null?v.baseLanes|i:i,t!==null){for(p=s.child=t.child,v=0;p!==null;)v=v|p.lanes|p.childLanes,p=p.sibling;s.childLanes=v&~u}else s.childLanes=0,s.child=null;return H0(t,s,u,i)}if((i&536870912)!==0)s.memoizedState={baseLanes:0,cachePool:null},t!==null&&ic(s,v!==null?v.cachePool:null),v!==null?$x(s,v):If(),E0(s);else return s.lanes=s.childLanes=536870912,H0(t,s,v!==null?v.baseLanes|i:i,i)}else v!==null?(ic(s,v.cachePool),$x(s,v),lr(),s.memoizedState=null):(t!==null&&ic(s,null),If(),lr());return Yt(t,s,p,i),s.child}function H0(t,s,i,u){var p=Af();return p=p===null?null:{parent:zt._currentValue,pool:p},s.memoizedState={baseLanes:i,cachePool:p},t!==null&&ic(s,null),If(),E0(s),t!==null&&ui(t,s,u,!0),null}function Sc(t,s){var i=s.ref;if(i===null)t!==null&&t.ref!==null&&(s.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(a(284));(t===null||t.ref!==i)&&(s.flags|=4194816)}}function sm(t,s,i,u,p){return Jr(s),i=Pf(t,s,i,u,void 0,p),u=Hf(),t!==null&&!Ut?(Uf(t,s,p),Ds(t,s,p)):(ct&&u&&Nf(s),s.flags|=1,Yt(t,s,i,p),s.child)}function U0(t,s,i,u,p,v){return Jr(s),s.updateQueue=null,i=Hx(s,u,i,p),Px(t),u=Hf(),t!==null&&!Ut?(Uf(t,s,v),Ds(t,s,v)):(ct&&u&&Nf(s),s.flags|=1,Yt(t,s,i,v),s.child)}function B0(t,s,i,u,p){if(Jr(s),s.stateNode===null){var v=$o,k=i.contextType;typeof k=="object"&&k!==null&&(v=Qt(k)),v=new i(u,v),s.memoizedState=v.state!==null&&v.state!==void 0?v.state:null,v.updater=tm,s.stateNode=v,v._reactInternals=s,v=s.stateNode,v.props=u,v.state=s.memoizedState,v.refs={},Rf(s),k=i.contextType,v.context=typeof k=="object"&&k!==null?Qt(k):$o,v.state=s.memoizedState,k=i.getDerivedStateFromProps,typeof k=="function"&&(em(s,i,k,u),v.state=s.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof v.getSnapshotBeforeUpdate=="function"||typeof v.UNSAFE_componentWillMount!="function"&&typeof v.componentWillMount!="function"||(k=v.state,typeof v.componentWillMount=="function"&&v.componentWillMount(),typeof v.UNSAFE_componentWillMount=="function"&&v.UNSAFE_componentWillMount(),k!==v.state&&tm.enqueueReplaceState(v,v.state,null),xi(s,u,v,p),gi(),v.state=s.memoizedState),typeof v.componentDidMount=="function"&&(s.flags|=4194308),u=!0}else if(t===null){v=s.stateNode;var O=s.memoizedProps,F=no(i,O);v.props=F;var se=v.context,ue=i.contextType;k=$o,typeof ue=="object"&&ue!==null&&(k=Qt(ue));var he=i.getDerivedStateFromProps;ue=typeof he=="function"||typeof v.getSnapshotBeforeUpdate=="function",O=s.pendingProps!==O,ue||typeof v.UNSAFE_componentWillReceiveProps!="function"&&typeof v.componentWillReceiveProps!="function"||(O||se!==k)&&k0(s,v,u,k),sr=!1;var oe=s.memoizedState;v.state=oe,xi(s,u,v,p),gi(),se=s.memoizedState,O||oe!==se||sr?(typeof he=="function"&&(em(s,i,he,u),se=s.memoizedState),(F=sr||C0(s,i,F,u,oe,se,k))?(ue||typeof v.UNSAFE_componentWillMount!="function"&&typeof v.componentWillMount!="function"||(typeof v.componentWillMount=="function"&&v.componentWillMount(),typeof v.UNSAFE_componentWillMount=="function"&&v.UNSAFE_componentWillMount()),typeof v.componentDidMount=="function"&&(s.flags|=4194308)):(typeof v.componentDidMount=="function"&&(s.flags|=4194308),s.memoizedProps=u,s.memoizedState=se),v.props=u,v.state=se,v.context=k,u=F):(typeof v.componentDidMount=="function"&&(s.flags|=4194308),u=!1)}else{v=s.stateNode,Df(t,s),k=s.memoizedProps,ue=no(i,k),v.props=ue,he=s.pendingProps,oe=v.context,se=i.contextType,F=$o,typeof se=="object"&&se!==null&&(F=Qt(se)),O=i.getDerivedStateFromProps,(se=typeof O=="function"||typeof v.getSnapshotBeforeUpdate=="function")||typeof v.UNSAFE_componentWillReceiveProps!="function"&&typeof v.componentWillReceiveProps!="function"||(k!==he||oe!==F)&&k0(s,v,u,F),sr=!1,oe=s.memoizedState,v.state=oe,xi(s,u,v,p),gi();var ae=s.memoizedState;k!==he||oe!==ae||sr||t!==null&&t.dependencies!==null&&oc(t.dependencies)?(typeof O=="function"&&(em(s,i,O,u),ae=s.memoizedState),(ue=sr||C0(s,i,ue,u,oe,ae,F)||t!==null&&t.dependencies!==null&&oc(t.dependencies))?(se||typeof v.UNSAFE_componentWillUpdate!="function"&&typeof v.componentWillUpdate!="function"||(typeof v.componentWillUpdate=="function"&&v.componentWillUpdate(u,ae,F),typeof v.UNSAFE_componentWillUpdate=="function"&&v.UNSAFE_componentWillUpdate(u,ae,F)),typeof v.componentDidUpdate=="function"&&(s.flags|=4),typeof v.getSnapshotBeforeUpdate=="function"&&(s.flags|=1024)):(typeof v.componentDidUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=4),typeof v.getSnapshotBeforeUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=1024),s.memoizedProps=u,s.memoizedState=ae),v.props=u,v.state=ae,v.context=F,u=ue):(typeof v.componentDidUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=4),typeof v.getSnapshotBeforeUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=1024),u=!1)}return v=u,Sc(t,s),u=(s.flags&128)!==0,v||u?(v=s.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:v.render(),s.flags|=1,t!==null&&u?(s.child=Xo(s,t.child,null,p),s.child=Xo(s,null,i,p)):Yt(t,s,i,p),s.memoizedState=v.state,t=s.child):t=Ds(t,s,p),t}function V0(t,s,i,u){return li(),s.flags|=256,Yt(t,s,i,u),s.child}var rm={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function om(t){return{baseLanes:t,cachePool:Ax()}}function am(t,s,i){return t=t!==null?t.childLanes&~i:0,s&&(t|=On),t}function q0(t,s,i){var u=s.pendingProps,p=!1,v=(s.flags&128)!==0,k;if((k=v)||(k=t!==null&&t.memoizedState===null?!1:(It.current&2)!==0),k&&(p=!0,s.flags&=-129),k=(s.flags&32)!==0,s.flags&=-33,t===null){if(ct){if(p?ir(s):lr(),ct){var O=jt,F;if(F=O){e:{for(F=O,O=ts;F.nodeType!==8;){if(!O){O=null;break e}if(F=Vn(F.nextSibling),F===null){O=null;break e}}O=F}O!==null?(s.memoizedState={dehydrated:O,treeContext:Xr!==null?{id:Cs,overflow:ks}:null,retryLane:536870912,hydrationErrors:null},F=pn(18,null,null,0),F.stateNode=O,F.return=s,s.child=F,tn=s,jt=null,F=!0):F=!1}F||Kr(s)}if(O=s.memoizedState,O!==null&&(O=O.dehydrated,O!==null))return Vm(O)?s.lanes=32:s.lanes=536870912,null;Rs(s)}return O=u.children,u=u.fallback,p?(lr(),p=s.mode,O=_c({mode:"hidden",children:O},p),u=Gr(u,p,i,null),O.return=s,u.return=s,O.sibling=u,s.child=O,p=s.child,p.memoizedState=om(i),p.childLanes=am(t,k,i),s.memoizedState=rm,u):(ir(s),im(s,O))}if(F=t.memoizedState,F!==null&&(O=F.dehydrated,O!==null)){if(v)s.flags&256?(ir(s),s.flags&=-257,s=lm(t,s,i)):s.memoizedState!==null?(lr(),s.child=t.child,s.flags|=128,s=null):(lr(),p=u.fallback,O=s.mode,u=_c({mode:"visible",children:u.children},O),p=Gr(p,O,i,null),p.flags|=2,u.return=s,p.return=s,u.sibling=p,s.child=u,Xo(s,t.child,null,i),u=s.child,u.memoizedState=om(i),u.childLanes=am(t,k,i),s.memoizedState=rm,s=p);else if(ir(s),Vm(O)){if(k=O.nextSibling&&O.nextSibling.dataset,k)var se=k.dgst;k=se,u=Error(a(419)),u.stack="",u.digest=k,ci({value:u,source:null,stack:null}),s=lm(t,s,i)}else if(Ut||ui(t,s,i,!1),k=(i&t.childLanes)!==0,Ut||k){if(k=yt,k!==null&&(u=i&-i,u=(u&42)!==0?1:mn(u),u=(u&(k.suspendedLanes|i))!==0?0:u,u!==0&&u!==F.retryLane))throw F.retryLane=u,Lo(t,u),bn(k,t,u),z0;O.data==="$?"||Em(),s=lm(t,s,i)}else O.data==="$?"?(s.flags|=192,s.child=t.child,s=null):(t=F.treeContext,jt=Vn(O.nextSibling),tn=s,ct=!0,Wr=null,ts=!1,t!==null&&(Mn[Rn++]=Cs,Mn[Rn++]=ks,Mn[Rn++]=Xr,Cs=t.id,ks=t.overflow,Xr=s),s=im(s,u.children),s.flags|=4096);return s}return p?(lr(),p=u.fallback,O=s.mode,F=t.child,se=F.sibling,u=Es(F,{mode:"hidden",children:u.children}),u.subtreeFlags=F.subtreeFlags&65011712,se!==null?p=Es(se,p):(p=Gr(p,O,i,null),p.flags|=2),p.return=s,u.return=s,u.sibling=p,s.child=u,u=p,p=s.child,O=t.child.memoizedState,O===null?O=om(i):(F=O.cachePool,F!==null?(se=zt._currentValue,F=F.parent!==se?{parent:se,pool:se}:F):F=Ax(),O={baseLanes:O.baseLanes|i,cachePool:F}),p.memoizedState=O,p.childLanes=am(t,k,i),s.memoizedState=rm,u):(ir(s),i=t.child,t=i.sibling,i=Es(i,{mode:"visible",children:u.children}),i.return=s,i.sibling=null,t!==null&&(k=s.deletions,k===null?(s.deletions=[t],s.flags|=16):k.push(t)),s.child=i,s.memoizedState=null,i)}function im(t,s){return s=_c({mode:"visible",children:s},t.mode),s.return=t,t.child=s}function _c(t,s){return t=pn(22,t,null,s),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function lm(t,s,i){return Xo(s,t.child,null,i),t=im(s,s.pendingProps.children),t.flags|=2,s.memoizedState=null,t}function F0(t,s,i){t.lanes|=s;var u=t.alternate;u!==null&&(u.lanes|=s),Ef(t.return,s,i)}function cm(t,s,i,u,p){var v=t.memoizedState;v===null?t.memoizedState={isBackwards:s,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:p}:(v.isBackwards=s,v.rendering=null,v.renderingStartTime=0,v.last=u,v.tail=i,v.tailMode=p)}function Y0(t,s,i){var u=s.pendingProps,p=u.revealOrder,v=u.tail;if(Yt(t,s,u.children,i),u=It.current,(u&2)!==0)u=u&1|2,s.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=s.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&F0(t,i,s);else if(t.tag===19)F0(t,i,s);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===s)break e;for(;t.sibling===null;){if(t.return===null||t.return===s)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(V(It,u),p){case"forwards":for(i=s.child,p=null;i!==null;)t=i.alternate,t!==null&&wc(t)===null&&(p=i),i=i.sibling;i=p,i===null?(p=s.child,s.child=null):(p=i.sibling,i.sibling=null),cm(s,!1,p,i,v);break;case"backwards":for(i=null,p=s.child,s.child=null;p!==null;){if(t=p.alternate,t!==null&&wc(t)===null){s.child=p;break}t=p.sibling,p.sibling=i,i=p,p=t}cm(s,!0,i,null,v);break;case"together":cm(s,!1,null,null,void 0);break;default:s.memoizedState=null}return s.child}function Ds(t,s,i){if(t!==null&&(s.dependencies=t.dependencies),mr|=s.lanes,(i&s.childLanes)===0)if(t!==null){if(ui(t,s,i,!1),(i&s.childLanes)===0)return null}else return null;if(t!==null&&s.child!==t.child)throw Error(a(153));if(s.child!==null){for(t=s.child,i=Es(t,t.pendingProps),s.child=i,i.return=s;t.sibling!==null;)t=t.sibling,i=i.sibling=Es(t,t.pendingProps),i.return=s;i.sibling=null}return s.child}function um(t,s){return(t.lanes&s)!==0?!0:(t=t.dependencies,!!(t!==null&&oc(t)))}function K_(t,s,i){switch(s.tag){case 3:ie(s,s.stateNode.containerInfo),nr(s,zt,t.memoizedState.cache),li();break;case 27:case 5:Ee(s);break;case 4:ie(s,s.stateNode.containerInfo);break;case 10:nr(s,s.type,s.memoizedProps.value);break;case 13:var u=s.memoizedState;if(u!==null)return u.dehydrated!==null?(ir(s),s.flags|=128,null):(i&s.child.childLanes)!==0?q0(t,s,i):(ir(s),t=Ds(t,s,i),t!==null?t.sibling:null);ir(s);break;case 19:var p=(t.flags&128)!==0;if(u=(i&s.childLanes)!==0,u||(ui(t,s,i,!1),u=(i&s.childLanes)!==0),p){if(u)return Y0(t,s,i);s.flags|=128}if(p=s.memoizedState,p!==null&&(p.rendering=null,p.tail=null,p.lastEffect=null),V(It,It.current),u)break;return null;case 22:case 23:return s.lanes=0,P0(t,s,i);case 24:nr(s,zt,t.memoizedState.cache)}return Ds(t,s,i)}function G0(t,s,i){if(t!==null)if(t.memoizedProps!==s.pendingProps)Ut=!0;else{if(!um(t,i)&&(s.flags&128)===0)return Ut=!1,K_(t,s,i);Ut=(t.flags&131072)!==0}else Ut=!1,ct&&(s.flags&1048576)!==0&&jx(s,rc,s.index);switch(s.lanes=0,s.tag){case 16:e:{t=s.pendingProps;var u=s.elementType,p=u._init;if(u=p(u._payload),s.type=u,typeof u=="function")vf(u)?(t=no(u,t),s.tag=1,s=B0(null,s,u,t,i)):(s.tag=0,s=sm(null,s,u,t,i));else{if(u!=null){if(p=u.$$typeof,p===M){s.tag=11,s=I0(null,s,u,t,i);break e}else if(p===z){s.tag=14,s=L0(null,s,u,t,i);break e}}throw s=B(u)||u,Error(a(306,s,""))}}return s;case 0:return sm(t,s,s.type,s.pendingProps,i);case 1:return u=s.type,p=no(u,s.pendingProps),B0(t,s,u,p,i);case 3:e:{if(ie(s,s.stateNode.containerInfo),t===null)throw Error(a(387));u=s.pendingProps;var v=s.memoizedState;p=v.element,Df(t,s),xi(s,u,null,i);var k=s.memoizedState;if(u=k.cache,nr(s,zt,u),u!==v.cache&&Cf(s,[zt],i,!0),gi(),u=k.element,v.isDehydrated)if(v={element:u,isDehydrated:!1,cache:k.cache},s.updateQueue.baseState=v,s.memoizedState=v,s.flags&256){s=V0(t,s,u,i);break e}else if(u!==p){p=Tn(Error(a(424)),s),ci(p),s=V0(t,s,u,i);break e}else{switch(t=s.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(jt=Vn(t.firstChild),tn=s,ct=!0,Wr=null,ts=!0,i=_0(s,null,u,i),s.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(li(),u===p){s=Ds(t,s,i);break e}Yt(t,s,u,i)}s=s.child}return s;case 26:return Sc(t,s),t===null?(i=Ky(s.type,null,s.pendingProps,null))?s.memoizedState=i:ct||(i=s.type,t=s.pendingProps,u=Pc(fe.current).createElement(i),u[Ht]=s,u[Kt]=t,Xt(u,i,t),Tt(u),s.stateNode=u):s.memoizedState=Ky(s.type,t.memoizedProps,s.pendingProps,t.memoizedState),null;case 27:return Ee(s),t===null&&ct&&(u=s.stateNode=Xy(s.type,s.pendingProps,fe.current),tn=s,ts=!0,p=jt,xr(s.type)?(qm=p,jt=Vn(u.firstChild)):jt=p),Yt(t,s,s.pendingProps.children,i),Sc(t,s),t===null&&(s.flags|=4194304),s.child;case 5:return t===null&&ct&&((p=u=jt)&&(u=_E(u,s.type,s.pendingProps,ts),u!==null?(s.stateNode=u,tn=s,jt=Vn(u.firstChild),ts=!1,p=!0):p=!1),p||Kr(s)),Ee(s),p=s.type,v=s.pendingProps,k=t!==null?t.memoizedProps:null,u=v.children,Hm(p,v)?u=null:k!==null&&Hm(p,k)&&(s.flags|=32),s.memoizedState!==null&&(p=Pf(t,s,V_,null,null,i),Pi._currentValue=p),Sc(t,s),Yt(t,s,u,i),s.child;case 6:return t===null&&ct&&((t=i=jt)&&(i=EE(i,s.pendingProps,ts),i!==null?(s.stateNode=i,tn=s,jt=null,t=!0):t=!1),t||Kr(s)),null;case 13:return q0(t,s,i);case 4:return ie(s,s.stateNode.containerInfo),u=s.pendingProps,t===null?s.child=Xo(s,null,u,i):Yt(t,s,u,i),s.child;case 11:return I0(t,s,s.type,s.pendingProps,i);case 7:return Yt(t,s,s.pendingProps,i),s.child;case 8:return Yt(t,s,s.pendingProps.children,i),s.child;case 12:return Yt(t,s,s.pendingProps.children,i),s.child;case 10:return u=s.pendingProps,nr(s,s.type,u.value),Yt(t,s,u.children,i),s.child;case 9:return p=s.type._context,u=s.pendingProps.children,Jr(s),p=Qt(p),u=u(p),s.flags|=1,Yt(t,s,u,i),s.child;case 14:return L0(t,s,s.type,s.pendingProps,i);case 15:return $0(t,s,s.type,s.pendingProps,i);case 19:return Y0(t,s,i);case 31:return u=s.pendingProps,i=s.mode,u={mode:u.mode,children:u.children},t===null?(i=_c(u,i),i.ref=s.ref,s.child=i,i.return=s,s=i):(i=Es(t.child,u),i.ref=s.ref,s.child=i,i.return=s,s=i),s;case 22:return P0(t,s,i);case 24:return Jr(s),u=Qt(zt),t===null?(p=Af(),p===null&&(p=yt,v=kf(),p.pooledCache=v,v.refCount++,v!==null&&(p.pooledCacheLanes|=i),p=v),s.memoizedState={parent:u,cache:p},Rf(s),nr(s,zt,p)):((t.lanes&i)!==0&&(Df(t,s),xi(s,null,null,i),gi()),p=t.memoizedState,v=s.memoizedState,p.parent!==u?(p={parent:u,cache:u},s.memoizedState=p,s.lanes===0&&(s.memoizedState=s.updateQueue.baseState=p),nr(s,zt,u)):(u=v.cache,nr(s,zt,u),u!==p.cache&&Cf(s,[zt],i,!0))),Yt(t,s,s.pendingProps.children,i),s.child;case 29:throw s.pendingProps}throw Error(a(156,s.tag))}function Os(t){t.flags|=4}function X0(t,s){if(s.type!=="stylesheet"||(s.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!nv(s)){if(s=Dn.current,s!==null&&((it&4194048)===it?ns!==null:(it&62914560)!==it&&(it&536870912)===0||s!==ns))throw hi=Mf,Mx;t.flags|=8192}}function Ec(t,s){s!==null&&(t.flags|=4),t.flags&16384&&(s=t.tag!==22?Pe():536870912,t.lanes|=s,Qo|=s)}function Si(t,s){if(!ct)switch(t.tailMode){case"hidden":s=t.tail;for(var i=null;s!==null;)s.alternate!==null&&(i=s),s=s.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?s||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function Nt(t){var s=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(s)for(var p=t.child;p!==null;)i|=p.lanes|p.childLanes,u|=p.subtreeFlags&65011712,u|=p.flags&65011712,p.return=t,p=p.sibling;else for(p=t.child;p!==null;)i|=p.lanes|p.childLanes,u|=p.subtreeFlags,u|=p.flags,p.return=t,p=p.sibling;return t.subtreeFlags|=u,t.childLanes=i,s}function Q_(t,s,i){var u=s.pendingProps;switch(jf(s),s.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Nt(s),null;case 1:return Nt(s),null;case 3:return i=s.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),s.memoizedState.cache!==u&&(s.flags|=2048),As(zt),ge(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(ii(s)?Os(s):t===null||t.memoizedState.isDehydrated&&(s.flags&256)===0||(s.flags|=1024,Ex())),Nt(s),null;case 26:return i=s.memoizedState,t===null?(Os(s),i!==null?(Nt(s),X0(s,i)):(Nt(s),s.flags&=-16777217)):i?i!==t.memoizedState?(Os(s),Nt(s),X0(s,i)):(Nt(s),s.flags&=-16777217):(t.memoizedProps!==u&&Os(s),Nt(s),s.flags&=-16777217),null;case 27:Ne(s),i=fe.current;var p=s.type;if(t!==null&&s.stateNode!=null)t.memoizedProps!==u&&Os(s);else{if(!u){if(s.stateNode===null)throw Error(a(166));return Nt(s),null}t=J.current,ii(s)?Sx(s):(t=Xy(p,u,i),s.stateNode=t,Os(s))}return Nt(s),null;case 5:if(Ne(s),i=s.type,t!==null&&s.stateNode!=null)t.memoizedProps!==u&&Os(s);else{if(!u){if(s.stateNode===null)throw Error(a(166));return Nt(s),null}if(t=J.current,ii(s))Sx(s);else{switch(p=Pc(fe.current),t){case 1:t=p.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=p.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=p.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=p.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=p.createElement("div"),t.innerHTML="