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/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/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 8be991598a..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; @@ -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 3525bb7a98..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 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 ab5da71a3c..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; @@ -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_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 50b0545be3..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; @@ -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/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/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.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/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/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/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index daad8d79a1..a8268863bd 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -43,27 +43,6 @@ lib/net10.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/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 - 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 - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -106,27 +85,6 @@ lib/net472/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net472/Microsoft.Agents.AI.dll - lib/net472/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 - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -169,27 +127,6 @@ lib/net8.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - 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/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/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -232,27 +169,6 @@ lib/net9.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - 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/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/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -295,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 79595f17a2..972dbee53f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -32,15 +32,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - // "." means the skill directory root itself (no sub-folder descent constraint) - private const string RootFolderIndicator = "."; + // "." 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 sub-folder names per https://agentskills.io/specification#directory-structure - private static readonly string[] s_defaultScriptFolders = ["scripts"]; - private static readonly string[] s_defaultResourceFolders = ["references", "assets"]; + // 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. @@ -63,8 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; - private readonly IReadOnlyList _scriptFolders; - private readonly IReadOnlyList _resourceFolders; + private readonly IReadOnlyList _scriptDirectories; + private readonly IReadOnlyList _resourceDirectories; private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; @@ -111,13 +111,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource options?.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); - this._scriptFolders = options?.ScriptFolders is not null - ? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)] - : s_defaultScriptFolders; + this._scriptDirectories = options?.ScriptDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)] + : s_defaultScriptDirectories; - this._resourceFolders = options?.ResourceFolders is not null - ? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)] - : s_defaultResourceFolders; + this._resourceDirectories = options?.ResourceDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)] + : s_defaultResourceDirectories; this._scriptRunner = scriptRunner; } @@ -303,12 +303,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Scans configured resource folders within 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. /// /// - /// By default, scans references/ and assets/ sub-folders as specified by the + /// By default, scans references/ and assets/ subdirectories as specified by the /// Agent Skills specification. - /// Configure to scan different or + /// 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. /// @@ -316,14 +316,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { var resources = new List(); - foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) { - bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal); + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootFolder + string targetDirectory = isRootDirectory ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar; + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; if (!Directory.Exists(targetDirectory)) { @@ -331,13 +331,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root folder is excluded — it's a caller-supplied + // 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 (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder)); + LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); } continue; @@ -380,7 +380,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource // e.g. "references/../../../etc/shadow" → "/etc/shadow" string resolvedFilePath = Path.GetFullPath(filePath); - // Path containment: reject if the resolved path escapes the target folder. + // 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)) { @@ -416,12 +416,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Scans configured script folders within 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. /// /// - /// By default, scans the scripts/ sub-folder as specified by the + /// By default, scans the scripts/ subdirectory as specified by the /// Agent Skills specification. - /// Configure to scan different or + /// 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. /// @@ -429,14 +429,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { var scripts = new List(); - foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) { - bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal); + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootFolder + string targetDirectory = isRootDirectory ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar; + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; if (!Directory.Exists(targetDirectory)) { @@ -444,13 +444,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root folder is excluded — it's a caller-supplied + // 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 (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder)); + LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); } continue; @@ -480,7 +480,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource // e.g. "scripts/../../../etc/shadow" → "/etc/shadow" string resolvedFilePath = Path.GetFullPath(filePath); - // Path containment: reject if the resolved path escapes the target folder. + // 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)) { @@ -541,8 +541,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Normalizes a relative path or folder name by stripping a leading "./"/".\", - /// trimming trailing directory separators, and replacing backslashes with forward + /// 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) @@ -602,36 +602,36 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } } - private static IEnumerable ValidateAndNormalizeFolderNames(IEnumerable folders, ILogger logger) + private static IEnumerable ValidateAndNormalizeDirectoryNames(IEnumerable directories, ILogger logger) { - foreach (string folder in folders) + foreach (string directory in directories) { - if (string.IsNullOrWhiteSpace(folder)) + if (string.IsNullOrWhiteSpace(directory)) { - throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders)); + throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories)); } // "." is valid — it means the skill root directory. - if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)) + if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal)) { - yield return folder; + yield return directory; continue; } // Reject absolute paths and any path segments that escape upward. - if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder)) + if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory)) { - LogFolderNameSkippedInvalid(logger, folder); + LogDirectoryNameSkippedInvalid(logger, directory); continue; } - yield return NormalizePath(folder); + yield return NormalizePath(directory); } } - private static bool ContainsParentTraversalSegment(string folder) + private static bool ContainsParentTraversalSegment(string directory) { - foreach (string segment in folder.Split('/', '\\')) + foreach (string segment in directory.Split('/', '\\')) { if (segment == "..") { @@ -666,8 +666,8 @@ 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 folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")] - private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName); + [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); @@ -678,9 +678,9 @@ 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 folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")] - private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName); + [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 folder name '{FolderName}': must be a relative path with no '..' segments")] - private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName); + [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 fcd9398104..b5c83c0220 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -32,7 +32,7 @@ public sealed class AgentFileSkillsSourceOptions public IEnumerable? AllowedScriptExtensions { get; set; } /// - /// Gets or sets relative folder paths to scan for script files within each skill directory. + /// 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 @@ -42,10 +42,10 @@ public sealed class AgentFileSkillsSourceOptions /// Agent Skills specification). /// When set, replaces the defaults entirely. /// - public IEnumerable? ScriptFolders { get; set; } + public IEnumerable? ScriptDirectories { get; set; } /// - /// Gets or sets relative folder paths to scan for resource files within each skill directory. + /// 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 @@ -55,5 +55,5 @@ public sealed class AgentFileSkillsSourceOptions /// Agent Skills specification). /// When set, replaces the defaults entirely. /// - public IEnumerable? ResourceFolders { get; set; } + 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 4febb8bc79..b44f423bc2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -1,8 +1,12 @@ // 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; @@ -11,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; @@ -44,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. @@ -65,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. /// @@ -72,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); /// @@ -83,11 +193,11 @@ public abstract class AgentClassSkill : AgentSkill /// An optional description of the resource. /// /// Optional used to marshal the delegate's parameters and return value. - /// When , is used. + /// When , falls back to . /// /// A new instance. - protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillResource(name, method, description, serializerOptions); + 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. @@ -97,9 +207,129 @@ public abstract class AgentClassSkill : AgentSkill /// An optional description of the script. /// /// Optional used to marshal the delegate's parameters and return value. - /// When , is used. + /// When , falls back to . /// /// A new instance. - protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillScript(name, method, description, serializerOptions); + 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/AgentInlineSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs index 5e032f073f..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,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -54,6 +55,28 @@ internal sealed class AgentInlineSkillResource : AgentSkillResource 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); + } + /// public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 232a2fefce..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; @@ -39,6 +40,27 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript 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); + } + /// /// Gets the JSON schema describing the parameters accepted by this script, or if not available. /// 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.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 f03c2d65fe..1dc7d5b3f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -2,64 +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] @@ -85,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 @@ -135,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."; @@ -340,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(); @@ -347,75 +676,17 @@ 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 - { - public CustomResource(string name, string? description = null) - : base(name, description) - { - } - - public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) - => Task.FromResult("resource-value"); - } - - private sealed class CustomScript : AgentSkillScript - { - public CustomScript(string name, string? description = null) - : base(name, description) - { - } - - public override Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) - => Task.FromResult("script-result"); - } - - #endregion - - [Fact] - public async Task CreateScript_WithSerializerOptions_DeserializesCustomInputTypeAsync() - { - // Arrange - var skill = new CustomTypeSkill(); - var jso = SkillTestJsonContext.Default.Options; - - // Act — pass a custom type as JSON; the JSO enables deserialization - var script = skill.Scripts![0]; - var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); - - // Assert — the custom input type was deserialized and the response was produced - Assert.NotNull(result); - var resultText = result!.ToString()!; - Assert.Contains("result for test", resultText); - Assert.Contains("5", resultText); - } - - [Fact] - public async Task CreateResource_WithSerializerOptions_SerializesReturnsCustomTypeAsync() - { - // Arrange - var skill = new CustomTypeSkill(); - - // Act - var result = await skill.Resources![0].ReadAsync(); - - // Assert — the custom type was returned successfully - Assert.NotNull(result); - Assert.Contains("dark", result!.ToString()!); - } - - private sealed class CustomTypeSkill : AgentClassSkill + private sealed class CustomTypeSkill : AgentClassSkill { public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources."); @@ -423,7 +694,7 @@ public sealed class AgentClassSkillTests public override IReadOnlyList? Resources => [ - CreateResource("config", () => new SkillConfig + this.CreateResource("config", () => new SkillConfig { Theme = "dark", Verbose = true @@ -432,11 +703,326 @@ public sealed class AgentClassSkillTests public override IReadOnlyList? Scripts => [ - CreateScript("Lookup", (LookupRequest request) => new LookupResponse + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse { Items = [$"result for {request.Query}"], TotalCount = request.MaxResults, }, serializerOptions: SkillTestJsonContext.Default.Options), ]; } + +#pragma warning disable IDE0051 // Remove unused private members + private sealed class AttributedScriptsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-scripts", "Skill with various attributed scripts."); + + 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 d524b6142a..f1ca662202 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -116,8 +116,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable [Fact] public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync() { - // Arrange — scripts outside configured folders are not discovered; only files directly - // inside the configured folder are picked up (no subdirectory recursion) + // 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'"); @@ -126,7 +126,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — neither file is in the default scripts/ folder, so no scripts are discovered + // Assert — neither file is in the default scripts/ directory, so no scripts are discovered Assert.Single(skills); Assert.Empty(skills[0].Scripts!); } @@ -229,18 +229,18 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync() + public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync() { - // Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3) - string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body."); + // 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 { ScriptFolders = ["f1/f2/f3"] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] }); // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — script file inside the deeply nested folder is discovered + // 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); @@ -250,29 +250,29 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable [InlineData("./scripts")] [InlineData("./scripts/f1")] [InlineData("./scripts/f1", "./f2")] - public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders) + public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories) { - // Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix; + // 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 folder in folders) + foreach (string directory in directories) { - string folderWithoutDotSlash = folder.Substring(2); // strip "./" - CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')"); + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" + CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')"); } var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = folders }); + new AgentFileSkillsSourceOptions { ScriptDirectories = directories }); // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — scripts are discovered with names identical to using folders without "./" + // Assert — scripts are discovered with names identical to using directories without "./" Assert.Single(skills); - Assert.Equal(folders.Length, skills[0].Scripts!.Count); - foreach (string folder in folders) + Assert.Equal(directories.Length, skills[0].Scripts!.Count); + foreach (string directory in directories) { - string expectedName = $"{folder.Substring(2)}/run.py"; + string expectedName = $"{directory.Substring(2)}/run.py"; Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs index 3901e61c7c..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; @@ -167,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 5d5dc5bd02..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,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -152,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/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 ca0884ea43..c43568acd9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -199,7 +199,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { - // Arrange — create resource files in spec-defined sub-folders + // Arrange — create resource files in spec-defined subdirectories string skillDir = Path.Combine(this._testRoot, "resource-skill"); string refsDir = Path.Combine(skillDir, "references"); string assetsDir = Path.Combine(skillDir, "assets"); @@ -226,7 +226,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { - // Arrange — create a file with an extension not in the default list inside a spec folder + // Arrange — create a file with an extension not in the default list inside a spec directory string skillDir = Path.Combine(this._testRoot, "ext-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -300,7 +300,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a source with custom extensions; files placed in spec folder + // Arrange — use a source with custom extensions; files placed in spec directory string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -364,7 +364,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync() { - // Arrange — resource files directly in the skill directory (not in a spec sub-folder) + // 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"); @@ -377,15 +377,15 @@ public sealed class FileAgentSkillLoaderTests : IDisposable // Act var skills = await source.GetSkillsAsync(); - // Assert — root-level files are NOT discovered unless "." is in ResourceFolders + // 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_DiscoveredWhenRootFolderConfiguredAsync() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() { - // Arrange — "." in ResourceFolders opts into root-level resource discovery + // 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"); @@ -394,7 +394,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable 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 { ResourceFolders = ["references", "assets", "."] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] }); // Act var skills = await source.GetSkillsAsync(); @@ -408,31 +408,31 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync() + public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync() { - // Arrange — resource in a non-spec folder (neither references/ nor assets/) + // 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 folder\n---\nBody."); + "---\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 folders are not scanned by default + // Assert — non-spec directories are not scanned by default Assert.Single(skills); Assert.Empty(skills[0].Resources!); } [Fact] - public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync() + public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync() { - // Arrange — custom ResourceFolders replaces the spec defaults - string skillDir = Path.Combine(this._testRoot, "custom-folder-skill"); + // 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); @@ -441,9 +441,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody."); + "---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] }); // Act var skills = await source.GetSkillsAsync(); @@ -518,7 +518,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() { - // Arrange — create a skill with a resource file discovered from the references folder + // 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, "references"); Directory.CreateDirectory(refsDir); @@ -614,12 +614,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync() + 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-folder-skip"); + 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"); @@ -642,21 +642,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody."); + "---\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/ folder is skipped entirely - var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip"); + // 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_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync() + public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync() { // Arrange — scripts/ is a symlink pointing outside the skill directory. // The directory-level check should skip it entirely. @@ -679,22 +679,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody."); + "---\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 folder are not discovered + // 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_SkipsCustomFolderAsync() + public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync() { - // Arrange — custom resource folder "sub/resources" where "sub" is a symlink. + // 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); @@ -720,12 +720,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable var source = new AgentFileSkillsSource( this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — the symlinked intermediate segment causes the folder to be skipped + // 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!); @@ -900,11 +900,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData("sub/../escape")] [InlineData("/absolute")] [InlineData("\\absolute")] - public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder) + public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory) { - // Arrange & Act — invalid folders are skipped with a warning rather than throwing - var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] }); - var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] }); + // 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); @@ -915,54 +915,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder) + 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 { ScriptFolders = [badFolder!] })); - Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] })); + 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/folder")] + [InlineData("sub/directory")] [InlineData(".")] [InlineData("./scripts")] [InlineData("./scripts/f1")] [InlineData("my..scripts")] - public void Constructor_ValidFolderName_DoesNotThrow(string validFolder) + public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory) { // Arrange & Act & Assert - var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] }); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] }); Assert.NotNull(source); } [Fact] - public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync() + 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-folder-skill"); + 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-folder-skill\ndescription: Dedup test\n---\nBody."); + "---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — only one copy of the resource despite two equivalent folder entries + // 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_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync() + public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync() { // Arrange — "references/" should be normalized to "references" string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill"); @@ -973,7 +973,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable 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 { ResourceFolders = ["references", "references/"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] }); // Act var skills = await source.GetSkillsAsync(); @@ -985,7 +985,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync() + public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync() { // Arrange — ".\\scripts" should be normalized to "scripts" string skillDir = Path.Combine(this._testRoot, "backslash-skill"); @@ -996,7 +996,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Path.Combine(skillDir, "SKILL.md"), "---\nname: backslash-skill\ndescription: Backslash test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] }); // Act var skills = await source.GetSkillsAsync(); @@ -1010,48 +1010,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Theory] [InlineData("./references")] [InlineData("./assets/docs")] - public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder) + 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 folderWithoutDotSlash = folder.Substring(2); // strip "./" + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill"); - string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar)); + 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 { ResourceFolders = [folder] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — the resource is discovered with a name identical to using the folder without "./" + // Assert — the resource is discovered with a name identical to using the directory without "./" Assert.Single(skills); Assert.Single(skills[0].Resources!); - Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name); + Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync() + public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync() { - // Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3) - string skillDir = Path.Combine(this._testRoot, "nested-folder-skill"); + // 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-folder-skill\ndescription: Nested folder\n---\nBody."); + "---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — resource file inside the deeply nested folder is discovered + // Assert — resource file inside the deeply nested directory is discovered Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); @@ -1107,9 +1107,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync() + public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() { - // Arrange — script file directly in the skill directory with ScriptFolders = ["."] + // 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')"); @@ -1117,7 +1117,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Path.Combine(skillDir, "SKILL.md"), "---\nname: root-script-skill\ndescription: Root script\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["."] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] }); // Act var skills = await source.GetSkillsAsync(); @@ -1131,7 +1131,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable #if NET [Fact] - public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync() + 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. 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/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..99947710c9 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +895,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/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/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..81d5fadbbe 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 @@ -128,6 +128,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.""" @@ -462,12 +463,39 @@ def _emit_mcp_tool_result( return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) +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]: """Emit AG-UI reasoning events for text_reasoning content. 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 +511,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 +597,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/_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/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/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/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..1b6257f203 --- /dev/null +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -0,0 +1,426 @@ +# 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 data serialization. Only load + checkpoints from trusted sources. Loading a malicious checkpoint can execute + arbitrary code. + + 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, + ) -> 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. + """ + self._cosmos_client: CosmosClient | None = cosmos_client + self._container_proxy: ContainerProxy | None = container_client + self._owns_client = False + + 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"), + ) + + @staticmethod + def _document_to_checkpoint(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) + 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..52155d0e21 --- /dev/null +++ b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py @@ -0,0 +1,597 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +import uuid +from collections.abc import AsyncIterator +from contextlib import suppress +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) 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/_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/pyproject.toml b/python/packages/core/pyproject.toml index e4aa1f5e40..e26e1be6c9 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" 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_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/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/frontend/README.md b/python/packages/devui/frontend/README.md index ae7bec5b39..8c6c4a0009 100644 --- a/python/packages/devui/frontend/README.md +++ b/python/packages/devui/frontend/README.md @@ -51,7 +51,7 @@ export default tseslint.config([ ]) ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index fd3bc62fe2..b12fbef49b 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API 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://github.com/microsoft/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", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index a7c325442c..8e95b3920f 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -4,7 +4,7 @@ description = "Durable Task 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", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/durabletask/tests/test_executors.py b/python/packages/durabletask/tests/test_executors.py index 2333f08106..8cc9900037 100644 --- a/python/packages/durabletask/tests/test_executors.py +++ b/python/packages/durabletask/tests/test_executors.py @@ -50,6 +50,7 @@ def mock_orchestration_context(mock_entity_task: Mock) -> Mock: """Provide a mock orchestration context with call_entity configured.""" context = Mock() context.call_entity = Mock(return_value=mock_entity_task) + context.new_uuid = Mock(return_value="test-uuid-1234") return context diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 335d7ef394..69d58ee3e5 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework." 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" @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", - "agent-framework-openai>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", + "agent-framework-openai>=1.0.1,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.0.0,<3.0", ] diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 1ee093516a..3002cd0d69 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Local 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,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", - "agent-framework-openai>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", + "agent-framework-openai>=1.0.1,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 4599cd3526..4744013b56 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -7,7 +7,7 @@ import contextlib import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload +from typing import Any, ClassVar, Generic, Literal, TypedDict, overload from agent_framework import ( AgentMiddlewareTypes, @@ -29,20 +29,11 @@ from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import AgentException try: - from copilot import CopilotClient, CopilotSession + from copilot import CopilotClient, CopilotSession, SubprocessConfig from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType - from copilot.types import ( - CopilotClientOptions, - MCPServerConfig, - MessageOptions, - PermissionRequestResult, - ResumeSessionConfig, - SessionConfig, - SystemMessageConfig, - ToolInvocation, - ToolResult, - ) - from copilot.types import Tool as CopilotTool + from copilot.session import MCPServerConfig, PermissionRequestResult, SystemMessageConfig + from copilot.tools import Tool as CopilotTool + from copilot.tools import ToolInvocation, ToolResult except ImportError as _copilot_import_error: raise ImportError( "GitHubCopilotAgent requires the 'github-copilot-sdk' package, which is only available on Python 3.11+. " @@ -64,6 +55,14 @@ PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], Permission logger = logging.getLogger("agent_framework.github_copilot") +def _deny_all_permissions( + _request: PermissionRequest, + _invocation: dict[str, str], +) -> PermissionRequestResult: + """Default permission handler that denies all requests.""" + return PermissionRequestResult() + + class GitHubCopilotSettings(TypedDict, total=False): """GitHub Copilot model settings. @@ -274,16 +273,13 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): return if self._client is None: - client_options: CopilotClientOptions = {} - cli_path = self._settings.get("cli_path") - if cli_path: - client_options["cli_path"] = cli_path + cli_path = self._settings.get("cli_path") or None + log_level = self._settings.get("log_level") or None - log_level = self._settings.get("log_level") + subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path} if log_level: - client_options["log_level"] = log_level # type: ignore[typeddict-item] - - self._client = CopilotClient(client_options if client_options else None) + subprocess_kwargs["log_level"] = log_level + self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs)) try: await self._client.start() @@ -407,10 +403,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt - message_options = cast(MessageOptions, {"prompt": prompt}) try: - response_event = await copilot_session.send_and_wait(message_options, timeout=timeout) + response_event = await copilot_session.send_and_wait(prompt, timeout=timeout) except Exception as ex: raise AgentException(f"GitHub Copilot request failed: {ex}") from ex @@ -489,7 +484,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt - message_options = cast(MessageOptions, {"prompt": prompt}) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() @@ -550,7 +544,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): unsubscribe = copilot_session.on(event_handler) try: - await copilot_session.send(message_options) + await copilot_session.send(prompt) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -730,43 +724,35 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") opts = runtime_options or {} - config: SessionConfig = {"streaming": streaming} + model = opts.get("model") or self._settings.get("model") or None + system_message = opts.get("system_message") or self._default_options.get("system_message") or None + permission_handler: PermissionHandlerType = ( + opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions + ) + mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None + tools = self._prepare_tools(self._tools) if self._tools else None - model = opts.get("model") or self._settings.get("model") - if model: - config["model"] = model # type: ignore[typeddict-item] - - system_message = opts.get("system_message") or self._default_options.get("system_message") - if system_message: - config["system_message"] = system_message - - if self._tools: - config["tools"] = self._prepare_tools(self._tools) - - permission_handler = opts.get("on_permission_request") or self._permission_handler - if permission_handler: - config["on_permission_request"] = permission_handler - - mcp_servers = opts.get("mcp_servers") or self._mcp_servers - if mcp_servers: - config["mcp_servers"] = mcp_servers - - return await self._client.create_session(config) + return await self._client.create_session( + on_permission_request=permission_handler, + streaming=streaming, + model=model or None, + system_message=system_message or None, + tools=tools or None, + mcp_servers=mcp_servers or None, + ) async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession: """Resume an existing Copilot session by ID.""" if not self._client: raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") - config: ResumeSessionConfig = {"streaming": streaming} + permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions + tools = self._prepare_tools(self._tools) if self._tools else None - if self._tools: - config["tools"] = self._prepare_tools(self._tools) - - if self._permission_handler: - config["on_permission_request"] = self._permission_handler - - if self._mcp_servers: - config["mcp_servers"] = self._mcp_servers - - return await self._client.resume_session(session_id, config) + return await self._client.resume_session( + session_id, + on_permission_request=permission_handler, + streaming=streaming, + tools=tools or None, + mcp_servers=self._mcp_servers or None, + ) diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index aecdaa02f9..90766cb7f6 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -4,7 +4,7 @@ description = "GitHub Copilot 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,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", - "github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'", + "agent-framework-core>=1.0.1,<2", + "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] [tool.uv] @@ -102,3 +102,4 @@ interpreter = "posix" [build-system] requires = ["flit-core >= 3.11,<4.0"] build-backend = "flit_core.buildapi" + diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index e91a725765..17e9432e40 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -23,7 +23,7 @@ from agent_framework import ( ) from agent_framework.exceptions import AgentException from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType -from copilot.types import ToolInvocation, ToolResult +from copilot.tools import ToolInvocation, ToolResult from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions @@ -268,8 +268,8 @@ class TestGitHubCopilotAgentLifecycle: await agent.start() call_args = MockClient.call_args[0][0] - assert call_args["cli_path"] == "/custom/path" - assert call_args["log_level"] == "debug" + assert call_args.cli_path == "/custom/path" + assert call_args.log_level == "debug" class TestGitHubCopilotAgentRun: @@ -855,7 +855,13 @@ class TestGitHubCopilotAgentSessionManagement: await agent.run("World", session=session) mock_client.create_session.assert_called_once() - mock_client.resume_session.assert_called_once_with(mock_session.session_id, unittest.mock.ANY) + mock_client.resume_session.assert_called_once_with( + mock_session.session_id, + on_permission_request=unittest.mock.ANY, + streaming=unittest.mock.ANY, + tools=unittest.mock.ANY, + mcp_servers=unittest.mock.ANY, + ) async def test_session_config_includes_model( self, @@ -871,7 +877,7 @@ class TestGitHubCopilotAgentSessionManagement: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["model"] == "claude-sonnet-4" async def test_session_config_includes_instructions( @@ -889,7 +895,7 @@ class TestGitHubCopilotAgentSessionManagement: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["system_message"]["mode"] == "append" assert config["system_message"]["content"] == "You are a helpful assistant." @@ -914,7 +920,7 @@ class TestGitHubCopilotAgentSessionManagement: ) call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["system_message"]["mode"] == "replace" assert config["system_message"]["content"] == "Runtime instructions" @@ -930,7 +936,7 @@ class TestGitHubCopilotAgentSessionManagement: await agent._get_or_create_session(AgentSession(), streaming=True) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["streaming"] is True async def test_resume_session_with_existing_service_session_id( @@ -958,7 +964,8 @@ class TestGitHubCopilotAgentSessionManagement: mock_session: MagicMock, ) -> None: """Test that resumed session config includes tools and permission handler.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: return PermissionRequestResult(kind="approved") @@ -981,7 +988,7 @@ class TestGitHubCopilotAgentSessionManagement: mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args - config = call_args[0][1] + config = call_args.kwargs assert "tools" in config assert "on_permission_request" in config @@ -995,7 +1002,7 @@ class TestGitHubCopilotAgentMCPServers: mock_session: MagicMock, ) -> None: """Test that mcp_servers are passed through to create_session config.""" - from copilot.types import MCPServerConfig + from copilot.session import MCPServerConfig mcp_servers: dict[str, MCPServerConfig] = { "filesystem": { @@ -1020,7 +1027,7 @@ class TestGitHubCopilotAgentMCPServers: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "mcp_servers" in config assert "filesystem" in config["mcp_servers"] assert "remote" in config["mcp_servers"] @@ -1033,7 +1040,7 @@ class TestGitHubCopilotAgentMCPServers: mock_session: MagicMock, ) -> None: """Test that mcp_servers are passed through to resume_session config.""" - from copilot.types import MCPServerConfig + from copilot.session import MCPServerConfig mcp_servers: dict[str, MCPServerConfig] = { "test-server": { @@ -1057,7 +1064,7 @@ class TestGitHubCopilotAgentMCPServers: mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args - config = call_args[0][1] + config = call_args.kwargs assert "mcp_servers" in config assert "test-server" in config["mcp_servers"] @@ -1073,8 +1080,8 @@ class TestGitHubCopilotAgentMCPServers: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "mcp_servers" not in config + config = call_args.kwargs + assert config["mcp_servers"] is None class TestGitHubCopilotAgentToolConversion: @@ -1097,7 +1104,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "tools" in config assert len(config["tools"]) == 1 assert config["tools"][0].name == "my_tool" @@ -1120,7 +1127,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"})) @@ -1146,7 +1153,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"})) @@ -1173,7 +1180,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] with pytest.raises((TypeError, AttributeError)): @@ -1196,7 +1203,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={})) @@ -1210,7 +1217,7 @@ class TestGitHubCopilotAgentToolConversion: mock_client: MagicMock, ) -> None: """Test that CopilotTool instances are passed through as-is.""" - from copilot.types import Tool as CopilotTool + from copilot.tools import Tool as CopilotTool async def tool_handler(invocation: Any) -> Any: return {"text_result_for_llm": "result", "result_type": "success"} @@ -1234,7 +1241,7 @@ class TestGitHubCopilotAgentToolConversion: ) -> None: """Test that mixed tool types are handled correctly.""" from agent_framework import tool - from copilot.types import Tool as CopilotTool + from copilot.tools import Tool as CopilotTool @tool(approval_mode="never_require") def my_function(arg: str) -> str: @@ -1317,10 +1324,11 @@ class TestGitHubCopilotAgentPermissions: def test_permission_handler_set_when_provided(self) -> None: """Test that a handler is set when on_permission_request is provided.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: - if request.get("kind") == "shell": + if request.kind == "shell": return PermissionRequestResult(kind="approved") return PermissionRequestResult(kind="denied-interactively-by-user") @@ -1335,10 +1343,11 @@ class TestGitHubCopilotAgentPermissions: mock_session: MagicMock, ) -> None: """Test that session config includes permission handler when provided.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: - if request.get("kind") in ("shell", "read"): + if request.kind in ("shell", "read"): return PermissionRequestResult(kind="approved") return PermissionRequestResult(kind="denied-interactively-by-user") @@ -1351,24 +1360,29 @@ class TestGitHubCopilotAgentPermissions: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "on_permission_request" in config assert config["on_permission_request"] is not None - async def test_session_config_excludes_permission_handler_when_not_set( + async def test_session_config_uses_deny_all_when_no_permission_handler_set( self, mock_client: MagicMock, mock_session: MagicMock, ) -> None: - """Test that session config does not include permission handler when not set.""" + """Test that session config uses deny-all handler when no permission handler is set. + + In SDK 0.2.x, on_permission_request is required by create_session, so the agent + always falls back to _deny_all_permissions when no handler is provided. + """ agent = GitHubCopilotAgent(client=mock_client) await agent.start() await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "on_permission_request" not in config + config = call_args.kwargs + assert "on_permission_request" in config + assert config["on_permission_request"] is not None class SpyContextProvider(ContextProvider): @@ -1454,7 +1468,7 @@ class TestGitHubCopilotAgentContextProviders: session = agent.create_session() await agent.run("Hello", session=session) - sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + sent_prompt = mock_session.send_and_wait.call_args[0][0] assert "Injected by spy provider" in sent_prompt async def test_after_run_receives_response( @@ -1547,7 +1561,7 @@ class TestGitHubCopilotAgentContextProviders: async for _ in agent.run("Hello", stream=True, session=session): pass - sent_prompt = mock_session.send.call_args[0][0]["prompt"] + sent_prompt = mock_session.send.call_args[0][0] assert "Injected by spy provider" in sent_prompt async def test_context_preserved_across_runs( @@ -1608,7 +1622,7 @@ class TestGitHubCopilotAgentContextProviders: session = agent.create_session() await agent.run("Hello", session=session) - sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + sent_prompt = mock_session.send_and_wait.call_args[0][0] assert "History message" in sent_prompt assert "Hello" in sent_prompt @@ -1659,7 +1673,7 @@ class TestGitHubCopilotAgentContextProviders: async for _ in agent.run("Hello", stream=True, session=session): pass - sent_prompt = mock_session.send.call_args[0][0]["prompt"] + sent_prompt = mock_session.send.call_args[0][0] assert "History message" in sent_prompt assert "Hello" in sent_prompt diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index d7a15aac4a..10e98810d5 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules 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 = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 7995afada6..20c8a6d467 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 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", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index dd18f2d14b..bf2b93cfaa 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -4,7 +4,7 @@ description = "Ollama 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://learn.microsoft.com/en-us/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", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index f5c0eea03e..ecd17c4b5e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -512,6 +512,7 @@ class RawOpenAIChatClient( # type: ignore[misc] if stream: function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() validated_options: dict[str, Any] | None = None async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -530,6 +531,7 @@ class RawOpenAIChatClient( # type: ignore[misc] chunk, options=validated_options, function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, ) except Exception as ex: self._handle_request_error(ex) @@ -1930,6 +1932,7 @@ class RawOpenAIChatClient( # type: ignore[misc] event: OpenAIResponseStreamEvent, options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse an OpenAI Responses API streaming event into a ChatResponseUpdate.""" metadata: dict[str, Any] = {} @@ -2008,6 +2011,8 @@ class RawOpenAIChatClient( # type: ignore[misc] contents.append(Content.from_text(text=event.delta, raw_representation=event)) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_text.delta": + if seen_reasoning_delta_item_ids is not None: + seen_reasoning_delta_item_ids.add(event.item_id) contents.append( Content.from_text_reasoning( id=event.item_id, @@ -2017,15 +2022,21 @@ class RawOpenAIChatClient( # type: ignore[misc] ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_text.done": - contents.append( - Content.from_text_reasoning( - id=event.item_id, - text=event.text, - raw_representation=event, + # Done event carries the full accumulated text. Emit it only as a + # fallback when no delta was already received for this item_id, to + # avoid duplicating content in downstream accumulators (e.g. ag-ui). + if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids: + contents.append( + Content.from_text_reasoning( + id=event.item_id, + text=event.text, + raw_representation=event, + ) ) - ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_summary_text.delta": + if seen_reasoning_delta_item_ids is not None: + seen_reasoning_delta_item_ids.add(event.item_id) contents.append( Content.from_text_reasoning( id=event.item_id, @@ -2035,13 +2046,17 @@ class RawOpenAIChatClient( # type: ignore[misc] ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_summary_text.done": - contents.append( - Content.from_text_reasoning( - id=event.item_id, - text=event.text, - raw_representation=event, + # Done event carries the full accumulated text. Emit it only as a + # fallback when no delta was already received for this item_id, to + # avoid duplicating content in downstream accumulators (e.g. ag-ui). + if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids: + contents.append( + Content.from_text_reasoning( + id=event.item_id, + text=event.text, + raw_representation=event, + ) ) - ) metadata.update(self._get_metadata_from_response(event)) case "response.code_interpreter_call_code.delta": call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id @@ -2065,6 +2080,9 @@ class RawOpenAIChatClient( # type: ignore[misc] ) ) metadata.update(self._get_metadata_from_response(event)) + # NOTE: Unlike reasoning done events, code_interpreter done events always + # emit content because downstream consumers do not accumulate + # code_interpreter deltas the same way. case "response.code_interpreter_call_code.done": call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id ci_additional_properties = { diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 2e2cef12ad..22b59e5a43 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework." 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" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index fd55321238..1e18f85273 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2808,11 +2808,12 @@ def test_streaming_reasoning_text_delta_event() -> None: mock_metadata.assert_called_once_with(event) -def test_streaming_reasoning_text_done_event() -> None: - """Test reasoning text done event creates TextReasoningContent with complete text.""" +def test_streaming_reasoning_text_done_event_skipped_after_deltas() -> None: + """Test reasoning text done event does not emit content when deltas were already received.""" client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = {"reasoning_456"} event = ResponseReasoningTextDoneEvent( type="response.reasoning_text.done", @@ -2824,12 +2825,40 @@ def test_streaming_reasoning_text_done_event() -> None: ) with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: - response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore + + assert len(response.contents) == 0 + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"test": "data"} + + +def test_streaming_reasoning_text_done_event_fallback_without_deltas() -> None: + """Test reasoning text done event emits content when no deltas were received for this item_id.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() + + event = ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id="reasoning_456", + output_index=0, + sequence_number=2, + text="complete reasoning", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore assert len(response.contents) == 1 assert response.contents[0].type == "text_reasoning" + assert response.contents[0].id == "reasoning_456" assert response.contents[0].text == "complete reasoning" - assert response.contents[0].raw_representation == event mock_metadata.assert_called_once_with(event) assert response.additional_properties == {"test": "data"} @@ -2859,11 +2888,12 @@ def test_streaming_reasoning_summary_text_delta_event() -> None: mock_metadata.assert_called_once_with(event) -def test_streaming_reasoning_summary_text_done_event() -> None: - """Test reasoning summary text done event creates TextReasoningContent with complete text.""" +def test_streaming_reasoning_summary_text_done_event_skipped_after_deltas() -> None: + """Test reasoning summary text done event does not emit content when deltas were already received.""" client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = {"summary_012"} event = ResponseReasoningSummaryTextDoneEvent( type="response.reasoning_summary_text.done", @@ -2875,16 +2905,94 @@ def test_streaming_reasoning_summary_text_done_event() -> None: ) with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: - response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore + + assert len(response.contents) == 0 + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"custom": "meta"} + + +def test_streaming_reasoning_summary_text_done_event_fallback_without_deltas() -> None: + """Test reasoning summary text done event emits content when no deltas were received for this item_id.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() + + event = ResponseReasoningSummaryTextDoneEvent( + type="response.reasoning_summary_text.done", + item_id="summary_012", + output_index=0, + sequence_number=4, + summary_index=0, + text="complete summary", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore assert len(response.contents) == 1 assert response.contents[0].type == "text_reasoning" + assert response.contents[0].id == "summary_012" assert response.contents[0].text == "complete summary" - assert response.contents[0].raw_representation == event mock_metadata.assert_called_once_with(event) assert response.additional_properties == {"custom": "meta"} +def test_streaming_reasoning_deltas_then_done_no_duplication() -> None: + """Sending delta events followed by a done event produces content only from deltas.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() + item_id = "reasoning_seq" + + delta1 = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=1, + delta="Hello ", + ) + delta2 = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=2, + delta="world", + ) + done = ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=3, + text="Hello world", + ) + + all_contents = [] + with patch.object(client, "_get_metadata_from_response", return_value={}): + for event in [delta1, delta2, done]: + response = client._parse_chunk_from_openai( + event, + chat_options, + function_call_ids, + seen_reasoning_delta_item_ids, # type: ignore + ) + all_contents.extend(response.contents) + + assert len(all_contents) == 2 + assert all_contents[0].text == "Hello " + assert all_contents[1].text == "world" + assert "".join(c.text for c in all_contents) == "Hello world" + + def test_streaming_reasoning_events_preserve_metadata() -> None: """Test that reasoning events preserve metadata like regular text events.""" client = OpenAIChatClient(model="test-model", api_key="test-key") diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index 69fb60e4bb..9d2232fb4a 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se 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", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index ad63be4448..711e0cbaf6 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f 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://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 0feec5bd23..533ed6635d 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis 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", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index 9e677fb602..d899537f95 100644 --- a/python/pyproject.toml +++ b/python/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" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.0.0", + "agent-framework-core[all]==1.0.1", ] [dependency-groups] @@ -39,7 +39,7 @@ dev = [ "pytest-retry==1.7.0", "mypy==1.20.0", "pyright==1.1.408", - "mcp[ws]==1.26.0", + "mcp[ws]==1.27.0", "opentelemetry-sdk==1.40.0", #tasks "poethepoet==0.42.1", diff --git a/python/samples/02-agents/conversations/README.md b/python/samples/02-agents/conversations/README.md index becacd9134..bbfb078659 100644 --- a/python/samples/02-agents/conversations/README.md +++ b/python/samples/02-agents/conversations/README.md @@ -9,6 +9,9 @@ These samples demonstrate different approaches to managing conversation history | [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume conversation sessions, comparing service-managed sessions (Azure AI Foundry) with in-memory sessions (OpenAI). | | [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom history provider by extending `HistoryProvider`, enabling conversation persistence in your preferred storage backend. | | [`cosmos_history_provider.py`](cosmos_history_provider.py) | Use Azure Cosmos DB as a history provider for durable conversation storage with `CosmosHistoryProvider`. | +| [`cosmos_history_provider_conversation_persistence.py`](cosmos_history_provider_conversation_persistence.py) | Persist and resume conversations across application restarts using `CosmosHistoryProvider` — serialize session state, restore it, and continue with full Cosmos DB history. | +| [`cosmos_history_provider_messages.py`](cosmos_history_provider_messages.py) | Direct message history operations — retrieve stored messages as a transcript, clear session history, and verify data deletion. | +| [`cosmos_history_provider_sessions.py`](cosmos_history_provider_sessions.py) | Multi-session and multi-tenant management — per-tenant session isolation, `list_sessions()` to enumerate, switch between sessions, and resume specific conversations. | | [`redis_history_provider.py`](redis_history_provider.py) | Use Redis as a history provider for persistent conversation history storage across sessions. | ## Prerequisites @@ -22,7 +25,7 @@ These samples demonstrate different approaches to managing conversation history **For `custom_history_provider.py`:** - `OPENAI_API_KEY`: Your OpenAI API key -**For `cosmos_history_provider.py`:** +**For Cosmos DB samples (`cosmos_history_provider*.py`):** - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint - `FOUNDRY_MODEL`: The Foundry model deployment name - `AZURE_COSMOS_ENDPOINT`: Your Azure Cosmos DB account endpoint diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py new file mode 100644 index 0000000000..548f09a92a --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py @@ -0,0 +1,163 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +import asyncio +import os + +from agent_framework import Agent, AgentSession +from agent_framework.foundry import FoundryChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates persisting and resuming conversations across application +restarts using CosmosHistoryProvider as the persistent backend. + +Key components: +- Phase 1: Run a conversation and serialize the session with session.to_dict() +- Phase 2: Simulate an app restart — create new provider and agent instances, + restore the session with AgentSession.from_dict(), and continue the conversation +- Cosmos DB reloads the full message history, so the agent remembers everything + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the conversation persistence sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + # ── Phase 1: Initial conversation ── + + print("=== Phase 1: Initial conversation ===\n") + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="PersistentAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + session = agent.create_session() + + response1 = await agent.run("My name is Ada. I'm building a distributed database in Rust.", session=session) + print("User: My name is Ada. I'm building a distributed database in Rust.") + print(f"Assistant: {response1.text}\n") + + response2 = await agent.run("The hardest part is the consensus algorithm.", session=session) + print("User: The hardest part is the consensus algorithm.") + print(f"Assistant: {response2.text}\n") + + serialized_session = session.to_dict() + print(f"Session serialized. Session ID: {session.session_id}") + + # ── Phase 2: Simulate app restart ── + + print("\n=== Phase 2: Resuming after 'restart' ===\n") + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="PersistentAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + restored_session = AgentSession.from_dict(serialized_session) + print(f"Session restored. Session ID: {restored_session.session_id}\n") + + response3 = await agent.run("What was I working on and what was the challenge?", session=restored_session) + print("User: What was I working on and what was the challenge?") + print(f"Assistant: {response3.text}\n") + + messages = await history_provider.get_messages(restored_session.session_id) + print(f"Messages stored in Cosmos DB: {len(messages)}") + for i, msg in enumerate(messages, 1): + print(f" {i}. [{msg.role}] {msg.text[:80]}...") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Phase 1: Initial conversation === + +User: My name is Ada. I'm building a distributed database in Rust. +Assistant: That sounds like a great project, Ada! Rust is an excellent choice for ... + +User: The hardest part is the consensus algorithm. +Assistant: Consensus algorithms can be tricky! Are you looking at Raft, Paxos, or ... + +Session serialized. Session ID: + +=== Phase 2: Resuming after 'restart' === + +Session restored. Session ID: + +User: What was I working on and what was the challenge? +Assistant: You told me you're building a distributed database in Rust and that the hardest +part is the consensus algorithm. + +Messages stored in Cosmos DB: 6 + 1. [user] My name is Ada. I'm building a distributed database in Rust.... + 2. [assistant] That sounds like a great project, Ada! Rust is an excellent ch... + 3. [user] The hardest part is the consensus algorithm.... + 4. [assistant] Consensus algorithms can be tricky! Are you looking at Raft, Pa... + 5. [user] What was I working on and what was the challenge?... + 6. [assistant] You told me you're building a distributed database in Rust and ... +""" diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_messages.py b/python/samples/02-agents/conversations/cosmos_history_provider_messages.py new file mode 100644 index 0000000000..9f8e7c9164 --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider_messages.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates direct message history operations using +CosmosHistoryProvider — retrieving, displaying, and clearing stored messages. + +Key components: +- get_messages(session_id): Retrieve all stored messages as a chat transcript +- clear(session_id): Delete all messages for a session (e.g., GDPR compliance) +- Verifying that history is empty after clearing +- Running a new conversation in the same session after clearing + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the messages history sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="HistoryAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + session = agent.create_session() + session_id = session.session_id + + # 1. Have a multi-turn conversation. + print("=== Building a conversation ===\n") + + queries = [ + "Hi! My favorite programming language is Python.", + "I also enjoy hiking in the mountains on weekends.", + "What do you know about me so far?", + ] + for query in queries: + response = await agent.run(query, session=session) + print(f"User: {query}") + print(f"Assistant: {response.text}\n") + + # 2. Retrieve and display the full message history as a transcript. + print("=== Chat transcript from Cosmos DB ===\n") + + messages = await history_provider.get_messages(session_id) + print(f"Total messages stored: {len(messages)}\n") + for i, msg in enumerate(messages, 1): + print(f" {i}. [{msg.role}] {msg.text[:100]}") + + # 3. Clear the session history. + print("\n=== Clearing session history ===\n") + + await history_provider.clear(session_id) + print(f"Cleared all messages for session: {session_id}") + + # 4. Verify history is empty. + remaining = await history_provider.get_messages(session_id) + print(f"Messages after clear: {len(remaining)}") + + # 5. Start a fresh conversation in the same session — agent has no memory. + print("\n=== Fresh conversation (same session, no memory) ===\n") + + response = await agent.run("What do you know about me?", session=session) + print("User: What do you know about me?") + print(f"Assistant: {response.text}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Building a conversation === + +User: Hi! My favorite programming language is Python. +Assistant: That's great! Python is a wonderful language. What do you like most about it? + +User: I also enjoy hiking in the mountains on weekends. +Assistant: Hiking sounds lovely! Do you have a favorite trail or mountain range? + +User: What do you know about me so far? +Assistant: You love Python as your favorite programming language and enjoy hiking in the mountains on weekends. + +=== Chat transcript from Cosmos DB === + +Total messages stored: 6 + + 1. [user] Hi! My favorite programming language is Python. + 2. [assistant] That's great! Python is a wonderful language. What do you like most about it? + 3. [user] I also enjoy hiking in the mountains on weekends. + 4. [assistant] Hiking sounds lovely! Do you have a favorite trail or mountain range? + 5. [user] What do you know about me so far? + 6. [assistant] You love Python as your favorite programming language and enjoy hiking ... + +=== Clearing session history === + +Cleared all messages for session: +Messages after clear: 0 + +=== Fresh conversation (same session, no memory) === + +User: What do you know about me? +Assistant: I don't have any information about you yet. Feel free to share anything you'd like! +""" diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py b/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py new file mode 100644 index 0000000000..31e8eef16c --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates multi-session and multi-tenant management using +CosmosHistoryProvider. Each tenant (user) gets isolated conversation sessions +stored in the same Cosmos DB container, partitioned by session_id. + +Key components: +- Per-tenant session isolation using prefixed session IDs +- list_sessions(): Enumerate all stored sessions across tenants +- Switching between sessions for different users +- Resuming a specific user's session — verifying data isolation + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the session management sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="MultiTenantAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + # 1. Tenant "alice" starts a conversation about travel. + print("=== Tenant: Alice — Travel conversation ===\n") + + alice_session = agent.create_session(session_id="tenant-alice-session-1") + + response = await agent.run("Hi! I'm planning a trip to Italy. I love Renaissance art.", session=alice_session) + print("Alice: I'm planning a trip to Italy. I love Renaissance art.") + print(f"Assistant: {response.text}\n") + + response = await agent.run("Which museums should I visit in Florence?", session=alice_session) + print("Alice: Which museums should I visit in Florence?") + print(f"Assistant: {response.text}\n") + + # 2. Tenant "bob" starts a separate conversation about cooking. + print("=== Tenant: Bob — Cooking conversation ===\n") + + bob_session = agent.create_session(session_id="tenant-bob-session-1") + + response = await agent.run("Hey! I'm learning to cook Thai food. I just made pad thai.", session=bob_session) + print("Bob: I'm learning to cook Thai food. I just made pad thai.") + print(f"Assistant: {response.text}\n") + + response = await agent.run("What Thai dish should I try next?", session=bob_session) + print("Bob: What Thai dish should I try next?") + print(f"Assistant: {response.text}\n") + + # 3. List all sessions stored in Cosmos DB. + print("=== Listing all sessions ===\n") + + sessions = await history_provider.list_sessions() + print(f"Found {len(sessions)} session(s):") + for sid in sessions: + print(f" - {sid}") + + # 4. Resume Alice's session — verify she gets her travel context back. + print("\n=== Resuming Alice's session ===\n") + + alice_resumed = agent.create_session(session_id="tenant-alice-session-1") + + response = await agent.run("What were we discussing?", session=alice_resumed) + print("Alice: What were we discussing?") + print(f"Assistant: {response.text}\n") + + # 5. Resume Bob's session — verify he gets his cooking context back. + print("=== Resuming Bob's session ===\n") + + bob_resumed = agent.create_session(session_id="tenant-bob-session-1") + + response = await agent.run("What was the last dish I mentioned?", session=bob_resumed) + print("Bob: What was the last dish I mentioned?") + print(f"Assistant: {response.text}\n") + + # 6. Show per-session message counts. + print("=== Per-session message counts ===\n") + + alice_messages = await history_provider.get_messages("tenant-alice-session-1") + bob_messages = await history_provider.get_messages("tenant-bob-session-1") + print(f"Alice's session: {len(alice_messages)} messages") + print(f"Bob's session: {len(bob_messages)} messages") + + # 7. Clean up: clear both sessions. + print("\n=== Cleaning up ===\n") + + await history_provider.clear("tenant-alice-session-1") + await history_provider.clear("tenant-bob-session-1") + print("Cleared Alice's and Bob's sessions.") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Tenant: Alice — Travel conversation === + +Alice: I'm planning a trip to Italy. I love Renaissance art. +Assistant: Italy is a dream for Renaissance art lovers! Florence, Rome, and Venice ... + +Alice: Which museums should I visit in Florence? +Assistant: In Florence, the Uffizi Gallery is a must — it has Botticelli's Birth of Venus ... + +=== Tenant: Bob — Cooking conversation === + +Bob: I'm learning to cook Thai food. I just made pad thai. +Assistant: Pad thai is a great start! How did it turn out? + +Bob: What Thai dish should I try next? +Assistant: I'd suggest trying green curry or tom yum soup — both are classic Thai dishes ... + +=== Listing all sessions === + +Found 2 session(s): + - tenant-alice-session-1 + - tenant-bob-session-1 + +=== Resuming Alice's session === + +Alice: What were we discussing? +Assistant: We were discussing your trip to Italy and your love for Renaissance art ... + +=== Resuming Bob's session === + +Bob: What was the last dish I mentioned? +Assistant: You mentioned pad thai — it was the dish you just made! + +=== Per-session message counts === + +Alice's session: 6 messages +Bob's session: 6 messages + +=== Cleaning up === + +Cleared Alice's and Bob's sessions. +""" diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index af59ae9b3b..93e37d89bb 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -20,7 +20,7 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from dotenv import load_dotenv from pydantic import Field diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py index 145702591f..1a82d9867d 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py @@ -16,7 +16,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py index 081dd1b21f..71bd67efb4 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py @@ -16,7 +16,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import MCPServerConfig, PermissionRequestResult +from copilot.session import MCPServerConfig, PermissionRequestResult from dotenv import load_dotenv # Load environment variables from .env file diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py index 5ccc8e51f7..916061f939 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py @@ -22,7 +22,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index 758eccb015..801edf52f3 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -15,7 +15,7 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from pydantic import Field diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py index 98c37d40f3..729aad6863 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py @@ -15,7 +15,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py index 827dfd86c1..2f14648bae 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py @@ -15,7 +15,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index 1fd5ab01fc..ae3292a07a 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -52,6 +52,8 @@ Once comfortable with these, explore the rest of the samples below. | Checkpointed Sub-Workflow | [checkpoint/sub_workflow_checkpoint.py](./checkpoint/sub_workflow_checkpoint.py) | Save and resume a sub-workflow that pauses for human approval | | Handoff + Tool Approval Resume | [orchestrations/handoff_with_tool_approval_checkpoint_resume.py](./orchestrations/handoff_with_tool_approval_checkpoint_resume.py) | Handoff workflow that captures tool-call approvals in checkpoints and resumes with human decisions | | Workflow as Agent Checkpoint | [checkpoint/workflow_as_agent_checkpoint.py](./checkpoint/workflow_as_agent_checkpoint.py) | Enable checkpointing when using workflow.as_agent() with checkpoint_storage parameter | +| Cosmos DB Checkpoint Storage | [checkpoint/cosmos_workflow_checkpointing.py](./checkpoint/cosmos_workflow_checkpointing.py) | Use `CosmosCheckpointStorage` for durable workflow checkpointing backed by Azure Cosmos DB NoSQL | +| Cosmos DB + Foundry Checkpoint | [checkpoint/cosmos_workflow_checkpointing_foundry.py](./checkpoint/cosmos_workflow_checkpointing_foundry.py) | Multi-agent workflow using `FoundryChatClient` with `CosmosCheckpointStorage` for durable pause/resume | ### composition diff --git a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py new file mode 100644 index 0000000000..fd4608db03 --- /dev/null +++ b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +"""Sample: Workflow Checkpointing with Cosmos DB NoSQL. + +Purpose: +This sample shows how to use Azure Cosmos DB NoSQL as a persistent checkpoint +storage backend for workflows, enabling durable pause-and-resume across +process restarts. + +What you learn: +- How to configure CosmosCheckpointStorage for workflow checkpointing +- How to run a workflow that automatically persists checkpoints to Cosmos DB +- How to resume a workflow from a Cosmos DB checkpoint +- How to list and inspect available checkpoints + +Prerequisites: +- An Azure Cosmos DB account (or local emulator) +- Environment variables set (see below) + +Environment variables: + AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint + AZURE_COSMOS_DATABASE_NAME - Database name + AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints +Optional: + AZURE_COSMOS_KEY - Account key (if not using Azure credentials) +""" + +import asyncio +import os +import sys +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowCheckpoint, + WorkflowContext, + handler, +) + +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore[import] # pragma: no cover + +from agent_framework_azure_cosmos import CosmosCheckpointStorage + + +@dataclass +class ComputeTask: + """Task containing the list of numbers remaining to be processed.""" + + remaining_numbers: list[int] + + +class StartExecutor(Executor): + """Initiates the workflow by providing the upper limit.""" + + @handler + async def start(self, upper_limit: int, ctx: WorkflowContext[ComputeTask]) -> None: + """Start the workflow with numbers up to the given limit.""" + print(f"StartExecutor: Starting computation up to {upper_limit}") + await ctx.send_message(ComputeTask(remaining_numbers=list(range(1, upper_limit + 1)))) + + +class WorkerExecutor(Executor): + """Processes numbers and manages executor state for checkpointing.""" + + def __init__(self, id: str) -> None: + """Initialize the worker executor.""" + super().__init__(id=id) + self._results: dict[int, list[tuple[int, int]]] = {} + + @handler + async def compute( + self, + task: ComputeTask, + ctx: WorkflowContext[ComputeTask, dict[int, list[tuple[int, int]]]], + ) -> None: + """Process the next number, computing its factor pairs.""" + next_number = task.remaining_numbers.pop(0) + print(f"WorkerExecutor: Processing {next_number}") + + pairs: list[tuple[int, int]] = [] + for i in range(1, next_number): + if next_number % i == 0: + pairs.append((i, next_number // i)) + self._results[next_number] = pairs + + if not task.remaining_numbers: + await ctx.yield_output(self._results) + else: + await ctx.send_message(task) + + @override + async def on_checkpoint_save(self) -> dict[str, Any]: + return {"results": self._results} + + @override + async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: + self._results = state.get("results", {}) + + +async def main() -> None: + """Run the workflow checkpointing sample with Cosmos DB.""" + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name: + print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.") + return + + # Authentication: supports both managed identity/RBAC and key-based auth. + # When AZURE_COSMOS_KEY is set, key-based auth is used. + # Otherwise, falls back to DefaultAzureCredential (properly closed via async with). + if cosmos_key: + async with CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=cosmos_key, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage: + await _run_workflow(checkpoint_storage) + else: + from azure.identity.aio import DefaultAzureCredential + + async with ( + DefaultAzureCredential() as credential, + CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=credential, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage, + ): + await _run_workflow(checkpoint_storage) + + +async def _run_workflow(checkpoint_storage: CosmosCheckpointStorage) -> None: + """Build and run the workflow with Cosmos DB checkpointing.""" + start = StartExecutor(id="start") + worker = WorkerExecutor(id="worker") + workflow_builder = ( + WorkflowBuilder(start_executor=start, checkpoint_storage=checkpoint_storage) + .add_edge(start, worker) + .add_edge(worker, worker) + ) + + # --- First run: execute the workflow --- + print("\n=== First Run ===\n") + workflow = workflow_builder.build() + + output = None + async for event in workflow.run(message=8, stream=True): + if event.type == "output": + output = event.data + + print(f"Factor pairs computed: {output}") + + # List checkpoints saved in Cosmos DB + checkpoint_ids = await checkpoint_storage.list_checkpoint_ids( + workflow_name=workflow.name, + ) + print(f"\nCheckpoints in Cosmos DB: {len(checkpoint_ids)}") + for cid in checkpoint_ids: + print(f" - {cid}") + + # Get the latest checkpoint + latest: WorkflowCheckpoint | None = await checkpoint_storage.get_latest( + workflow_name=workflow.name, + ) + + if latest is None: + print("No checkpoint found to resume from.") + return + + print(f"\nLatest checkpoint: {latest.checkpoint_id}") + print(f" iteration_count: {latest.iteration_count}") + print(f" timestamp: {latest.timestamp}") + + # --- Second run: resume from the latest checkpoint --- + print("\n=== Resuming from Checkpoint ===\n") + workflow2 = workflow_builder.build() + + output2 = None + async for event in workflow2.run(checkpoint_id=latest.checkpoint_id, stream=True): + if event.type == "output": + output2 = event.data + + if output2: + print(f"Resumed workflow produced: {output2}") + else: + print("Resumed workflow completed (no remaining work — already finished).") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py new file mode 100644 index 0000000000..7d4f6ad17f --- /dev/null +++ b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +"""Sample: Workflow Checkpointing with Cosmos DB and Azure AI Foundry. + +Purpose: +This sample demonstrates how to use CosmosCheckpointStorage with agents built +on Azure AI Foundry (via FoundryChatClient). It shows a multi-agent +workflow where checkpoint state is persisted to Cosmos DB, enabling durable +pause-and-resume across process restarts. + +What you learn: +- How to wire CosmosCheckpointStorage with FoundryChatClient agents +- How to combine session history with workflow checkpointing +- How to resume a workflow-as-agent from a Cosmos DB checkpoint + +Key concepts: +- AgentSession: Maintains conversation history across agent invocations +- CosmosCheckpointStorage: Persists workflow execution state in Cosmos DB +- These are complementary: sessions track conversation, checkpoints track workflow state + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint + FOUNDRY_MODEL - Model deployment name + AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint + AZURE_COSMOS_DATABASE_NAME - Database name + AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints +Optional: + AZURE_COSMOS_KEY - Account key (if not using Azure credentials) +""" + +import asyncio +import os +from typing import Any + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework.orchestrations import SequentialBuilder +from agent_framework_azure_cosmos import CosmosCheckpointStorage +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + """Run the Azure AI Foundry + Cosmos DB checkpointing sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if not project_endpoint or not model: + print("Please set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL.") + return + + if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name: + print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.") + return + + # Use a single AzureCliCredential for both Cosmos and Foundry, + # properly closed via async context manager. + async with AzureCliCredential() as azure_credential: + cosmos_credential: Any = cosmos_key if cosmos_key else azure_credential + + async with CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=cosmos_credential, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage: + # Create Azure AI Foundry agents + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=azure_credential, + ) + + assistant = Agent( + name="assistant", + instructions="You are a helpful assistant. Keep responses brief.", + client=client, + ) + + reviewer = Agent( + name="reviewer", + instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.", + client=client, + ) + + # Build a sequential workflow and wrap it as an agent + workflow = SequentialBuilder(participants=[assistant, reviewer]).build() + agent = workflow.as_agent(name="FoundryCheckpointedAgent") + + # --- First run: execute with Cosmos DB checkpointing --- + print("=== First Run ===\n") + + session = agent.create_session() + query = "What are the benefits of renewable energy?" + print(f"User: {query}") + + response = await agent.run(query, session=session, checkpoint_storage=checkpoint_storage) + + for msg in response.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Show checkpoints persisted in Cosmos DB + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) + print(f"\nCheckpoints in Cosmos DB: {len(checkpoints)}") + for i, cp in enumerate(checkpoints[:5], 1): + print(f" {i}. {cp.checkpoint_id} (iteration={cp.iteration_count})") + + # --- Second run: continue conversation with checkpoint history --- + print("\n=== Second Run (continuing conversation) ===\n") + + query2 = "Can you elaborate on the economic benefits?" + print(f"User: {query2}") + + response2 = await agent.run(query2, session=session, checkpoint_storage=checkpoint_storage) + + for msg in response2.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Show total checkpoints + all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) + print(f"\nTotal checkpoints after two runs: {len(all_checkpoints)}") + + # Get latest checkpoint + latest = await checkpoint_storage.get_latest(workflow_name=workflow.name) + if latest: + print(f"Latest checkpoint: {latest.checkpoint_id}") + print(f" iteration_count: {latest.iteration_count}") + print(f" timestamp: {latest.timestamp}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json index 991211fafd..a78c2a9196 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json @@ -17,7 +17,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.4", - "vite": "^7.3.1" + "vite": "^7.3.2" } }, "node_modules/@babel/code-frame": { @@ -1776,9 +1776,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json index 75af8fcf94..e839a5e9ec 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json @@ -18,6 +18,6 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.4", - "vite": "^7.3.1" + "vite": "^7.3.2" } } diff --git a/python/scripts/sample_validation/create_dynamic_workflow_executor.py b/python/scripts/sample_validation/create_dynamic_workflow_executor.py index 66ba578228..f9356bbdd8 100644 --- a/python/scripts/sample_validation/create_dynamic_workflow_executor.py +++ b/python/scripts/sample_validation/create_dynamic_workflow_executor.py @@ -15,7 +15,7 @@ from agent_framework import ( ) from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from pydantic import BaseModel from typing_extensions import Never diff --git a/python/uv.lock b/python/uv.lock index b991a44744..e638be46b9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -94,7 +94,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0" +version = "1.0.1" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -127,7 +127,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ { name = "flit", specifier = "==3.12.0" }, - { name = "mcp", extras = ["ws"], specifier = "==1.26.0" }, + { name = "mcp", extras = ["ws"], specifier = "==1.27.0" }, { name = "mypy", specifier = "==1.20.0" }, { name = "opentelemetry-sdk", specifier = "==1.40.0" }, { name = "poethepoet", specifier = "==0.42.1" }, @@ -147,7 +147,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -162,7 +162,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -190,7 +190,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -205,7 +205,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -220,7 +220,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -235,7 +235,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -257,7 +257,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -274,7 +274,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -289,7 +289,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -304,7 +304,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -319,7 +319,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0" +version = "1.0.1" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -391,7 +391,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -416,7 +416,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -454,7 +454,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -481,7 +481,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.0.0" +version = "1.0.1" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -521,7 +521,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -538,7 +538,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -548,12 +548,12 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.31,<0.1.33" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] [[package]] name = "agent-framework-lab" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -634,7 +634,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -649,7 +649,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -664,7 +664,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.0.0" +version = "1.0.1" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -679,7 +679,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -690,7 +690,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -707,7 +707,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1822,62 +1822,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -2373,19 +2373,19 @@ wheels = [ [[package]] name = "github-copilot-sdk" -version = "0.1.32" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/67/ebd002c14fe7d2640d0fff47a0b29fdb21ed239b597afa2d2c6f6cfebb0b/github_copilot_sdk-0.1.32-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d97bc39fbd4b51e0aea3405299da1e643838ddbf6bff284f688a2d8c20d82ff8", size = 58576987, upload-time = "2026-03-07T15:28:24.062Z" }, - { url = "https://files.pythonhosted.org/packages/a5/50/add440f61e19f5b7e6989c89c5cefcb14c23f06627621e7c3a15a1f75e5d/github_copilot_sdk-0.1.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8098592f34e7ee7decbcbb7615c7eb924471e65a3e4d0d93bc49b0d112f8ec51", size = 55328145, upload-time = "2026-03-07T15:28:28.395Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b8/c3ca0678b21d8a0dd8fe3aa8fad4b7ec5f22cbe9d5fb3a11f82df4f40578/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c20cae4bec3584ce007a65a363216a1f98a71428a3ca3b76622f9e556307eed2", size = 61456678, upload-time = "2026-03-07T15:28:32.646Z" }, - { url = "https://files.pythonhosted.org/packages/21/5c/bdfe177353f88d44da9600c3ec478e2b0df7a838901947b168e869ba5ad7/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0941fd445e97a9b13fb713086c4a8c09c20ec8c7ab854cf009bd7cc213488999", size = 59641536, upload-time = "2026-03-07T15:28:36.977Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d0/2f3a07c74ecd24587b8f7d26729738f73e63f3341bf4bdc9eb2bb73ddaaf/github_copilot_sdk-0.1.32-py3-none-win_amd64.whl", hash = "sha256:37a82ff0908e01512052b69df4aa498332fa5769999635425015ed43cd850622", size = 54077464, upload-time = "2026-03-07T15:28:41.34Z" }, - { url = "https://files.pythonhosted.org/packages/1c/76/292088d6ccf2daf8bcb8a94b22b4f16005a6772087896f1b43c4f0d5edaa/github_copilot_sdk-0.1.32-py3-none-win_arm64.whl", hash = "sha256:3199c99604e8d393b1d60905be80b84da44e70d16d30b92e2ae9b92814cdc4ae", size = 52083845, upload-time = "2026-03-07T15:28:45.092Z" }, + { url = "https://files.pythonhosted.org/packages/67/41/76a9d50d7600bf8d26c659dc113be62e4e56e00a5cbfd544e1b5b200f45c/github_copilot_sdk-0.2.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c0823150f3b73431f04caee43d1dbafac22ae7e8bd1fc83727ee8363089ee038", size = 61076141, upload-time = "2026-04-03T20:18:22.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/d2e8bf4587c4da270ccb9cbd5ab8a2c4b41217c2bf04a43904be8a27ae20/github_copilot_sdk-0.2.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef7ff68eb8960515e1a2e199ac0ffb9a17cd3325266461e6edd7290e43dcf012", size = 57838464, upload-time = "2026-04-03T20:18:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/78/8b/cc8ee46724bd9fdfd6afe855a043c8403ed6884c5f3a55a9737780810396/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:890f7124e3b147532a1ac6c8d5f66421ea37757b2b9990d7967f3f147a2f533a", size = 63940155, upload-time = "2026-04-03T20:18:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ee/facf04e22e42d4bdd4fe3d356f3a51180a6ea769ae2ac306d0897f9bf9d9/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6502be0b9ececacbda671835e5f61c7aaa906c6b8657ee252cad6cc8335cac8e", size = 62130538, upload-time = "2026-04-03T20:18:34.061Z" }, + { url = "https://files.pythonhosted.org/packages/3f/1c/8b105f14bf61d1d304a00ac29460cb0d4e7406ceb89907d5a7b41a72fe85/github_copilot_sdk-0.2.1-py3-none-win_amd64.whl", hash = "sha256:8275ca8e387e6b29bc5155a3c02a0eb3d035c6bc7b1896253eb0d469f2385790", size = 56547331, upload-time = "2026-04-03T20:18:37.859Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/0ce319d2f618e9bc89f275e60b1920f4587eb0218bba6cbb84283dc7a7f3/github_copilot_sdk-0.2.1-py3-none-win_arm64.whl", hash = "sha256:1f9b59b7c41f31be416bf20818f58e25b6adc76f6d17357653fde6fbab662606", size = 54499549, upload-time = "2026-04-03T20:18:41.77Z" }, ] [[package]] @@ -3530,7 +3530,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3548,9 +3548,9 @@ dependencies = [ { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] [package.optional-dependencies]