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