From 36752646edc40b558d5b6fea852fcb8cd8d6aa94 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:12:15 +0000 Subject: [PATCH 1/5] add inline skills --- dotnet/agent-framework-dotnet.slnx | 7 +- .../Agent_Step02_CodeDefinedSkills.csproj | 21 + .../Agent_Step02_CodeDefinedSkills/Program.cs | 90 ++++ .../Agent_Step02_CodeDefinedSkills/README.md | 52 +++ .../samples/02-agents/AgentSkills/README.md | 19 +- .../Skills/AgentInMemorySkillsSource.cs | 35 ++ .../Microsoft.Agents.AI/Skills/AgentSkill.cs | 5 +- .../Skills/AgentSkillsProvider.cs | 34 +- .../Skills/AgentSkillsProviderBuilder.cs | 38 ++ .../Skills/Programmatic/AgentInlineSkill.cs | 213 +++++++++ .../Programmatic/AgentInlineSkillResource.cs | 60 +++ .../Programmatic/AgentInlineSkillScript.cs | 46 ++ .../AgentInMemorySkillsSourceTests.cs | 51 +++ .../AgentInlineSkillResourceTests.cs | 153 +++++++ .../AgentInlineSkillScriptTests.cs | 132 ++++++ .../AgentSkills/AgentInlineSkillTests.cs | 418 ++++++++++++++++++ .../AgentSkills/AgentSkillsProviderTests.cs | 148 ++++++- .../DeduplicatingAgentSkillsSourceTests.cs | 32 +- .../FilteringAgentSkillsSourceTests.cs | 52 ++- 19 files changed, 1537 insertions(+), 69 deletions(-) create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a5b93487b3..a33bcc4b12 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -77,8 +77,6 @@ - - @@ -105,6 +103,7 @@ + @@ -455,10 +454,6 @@ - - - - diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj new file mode 100644 index 0000000000..fd3d71fe7e --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs new file mode 100644 index 0000000000..46c58985fb --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to define Agent Skills entirely in code using AgentInlineSkill. +// No SKILL.md files are needed — skills, resources, and scripts are all defined programmatically. +// +// Three approaches are shown using a unit-converter skill: +// 1. Static resources — inline content provided via AddResource +// 2. Dynamic resources — computed at runtime via a factory delegate +// 3. Code scripts — executable delegates the agent can invoke directly + +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Build the code-defined skill --- +var unitConverterSkill = new AgentInlineSkill( + name: "unit-converter", + description: "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.", + instructions: """ + Use this skill when the user asks to convert between units. + + 1. Review the conversion-table resource to find the factor for the requested conversion. + 2. Check the conversion-policy resource for rounding and formatting rules. + 3. Use the convert script, passing the value and factor from the table. + """) + // 1. Static Resource: conversion tables + .AddResource( + "conversion-table", + """ + # Conversion Tables + + Formula: **result = value × factor** + + | From | To | Factor | + |-------------|-------------|----------| + | miles | kilometers | 1.60934 | + | kilometers | miles | 0.621371 | + | pounds | kilograms | 0.453592 | + | kilograms | pounds | 2.20462 | + """) + // 2. Dynamic Resource: conversion policy (computed at runtime) + .AddResource("conversion-policy", () => + { + const int Precision = 4; + return $""" + # Conversion Policy + + **Decimal places:** {Precision} + **Format:** Always show both the original and converted values with units + **Generated at:** {DateTime.UtcNow:O} + """; + }) + // 3. Code Script: convert + .AddScript("convert", (double value, double factor) => + { + double result = Math.Round(value * factor, 4); + return JsonSerializer.Serialize(new { value, factor, result }); + }); + +// --- Skills Provider --- +var skillsProvider = new AgentSkillsProvider(unitConverterSkill); + +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with code-defined skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md new file mode 100644 index 0000000000..2a778ed68c --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md @@ -0,0 +1,52 @@ +# Code-Defined Agent Skills Sample + +This sample demonstrates how to define **Agent Skills entirely in code** using `AgentInlineSkill`. + +## What it demonstrates + +- Creating skills programmatically with `AgentInlineSkill` — no SKILL.md files needed +- **Static resources** via `AgentInlineSkillResource` with inline content +- **Dynamic resources** via `AgentInlineSkillResource` with a factory delegate (computed at runtime) +- **Code scripts** via `AgentInlineSkillScript` with a delegate handler +- Using the `AgentSkillsProvider` constructor with inline skills + +## Skills Included + +### unit-converter (code-defined) + +Converts between common units using multiplication factors. Defined entirely in C# code: + +- `conversion-table` — Static resource with factor table +- `conversion-policy` — Dynamic resource with formatting rules (generated at runtime) +- `convert` — Script that performs `value × factor` conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with code-defined skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 75b850f077..db14e80db4 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -1,7 +1,24 @@ # AgentSkills Samples -Samples demonstrating Agent Skills capabilities. +Samples demonstrating Agent Skills capabilities. Each sample shows a different way to define and use skills. | Sample | Description | |--------|-------------| | [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | +| [Agent_Step02_CodeDefinedSkills](Agent_Step02_CodeDefinedSkills/) | Define skills entirely in C# code using `AgentInlineSkill`, with static/dynamic resources and scripts. | + +## Key Concepts + +### File-Based vs Code-Defined Skills + +| Aspect | File-Based | Code-Defined | +|--------|-----------|--------------| +| Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# | +| Resources | All files in skill directory (filtered by extension) | `AgentInlineSkillResource` (static value or delegate-backed) | +| Scripts | Supported via script executor delegate | `AgentInlineSkillScript` delegates | +| Discovery | Automatic from directory path | Explicit via constructor | +| Dynamic content | No (static files only) | Yes (factory delegates) | +| Reusability | Copy skill directory | Inline or shared instances | + +For single-source scenarios, use the `AgentSkillsProvider` constructors directly. To combine multiple skill types, use the `AgentSkillsProviderBuilder`. + diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs new file mode 100644 index 0000000000..57c9295c24 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that holds instances in memory. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AgentInMemorySkillsSource : AgentSkillsSource +{ + private readonly List _skills; + + /// + /// Initializes a new instance of the class. + /// + /// The skills to include in this source. + public AgentInMemorySkillsSource(IEnumerable skills) + { + this._skills = Throw.IfNull(skills).ToList(); + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>(this._skills); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs index 5f0a66808d..4c5ca8dbc3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -12,7 +12,8 @@ namespace Microsoft.Agents.AI; /// /// /// A skill represents a domain-specific capability with instructions, resources, and scripts. -/// Concrete implementations include (filesystem-backed). +/// Concrete implementations include (filesystem-backed) +/// and (code-defined). /// /// /// Skill metadata follows the Agent Skills specification. @@ -35,6 +36,8 @@ public abstract class AgentSkill /// /// /// For file-based skills this is the raw SKILL.md file content. + /// For code-defined skills this is a synthesized XML document + /// containing name, description, and body (instructions, resources, scripts). /// public abstract string Content { get; } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index f2f87851c0..626e0cfda2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -109,7 +109,39 @@ public AgentSkillsProvider( ILoggerFactory? loggerFactory = null) : this( new DeduplicatingAgentSkillsSource( - new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), + new AgentFileSkillsSource(skillPaths, Throw.IfNull(scriptRunner), fileOptions, loggerFactory), + loggerFactory), + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// with one or more inline (code-defined) skills. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// The inline skills to include. + public AgentSkillsProvider(params AgentInlineSkill[] skills) + : this(skills as IEnumerable) + { + } + + /// + /// Initializes a new instance of the class + /// with inline (code-defined) skills. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// The inline skills to include. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + IEnumerable skills, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + new DeduplicatingAgentSkillsSource( + new AgentInMemorySkillsSource(Throw.IfNull(skills)), loggerFactory), options, loggerFactory) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 17d7f2d6f3..f5c7ff6c1c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -13,9 +13,13 @@ namespace Microsoft.Agents.AI; /// Fluent builder for constructing an backed by a composite source. /// /// +/// +/// Use this builder to combine multiple skill sources into a single provider: +/// /// /// var provider = new AgentSkillsProviderBuilder() /// .UseFileSkills("/path/to/skills") +/// .UseInlineSkills(myInlineSkill1, myInlineSkill2) /// .Build(); /// /// @@ -65,6 +69,40 @@ public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, return this; } + /// + /// Adds a single inline (code-defined) skill. + /// + /// The inline skill to add. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseInlineSkill(AgentInlineSkill skill) + { + return this.UseInlineSkills(skill); + } + + /// + /// Adds inline (code-defined) skills. + /// + /// The inline skills to add. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseInlineSkills(params AgentInlineSkill[] skills) + { + var source = new AgentInMemorySkillsSource(skills); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Adds inline (code-defined) skills. + /// + /// The inline skills to add. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseInlineSkills(IEnumerable skills) + { + var source = new AgentInMemorySkillsSource(skills); + this._sourceFactories.Add((_, _) => source); + return this; + } + /// /// Adds a custom skill source. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs new file mode 100644 index 0000000000..beebea50b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill defined entirely in code with resources (static values or delegates) and scripts (delegates). +/// +/// +/// All calls to , +/// , and +/// must be made before the skill is passed to an or +/// . Calls made after that point will not be +/// reflected in the generated . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentInlineSkill : AgentSkill +{ + private readonly string _instructions; + private List? _resources; + private List? _scripts; + private string? _cachedContent; + + /// + /// Initializes a new instance of the class + /// with a pre-built . + /// + /// The skill frontmatter containing name, description, and other metadata. + /// Skill instructions text. + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this._instructions = Throw.IfNull(instructions); + } + + /// + /// Initializes a new instance of the class + /// with all frontmatter properties specified individually. + /// + /// Skill name in kebab-case. + /// Skill description for discovery. + /// Skill instructions text. + /// Optional license name or reference. + /// Optional compatibility information (max 500 chars). + /// Optional space-delimited list of pre-approved tools. + /// Optional arbitrary key-value metadata. + public AgentInlineSkill( + string name, + string description, + string instructions, + string? license = null, + string? compatibility = null, + string? allowedTools = null, + AdditionalPropertiesDictionary? metadata = null) + : this( + new AgentSkillFrontmatter(name, description, compatibility) + { + License = license, + AllowedTools = allowedTools, + Metadata = metadata, + }, + instructions) + { + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content => this._cachedContent ??= this.BuildContent(); + + /// + public override IReadOnlyList? Resources => this._resources; + + /// + public override IReadOnlyList? Scripts => this._scripts; + + /// + /// Registers a static resource with this skill. + /// + /// The resource name. + /// The static resource value. + /// An optional description of the resource. + /// This instance, for chaining. + public AgentInlineSkill AddResource(string name, object value, string? description = null) + { + (this._resources ??= []).Add(new AgentInlineSkillResource(name, value, description)); + return this; + } + + /// + /// Registers a dynamic resource with this skill, backed by a C# delegate. + /// The delegate's parameters and return type are automatically marshaled via AIFunctionFactory. + /// + /// The resource name. + /// A method that produces the resource value when requested. + /// An optional description of the resource. + /// This instance, for chaining. + public AgentInlineSkill AddResource(string name, Delegate method, string? description = null) + { + (this._resources ??= []).Add(new AgentInlineSkillResource(name, method, description)); + return this; + } + + /// + /// Registers a script with this skill, backed by a C# delegate. + /// The delegate's parameters and return type are automatically marshaled via AIFunctionFactory. + /// + /// The script name. + /// A method to execute when the script is invoked. + /// An optional description of the script. + /// This instance, for chaining. + public AgentInlineSkill AddScript(string name, Delegate method, string? description = null) + { + (this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description)); + return this; + } + + private string BuildContent() + { + var sb = new StringBuilder(); + + sb.Append($"{EscapeXmlString(this.Frontmatter.Name)}\n") + .Append($"{EscapeXmlString(this.Frontmatter.Description)}\n\n") + .Append("\n") + .Append(this._instructions) + .Append("\n"); + + if (this.Resources is { Count: > 0 }) + { + sb.Append("\n\n\n"); + foreach (var resource in this.Resources) + { + if (resource.Description is not null) + { + sb.Append($" \n"); + } + else + { + sb.Append($" \n"); + } + } + + sb.Append(""); + } + + if (this.Scripts is { Count: > 0 }) + { + sb.Append("\n\n\n"); + foreach (var script in this.Scripts) + { + JsonElement? parametersSchema = ((AgentInlineSkillScript)script).ParametersSchema; + + if (script.Description is null && parametersSchema is null) + { + sb.Append($" \n"); + } + } + + sb.Append(""); + } + + return sb.ToString(); + } + + /// + /// Escapes XML special characters: always escapes &, <, >, + /// ", and '. When is , + /// quotes are left unescaped to preserve readability of embedded content such as JSON. + /// + /// The string to escape. + /// + /// When , leaves " and ' unescaped for use in XML element content (e.g., JSON). + /// When (default), escapes all XML special characters including quotes. + /// + private static string EscapeXmlString(string value, bool preserveQuotes = false) + { + var result = value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); + + if (!preserveQuotes) + { + result = result + .Replace("\"", """) + .Replace("'", "'"); + } + + return result; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs new file mode 100644 index 0000000000..a700d7fbf7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill resource defined in code, backed by either a static value or a delegate. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + private readonly object? _value; + private readonly AIFunction? _function; + + /// + /// Initializes a new instance of the class with a static value. + /// The value is returned as-is when is called. + /// + /// The resource name. + /// The static resource value. + /// An optional description of the resource. + public AgentInlineSkillResource(string name, object value, string? description = null) + : base(name, description) + { + this._value = Throw.IfNull(value); + } + + /// + /// Initializes a new instance of the class with a delegate. + /// The delegate 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. + /// An optional description of the resource. + public AgentInlineSkillResource(string name, Delegate method, string? description = null) + : base(name, description) + { + Throw.IfNull(method); + this._function = AIFunctionFactory.Create(method, name: this.Name); + } + + /// + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + if (this._function is not null) + { + return await this._function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + + return this._value; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs new file mode 100644 index 0000000000..0d64a43294 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill script backed by a delegate. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentInlineSkillScript : AgentSkillScript +{ + private readonly AIFunction _function; + + /// + /// Initializes a new instance of the class from a delegate. + /// The delegate's parameters and return type are automatically marshaled via . + /// + /// The script name. + /// A method to execute when the script is invoked. Parameters are automatically deserialized from JSON. + /// An optional description of the script. + public AgentInlineSkillScript(string name, Delegate method, string? description = null) + : base(Throw.IfNullOrWhitespace(name), description) + { + Throw.IfNull(method); + this._function = AIFunctionFactory.Create(method, name: this.Name); + } + + /// + /// Gets the JSON schema describing the parameters accepted by this script, or if not available. + /// + public JsonElement? ParametersSchema => this._function.JsonSchema; + + /// + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs new file mode 100644 index 0000000000..f28019b70f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentInMemorySkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_ValidSkills_ReturnsAll() + { + // Arrange + var skills = new AgentSkill[] + { + new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."), + new AgentInlineSkill("another", "Another valid skill.", "More instructions."), + }; + var source = new AgentInMemorySkillsSource(skills); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("my-skill", result[0].Frontmatter.Name); + Assert.Equal("another", result[1].Frontmatter.Name); + } + + [Theory] + [InlineData("INVALID-NAME")] + [InlineData("-leading")] + [InlineData("trailing-")] + public void Constructor_InvalidFrontmatter_ThrowsArgumentException(string invalidName) + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkill(invalidName, "A skill.", "Instructions.")); + } + + [Fact] + public void Constructor_NullSkills_Throws() + { + // Act & Assert + Assert.Throws(() => new AgentInMemorySkillsSource(null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs new file mode 100644 index 0000000000..913abb9283 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentInlineSkillResourceTests +{ + [Fact] + public async Task ReadAsync_StaticValue_ReturnsValueAsync() + { + // Arrange + var resource = new AgentInlineSkillResource("config", "my-value"); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("my-value", result); + } + + [Fact] + public async Task ReadAsync_StaticObjectValue_ReturnsSameInstanceAsync() + { + // Arrange + var obj = new object(); + var resource = new AgentInlineSkillResource("ref", obj); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Same(obj, result); + } + + [Fact] + public async Task ReadAsync_Delegate_InvokesFunctionAsync() + { + // Arrange + int callCount = 0; + var resource = new AgentInlineSkillResource("dynamic", () => + { + callCount++; + return "computed"; + }); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("computed", result?.ToString()); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task ReadAsync_Delegate_InvokesEachTimeAsync() + { + // Arrange + int callCount = 0; + var resource = new AgentInlineSkillResource("counter", () => ++callCount); + + // Act + await resource.ReadAsync(); + await resource.ReadAsync(); + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal(3, callCount); + } + + [Fact] + public void Constructor_StaticValue_SetsNameAndDescription() + { + // Arrange & Act + var resource = new AgentInlineSkillResource("my-res", "val", "A description."); + + // Assert + Assert.Equal("my-res", resource.Name); + Assert.Equal("A description.", resource.Description); + } + + [Fact] + public void Constructor_StaticValue_NullDescription_DescriptionIsNull() + { + // Arrange & Act + var resource = new AgentInlineSkillResource("my-res", "val"); + + // Assert + Assert.Null(resource.Description); + } + + [Fact] + public void Constructor_StaticValue_NullValue_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource("my-res", null!)); + } + + [Fact] + public void Constructor_Delegate_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource("my-res", (Delegate)null!)); + } + + [Fact] + public void Constructor_NullName_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource(null!, "val")); + } + + [Fact] + public void Constructor_WhitespaceName_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource(" ", "val")); + } + + [Fact] + public void Constructor_Delegate_SetsNameAndDescription() + { + // Arrange & Act + var resource = new AgentInlineSkillResource("dyn-res", () => "hello", "Dynamic resource."); + + // Assert + Assert.Equal("dyn-res", resource.Name); + Assert.Equal("Dynamic resource.", resource.Description); + } + + [Fact] + public async Task ReadAsync_SupportsCancellationTokenAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + var resource = new AgentInlineSkillResource("cancellable", "value"); + + // Act — should not throw with a non-cancelled token + var result = await resource.ReadAsync(cancellationToken: cts.Token); + + // Assert + Assert.Equal("value", result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs new file mode 100644 index 0000000000..61f42ca66d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentInlineSkillScriptTests +{ + [Fact] + public async Task RunAsync_InvokesDelegate_ReturnsResultAsync() + { + // Arrange + var script = new AgentInlineSkillScript("greet", () => "hello"); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + + // Act + var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.Equal("hello", result?.ToString()); + } + + [Fact] + public async Task RunAsync_WithParameters_PassesArgumentsAsync() + { + // Arrange + var script = new AgentInlineSkillScript("add", (int a, int b) => a + b); + var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions."); + var args = new AIFunctionArguments { ["a"] = 3, ["b"] = 7 }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal(10, int.Parse(result?.ToString()!)); + } + + [Fact] + public void ParametersSchema_NoParameters_ReturnsSchema() + { + // Arrange + var script = new AgentInlineSkillScript("noop", () => "ok"); + + // Act + var schema = script.ParametersSchema; + + // Assert — parameterless delegates still produce a schema + Assert.NotNull(schema); + } + + [Fact] + public void ParametersSchema_WithParameters_ContainsPropertyNames() + { + // Arrange + var script = new AgentInlineSkillScript("search", (string query, int limit) => $"{query}:{limit}"); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + var schemaText = schema!.Value.GetRawText(); + Assert.Contains("query", schemaText); + Assert.Contains("limit", schemaText); + } + + [Fact] + public void Constructor_SetsNameAndDescription() + { + // Arrange & Act + var script = new AgentInlineSkillScript("my-script", () => "ok", "Does something."); + + // Assert + Assert.Equal("my-script", script.Name); + Assert.Equal("Does something.", script.Description); + } + + [Fact] + public void Constructor_NullDescription_DescriptionIsNull() + { + // Arrange & Act + var script = new AgentInlineSkillScript("my-script", () => "ok"); + + // Assert + Assert.Null(script.Description); + } + + [Fact] + public void Constructor_NullName_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillScript(null!, () => "ok")); + } + + [Fact] + public void Constructor_WhitespaceName_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillScript(" ", () => "ok")); + } + + [Fact] + public void Constructor_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillScript("my-script", null!)); + } + + [Fact] + public async Task RunAsync_StringParameter_WorksAsync() + { + // Arrange + var script = new AgentInlineSkillScript("echo", (string message) => message); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["message"] = "hello world" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("hello world", result?.ToString()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs new file mode 100644 index 0000000000..779302d158 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentInlineSkillTests +{ + [Fact] + public void Constructor_WithNameAndDescription_SetsFrontmatter() + { + // Arrange & Act + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Assert + Assert.Equal("my-skill", skill.Frontmatter.Name); + Assert.Equal("A valid skill.", skill.Frontmatter.Description); + Assert.Null(skill.Frontmatter.License); + Assert.Null(skill.Frontmatter.Compatibility); + Assert.Null(skill.Frontmatter.AllowedTools); + Assert.Null(skill.Frontmatter.Metadata); + } + + [Fact] + public void Constructor_WithAllProps_SetsFrontmatter() + { + // Arrange + var metadata = new AdditionalPropertiesDictionary { ["key"] = "value" }; + + // Act + var skill = new AgentInlineSkill( + "my-skill", + "A valid skill.", + "Instructions.", + license: "MIT", + compatibility: "gpt-4", + allowedTools: "tool-a tool-b", + metadata: metadata); + + // Assert + Assert.Equal("my-skill", skill.Frontmatter.Name); + Assert.Equal("A valid skill.", skill.Frontmatter.Description); + Assert.Equal("MIT", skill.Frontmatter.License); + Assert.Equal("gpt-4", skill.Frontmatter.Compatibility); + Assert.Equal("tool-a tool-b", skill.Frontmatter.AllowedTools); + Assert.NotNull(skill.Frontmatter.Metadata); + Assert.Equal("value", skill.Frontmatter.Metadata["key"]); + } + + [Fact] + public void Constructor_WithFrontmatter_UsesFrontmatterDirectly() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.") + { + License = "Apache-2.0", + Compatibility = "gpt-4", + AllowedTools = "tool-a", + Metadata = new AdditionalPropertiesDictionary { ["env"] = "prod" }, + }; + + // Act + var skill = new AgentInlineSkill(frontmatter, "Instructions."); + + // Assert + Assert.Same(frontmatter, skill.Frontmatter); + Assert.Equal("Apache-2.0", skill.Frontmatter.License); + Assert.Equal("gpt-4", skill.Frontmatter.Compatibility); + Assert.Equal("tool-a", skill.Frontmatter.AllowedTools); + Assert.Equal("prod", skill.Frontmatter.Metadata!["env"]); + } + + [Fact] + public void Constructor_WithFrontmatter_NullFrontmatter_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkill(null!, "Instructions.")); + } + + [Fact] + public void Constructor_WithFrontmatter_NullInstructions_Throws() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill."); + + // Act & Assert + Assert.Throws(() => + new AgentInlineSkill(frontmatter, null!)); + } + + [Fact] + public void Constructor_WithAllProps_NullInstructions_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkill("my-skill", "A valid skill.", null!)); + } + + [Fact] + public void Content_ContainsNameDescriptionAndInstructions() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing."); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("my-skill", content); + Assert.Contains("A valid skill.", content); + Assert.Contains("\nDo the thing.\n", content); + } + + [Fact] + public void Content_EscapesXmlCharacters() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "xz\"w & it's more", "1 & 2 < 3"); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("my-skill", content); + Assert.Contains("x<y>z"w & it's more", content); + Assert.Contains("1 & 2 < 3", content); // instructions are not escaped + } + + [Fact] + public void Content_IsCachedAcrossAccesses() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act + var first = skill.Content; + var second = skill.Content; + + // Assert + Assert.Same(first, second); + } + + [Fact] + public void Content_IncludesResourcesAddedBeforeFirstAccess() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddResource("config", "value1", "A config resource."); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("config", content); + } + + [Fact] + public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddResource("dynamic", () => "hello"); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("dynamic", content); + } + + [Fact] + public void Content_IncludesScriptsAddedBeforeFirstAccess() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddScript("run", () => "result", "Runs something."); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("run", content); + } + + [Fact] + public void Content_IsCachedAndNotRebuilt() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddResource("r1", "v1"); + + // Act + var first = skill.Content; + var second = skill.Content; + + // Assert + Assert.Same(first, second); + } + + [Fact] + public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddResource("r1", "v1"); + skill.AddScript("s1", () => "ok"); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("", content); + Assert.Contains("r1", content); + Assert.Contains("", content); + Assert.Contains("s1", content); + } + + [Fact] + public void Content_ParametersSchema_IsXmlEscaped() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}"); + + // Act + var content = skill.Content; + + // Assert — JSON schema should be present and XML content chars escaped + Assert.Contains("parameters_schema", content); + Assert.DoesNotContain("(() => skill.AddResource("config", null!)); + } + + [Fact] + public void AddResource_NullDelegate_Throws() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act & Assert + Assert.Throws(() => skill.AddResource("config", (Delegate)null!)); + } + + [Fact] + public void AddScript_NullDelegate_Throws() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act & Assert + Assert.Throws(() => skill.AddScript("run", null!)); + } + + [Fact] + public void Resources_WhenNoneAdded_ReturnsNull() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act & Assert + Assert.Null(skill.Resources); + } + + [Fact] + public void Scripts_WhenNoneAdded_ReturnsNull() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act & Assert + Assert.Null(skill.Scripts); + } + + [Fact] + public void AddResource_ReturnsSameInstance_ForChaining() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act + var returned = skill.AddResource("r1", "v1"); + + // Assert + Assert.Same(skill, returned); + } + + [Fact] + public void AddResource_Delegate_ReturnsSameInstance_ForChaining() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act + var returned = skill.AddResource("r1", () => "v1"); + + // Assert + Assert.Same(skill, returned); + } + + [Fact] + public void AddScript_ReturnsSameInstance_ForChaining() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act + var returned = skill.AddScript("s1", () => "ok"); + + // Assert + Assert.Same(skill, returned); + } + + [Fact] + public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act + var content = skill.Content; + + // Assert + Assert.DoesNotContain("", content); + Assert.DoesNotContain("", content); + } + + [Fact] + public void Content_ResourcesAddedAfterCaching_AreNotIncluded() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + _ = skill.Content; // trigger caching + skill.AddResource("late-resource", "late-value"); + + // Act + var content = skill.Content; + + // Assert — the late resource should not appear because content was cached + Assert.DoesNotContain("late-resource", content); + } + + [Fact] + public void Content_ScriptsAddedAfterCaching_AreNotIncluded() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + _ = skill.Content; // trigger caching + skill.AddScript("late-script", () => "late"); + + // Act + var content = skill.Content; + + // Assert — the late script should not appear because content was cached + Assert.DoesNotContain("late-script", content); + } + + [Fact] + public void Content_ScriptWithDescription_IncludesDescriptionAttribute() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddScript("my-script", () => "ok", "Runs something."); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("description=\"Runs something.\"", content); + } + + [Fact] + public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddScript("simple", () => "ok"); + + // Act + var content = skill.Content; + + // Assert — parameterless Action delegates still produce a schema, so this + // verifies the script is at least included in the output + Assert.Contains("simple", content); + } + + [Fact] + public void Content_ResourceWithDescription_IncludesDescriptionAttribute() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + skill.AddResource("with-desc", "value", "A described resource."); + skill.AddResource("no-desc", "value"); + + // Act + var content = skill.Content; + + // Assert + Assert.Contains("description=\"A described resource.\"", content); + Assert.DoesNotContain("no-desc\" description", content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index 6dfc45918a..e7b3561f5d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -270,7 +270,7 @@ public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync // Arrange var source = new CountingAgentSkillsSource( [ - new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") + new AgentInlineSkill("concurrent-skill", "Concurrent test", "Body.") ]); var provider = new AgentSkillsProvider(source); @@ -502,7 +502,7 @@ public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() // Arrange var source = new CountingAgentSkillsSource( [ - new TestAgentSkill("no-cache-skill", "No cache test", "Body.") + new AgentInlineSkill("no-cache-skill", "No cache test", "Body.") ]); var provider = new AgentSkillsProviderBuilder() .UseSource(source) @@ -525,7 +525,7 @@ public async Task Build_WithCachingEnabled_CachesSkillsAsync() // Arrange var source = new CountingAgentSkillsSource( [ - new TestAgentSkill("cached-skill", "Cached test", "Body.") + new AgentInlineSkill("cached-skill", "Cached test", "Body.") ]); var provider = new AgentSkillsProviderBuilder() .UseSource(source) @@ -547,7 +547,7 @@ public async Task Build_DefaultOptions_CachesSkillsAsync() // Arrange var source = new CountingAgentSkillsSource( [ - new TestAgentSkill("default-skill", "Default test", "Body.") + new AgentInlineSkill("default-skill", "Default test", "Body.") ]); var provider = new AgentSkillsProviderBuilder() .UseSource(source) @@ -563,6 +563,70 @@ public async Task Build_DefaultOptions_CachesSkillsAsync() Assert.Equal(1, source.GetSkillsCallCount); } + [Fact] + public async Task Build_PreservesSourceRegistrationOrderAsync() + { + // Arrange — register file, inline, file in that order + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "file-skill-1", "First file skill", "Body 1."); + CreateSkillIn(dir2, "file-skill-2", "Second file skill", "Body 2."); + + var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline."); + + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(dir1) + .UseInlineSkills(inlineSkill) + .UseFileSkill(dir2) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — all three skills should be present in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + Assert.Contains("file-skill-1", result.Instructions); + Assert.Contains("inline-skill", result.Instructions); + Assert.Contains("file-skill-2", result.Instructions); + } + + [Fact] + public async Task Build_MixedSources_AllSkillsDiscoveredAsync() + { + // Arrange — use UseSource, UseInlineSkill, and UseFileSkill in mixed order + string dir = Path.Combine(this._testRoot, "mixed-dir"); + CreateSkillIn(dir, "file-skill", "File skill", "Body file."); + + var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline."); + var customSource = new CountingAgentSkillsSource( + [ + new AgentInlineSkill("custom-skill", "Custom source skill", "Body custom.") + ]); + + var provider = new AgentSkillsProviderBuilder() + .UseSource(customSource) + .UseInlineSkills(inlineSkill) + .UseFileSkill(dir) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — all skills from all sources are present + Assert.NotNull(result.Instructions); + Assert.Contains("custom-skill", result.Instructions); + Assert.Contains("inline-skill", result.Instructions); + Assert.Contains("file-skill", result.Instructions); + } + [Fact] public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() { @@ -722,6 +786,63 @@ public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync( Assert.Contains("Body 1.", content!.ToString()!); } + [Fact] + public async Task Constructor_InlineSkillsParams_ProvidesSkillsAsync() + { + // Arrange + var skill1 = new AgentInlineSkill("inline-a", "Inline A", "Instructions A."); + var skill2 = new AgentInlineSkill("inline-b", "Inline B", "Instructions B."); + var provider = new AgentSkillsProvider(skill1, skill2); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("inline-a", result.Instructions); + Assert.Contains("inline-b", result.Instructions); + } + + [Fact] + public async Task Constructor_InlineSkillsEnumerable_ProvidesSkillsAsync() + { + // Arrange + var skills = new List + { + new("enum-inline-a", "Inline A", "Instructions A."), + new("enum-inline-b", "Inline B", "Instructions B."), + }; + var provider = new AgentSkillsProvider(skills); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("enum-inline-a", result.Instructions); + Assert.Contains("enum-inline-b", result.Instructions); + } + + [Fact] + public async Task Constructor_InlineSkills_DeduplicatesAsync() + { + // Arrange — two inline skills with the same name + var skill1 = new AgentInlineSkill("dup-inline", "First", "First instructions."); + var skill2 = new AgentInlineSkill("dup-inline", "Second", "Second instructions."); + var provider = new AgentSkillsProvider(skill1, skill2); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-inline" })); + + // Assert — only one occurrence (first) + Assert.Contains("First instructions.", content!.ToString()!); + } + /// /// A test skill source that counts how many times is called. /// @@ -743,23 +864,4 @@ public override Task> GetSkillsAsync(CancellationToken cancell return Task.FromResult(this._skills); } } - - private sealed class TestAgentSkill : AgentSkill - { - private readonly string _content; - - public TestAgentSkill(string name, string description, string content) - { - this.Frontmatter = new AgentSkillFrontmatter(name, description); - this._content = content; - } - - public override AgentSkillFrontmatter Frontmatter { get; } - - public override string Content => this._content; - - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => null; - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs index 860f402005..0ee6af9b3b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -13,12 +13,14 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class DeduplicatingAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkills() { // Arrange - var inner = new TestAgentSkillsSource( - new TestAgentSkill("skill-a", "A", "Instructions A."), - new TestAgentSkill("skill-b", "B", "Instructions B.")); + var inner = new AgentInMemorySkillsSource(new AgentSkill[] + { + new AgentInlineSkill("skill-a", "A", "Instructions A."), + new AgentInlineSkill("skill-b", "B", "Instructions B."), + }); var source = new DeduplicatingAgentSkillsSource(inner); // Act @@ -29,16 +31,16 @@ public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() } [Fact] - public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrence() { // Arrange var skills = new AgentSkill[] { - new TestAgentSkill("dupe", "First", "Instructions 1."), - new TestAgentSkill("dupe", "Second", "Instructions 2."), - new TestAgentSkill("unique", "Unique", "Instructions 3."), + new AgentInlineSkill("dupe", "First", "Instructions 1."), + new AgentInlineSkill("dupe", "Second", "Instructions 2."), + new AgentInlineSkill("unique", "Unique", "Instructions 3."), }; - var inner = new TestAgentSkillsSource(skills); + var inner = new AgentInMemorySkillsSource(skills); var source = new DeduplicatingAgentSkillsSource(inner); // Act @@ -51,9 +53,9 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() } [Fact] - public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirst() { - // Arrange — use a custom source that returns skills with same name but different casing + // Arrange - Use a custom source that returns skills with same name but different casing var inner = new FakeDuplicateCaseSource(); var source = new DeduplicatingAgentSkillsSource(inner); @@ -66,10 +68,10 @@ public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() { // Arrange - var inner = new TestAgentSkillsSource(System.Array.Empty()); + var inner = new AgentInMemorySkillsSource(System.Array.Empty()); var source = new DeduplicatingAgentSkillsSource(inner); // Act @@ -90,8 +92,8 @@ public override Task> GetSkillsAsync(CancellationToken cancell // two skills with the same lowercase name to test case-insensitive dedup. var skills = new List { - new TestAgentSkill("my-skill", "First", "Instructions 1."), - new TestAgentSkill("my-skill", "Second", "Instructions 2."), + new AgentInlineSkill("my-skill", "First", "Instructions 1."), + new AgentInlineSkill("my-skill", "Second", "Instructions 2."), }; return Task.FromResult>(skills); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs index de145004e0..b24ef2e1b1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -12,12 +12,14 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class FilteringAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkills() { // Arrange - var inner = new TestAgentSkillsSource( - new TestAgentSkill("skill-a", "A", "Instructions A."), - new TestAgentSkill("skill-b", "B", "Instructions B.")); + var inner = new AgentInMemorySkillsSource(new AgentSkill[] + { + new AgentInlineSkill("skill-a", "A", "Instructions A."), + new AgentInlineSkill("skill-b", "B", "Instructions B."), + }); var source = new FilteringAgentSkillsSource(inner, _ => true); // Act @@ -28,12 +30,14 @@ public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() } [Fact] - public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmpty() { // Arrange - var inner = new TestAgentSkillsSource( - new TestAgentSkill("skill-a", "A", "Instructions A."), - new TestAgentSkill("skill-b", "B", "Instructions B.")); + var inner = new AgentInMemorySkillsSource(new AgentSkill[] + { + new AgentInlineSkill("skill-a", "A", "Instructions A."), + new AgentInlineSkill("skill-b", "B", "Instructions B."), + }); var source = new FilteringAgentSkillsSource(inner, _ => false); // Act @@ -44,13 +48,15 @@ public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() } [Fact] - public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnly() { // Arrange - var inner = new TestAgentSkillsSource( - new TestAgentSkill("keep-me", "Keep", "Instructions."), - new TestAgentSkill("drop-me", "Drop", "Instructions."), - new TestAgentSkill("keep-also", "KeepAlso", "Instructions.")); + var inner = new AgentInMemorySkillsSource(new AgentSkill[] + { + new AgentInlineSkill("keep-me", "Keep", "Instructions."), + new AgentInlineSkill("drop-me", "Drop", "Instructions."), + new AgentInlineSkill("keep-also", "KeepAlso", "Instructions."), + }); var source = new FilteringAgentSkillsSource( inner, skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)); @@ -64,10 +70,10 @@ public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() { // Arrange - var inner = new TestAgentSkillsSource(Array.Empty()); + var inner = new AgentInMemorySkillsSource(Array.Empty()); var source = new FilteringAgentSkillsSource(inner, _ => true); // Act @@ -81,7 +87,7 @@ public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() public void Constructor_NullPredicate_Throws() { // Arrange - var inner = new TestAgentSkillsSource(Array.Empty()); + var inner = new AgentInMemorySkillsSource(Array.Empty()); // Act & Assert Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!)); @@ -95,14 +101,16 @@ public void Constructor_NullInnerSource_Throws() } [Fact] - public async Task GetSkillsAsync_PreservesOrderAsync() + public async Task GetSkillsAsync_PreservesOrder() { // Arrange - var inner = new TestAgentSkillsSource( - new TestAgentSkill("alpha", "Alpha", "Instructions."), - new TestAgentSkill("beta", "Beta", "Instructions."), - new TestAgentSkill("gamma", "Gamma", "Instructions."), - new TestAgentSkill("delta", "Delta", "Instructions.")); + var inner = new AgentInMemorySkillsSource(new AgentSkill[] + { + new AgentInlineSkill("alpha", "Alpha", "Instructions."), + new AgentInlineSkill("beta", "Beta", "Instructions."), + new AgentInlineSkill("gamma", "Gamma", "Instructions."), + new AgentInlineSkill("delta", "Delta", "Instructions."), + }); // Keep only alpha and gamma var source = new FilteringAgentSkillsSource( From c0439c811cde78d3b8e73f6f18cc86dbe5735c86 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:27:46 +0000 Subject: [PATCH 2/5] Fix IDE1006 and IDE0004 formatting errors in test files - Add 'Async' suffix to async test methods in FilteringAgentSkillsSourceTests, DeduplicatingAgentSkillsSourceTests, and AgentInMemorySkillsSourceTests - Use pragma to suppress false-positive IDE0004 on casts needed for overload disambiguation in AgentInlineSkillTests and AgentInlineSkillResourceTests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentSkills/AgentInMemorySkillsSourceTests.cs | 2 +- .../AgentSkills/AgentInlineSkillResourceTests.cs | 8 +++++--- .../AgentSkills/AgentInlineSkillTests.cs | 8 +++++--- .../AgentSkills/DeduplicatingAgentSkillsSourceTests.cs | 8 ++++---- .../AgentSkills/FilteringAgentSkillsSourceTests.cs | 10 +++++----- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs index f28019b70f..69e96f0957 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInMemorySkillsSourceTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class AgentInMemorySkillsSourceTests { [Fact] - public async Task GetSkillsAsync_ValidSkills_ReturnsAll() + public async Task GetSkillsAsync_ValidSkills_ReturnsAllAsync() { // Arrange var skills = new AgentSkill[] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs index 913abb9283..202a90d460 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -97,9 +97,11 @@ public void Constructor_StaticValue_NullDescription_DescriptionIsNull() [Fact] public void Constructor_StaticValue_NullValue_Throws() { - // Act & Assert + // Act & Assert — cast needed to target the object overload +#pragma warning disable IDE0004 Assert.Throws(() => - new AgentInlineSkillResource("my-res", null!)); + new AgentInlineSkillResource("my-res", (object)null!)); +#pragma warning restore IDE0004 } [Fact] @@ -107,7 +109,7 @@ public void Constructor_Delegate_NullMethod_Throws() { // Act & Assert Assert.Throws(() => - new AgentInlineSkillResource("my-res", (Delegate)null!)); + new AgentInlineSkillResource("my-res", null!)); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs index 779302d158..911d482f84 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs @@ -244,8 +244,10 @@ public void AddResource_NullValue_Throws() // Arrange var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); - // Act & Assert - Assert.Throws(() => skill.AddResource("config", null!)); + // Act & Assert — cast needed to target the object overload +#pragma warning disable IDE0004 + Assert.Throws(() => skill.AddResource("config", (object)null!)); +#pragma warning restore IDE0004 } [Fact] @@ -255,7 +257,7 @@ public void AddResource_NullDelegate_Throws() var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); // Act & Assert - Assert.Throws(() => skill.AddResource("config", (Delegate)null!)); + Assert.Throws(() => skill.AddResource("config", null!)); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs index 0ee6af9b3b..7895023681 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class DeduplicatingAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkills() + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -31,7 +31,7 @@ public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkills() } [Fact] - public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrence() + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() { // Arrange var skills = new AgentSkill[] @@ -53,7 +53,7 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrence() } [Fact] - public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirst() + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() { // Arrange - Use a custom source that returns skills with same name but different casing var inner = new FakeDuplicateCaseSource(); @@ -68,7 +68,7 @@ public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirst() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(System.Array.Empty()); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs index b24ef2e1b1..12bdb28e05 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills; public sealed class FilteringAgentSkillsSourceTests { [Fact] - public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkills() + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -30,7 +30,7 @@ public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkills() } [Fact] - public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmpty() + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -48,7 +48,7 @@ public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmpty() } [Fact] - public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnly() + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] @@ -70,7 +70,7 @@ public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnly() } [Fact] - public async Task GetSkillsAsync_EmptySource_ReturnsEmpty() + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() { // Arrange var inner = new AgentInMemorySkillsSource(Array.Empty()); @@ -101,7 +101,7 @@ public void Constructor_NullInnerSource_Throws() } [Fact] - public async Task GetSkillsAsync_PreservesOrder() + public async Task GetSkillsAsync_PreservesOrderAsync() { // Arrange var inner = new AgentInMemorySkillsSource(new AgentSkill[] From 50999da42ac41fcbbb1b0c2edfd10de6bf055b85 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:58:26 +0000 Subject: [PATCH 3/5] address issues --- dotnet/agent-framework-dotnet.slnx | 6 ++++++ .../src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a33bcc4b12..df1c91eea4 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -77,6 +77,8 @@ + + @@ -454,6 +456,10 @@ + + + + diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index 626e0cfda2..70d7939227 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -109,7 +109,7 @@ public AgentSkillsProvider( ILoggerFactory? loggerFactory = null) : this( new DeduplicatingAgentSkillsSource( - new AgentFileSkillsSource(skillPaths, Throw.IfNull(scriptRunner), fileOptions, loggerFactory), + new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), loggerFactory), options, loggerFactory) From 01ca2091d3d6e9b18ac92830251a02e53c18edfe Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:16:40 +0000 Subject: [PATCH 4/5] address comments --- .../Skills/Programmatic/AgentInlineSkill.cs | 12 +++++++----- .../AgentSkills/AgentInlineSkillTests.cs | 2 +- .../AgentSkills/AgentSkillsProviderTests.cs | 14 +++++++++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs index beebea50b6..d326a47a59 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -17,9 +17,11 @@ namespace Microsoft.Agents.AI; /// /// All calls to , /// , and -/// must be made before the skill is passed to an or -/// . Calls made after that point will not be -/// reflected in the generated . +/// must be made before the skill's is first accessed. +/// Calls made after that point will not be reflected in the generated +/// . In typical usage, this means configuring all +/// resources and scripts before registering the skill with an +/// or . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentInlineSkill : AgentSkill @@ -38,7 +40,7 @@ public sealed class AgentInlineSkill : AgentSkill public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) { this.Frontmatter = Throw.IfNull(frontmatter); - this._instructions = Throw.IfNull(instructions); + this._instructions = Throw.IfNullOrWhitespace(instructions); } /// @@ -131,7 +133,7 @@ private string BuildContent() sb.Append($"{EscapeXmlString(this.Frontmatter.Name)}\n") .Append($"{EscapeXmlString(this.Frontmatter.Description)}\n\n") .Append("\n") - .Append(this._instructions) + .Append(EscapeXmlString(this._instructions)) .Append("\n"); if (this.Resources is { Count: > 0 }) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs index 911d482f84..9a1f6a4951 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs @@ -128,7 +128,7 @@ public void Content_EscapesXmlCharacters() // Assert Assert.Contains("my-skill", content); Assert.Contains("x<y>z"w & it's more", content); - Assert.Contains("1 & 2 < 3", content); // instructions are not escaped + Assert.Contains("1 & 2 < 3", content); // instructions are escaped } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index e7b3561f5d..5856430ec9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -589,9 +589,17 @@ public async Task Build_PreservesSourceRegistrationOrderAsync() // Assert — all three skills should be present in alphabetical order in the prompt Assert.NotNull(result.Instructions); - Assert.Contains("file-skill-1", result.Instructions); - Assert.Contains("inline-skill", result.Instructions); - Assert.Contains("file-skill-2", result.Instructions); + var instructions = result.Instructions!; + var indexFileSkill1 = instructions.IndexOf("file-skill-1", StringComparison.Ordinal); + var indexFileSkill2 = instructions.IndexOf("file-skill-2", StringComparison.Ordinal); + var indexInlineSkill = instructions.IndexOf("inline-skill", StringComparison.Ordinal); + + Assert.True(indexFileSkill1 >= 0, "file-skill-1 should be present in the instructions."); + Assert.True(indexFileSkill2 >= 0, "file-skill-2 should be present in the instructions."); + Assert.True(indexInlineSkill >= 0, "inline-skill should be present in the instructions."); + + Assert.True(indexFileSkill1 < indexFileSkill2, "file-skill-1 should appear before file-skill-2."); + Assert.True(indexFileSkill2 < indexInlineSkill, "file-skill-2 should appear before inline-skill."); } [Fact] From ce951a6e4c7d4f08bf81fb1cd37c8c3b5be9d706 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:02:35 +0000 Subject: [PATCH 5/5] make inline skills script and resource model classes internal --- .../Agent_Step02_CodeDefinedSkills/README.md | 6 ++--- .../samples/02-agents/AgentSkills/README.md | 4 ++-- .../Skills/AgentSkillsProviderBuilder.cs | 22 +++++++++---------- .../Programmatic/AgentInlineSkillResource.cs | 2 +- .../Programmatic/AgentInlineSkillScript.cs | 2 +- .../AgentSkills/AgentSkillsProviderTests.cs | 6 ++--- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md index 2a778ed68c..5b7c8747dd 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/README.md @@ -5,9 +5,9 @@ This sample demonstrates how to define **Agent Skills entirely in code** using ` ## What it demonstrates - Creating skills programmatically with `AgentInlineSkill` — no SKILL.md files needed -- **Static resources** via `AgentInlineSkillResource` with inline content -- **Dynamic resources** via `AgentInlineSkillResource` with a factory delegate (computed at runtime) -- **Code scripts** via `AgentInlineSkillScript` with a delegate handler +- **Static resources** via `AddResource` with inline content +- **Dynamic resources** via `AddResource` with a factory delegate (computed at runtime) +- **Code scripts** via `AddScript` with a delegate handler - Using the `AgentSkillsProvider` constructor with inline skills ## Skills Included diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index db14e80db4..6011384997 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -14,8 +14,8 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w | Aspect | File-Based | Code-Defined | |--------|-----------|--------------| | Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# | -| Resources | All files in skill directory (filtered by extension) | `AgentInlineSkillResource` (static value or delegate-backed) | -| Scripts | Supported via script executor delegate | `AgentInlineSkillScript` delegates | +| Resources | All files in skill directory (filtered by extension) | `AddResource` (static value or delegate-backed) | +| Scripts | Supported via script executor delegate | `AddScript` delegates | | Discovery | Automatic from directory path | Explicit via constructor | | Dynamic content | No (static files only) | Yes (factory delegates) | | Reusability | Copy skill directory | Inline or shared instances | diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index f5c7ff6c1c..8e52cc522e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI; /// /// var provider = new AgentSkillsProviderBuilder() /// .UseFileSkills("/path/to/skills") -/// .UseInlineSkills(myInlineSkill1, myInlineSkill2) +/// .UseSkills(myInlineSkill1, myInlineSkill2) /// .Build(); /// /// @@ -70,21 +70,21 @@ public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, } /// - /// Adds a single inline (code-defined) skill. + /// Adds a single skill. /// - /// The inline skill to add. + /// The skill to add. /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseInlineSkill(AgentInlineSkill skill) + public AgentSkillsProviderBuilder UseSkill(AgentSkill skill) { - return this.UseInlineSkills(skill); + return this.UseSkills(skill); } /// - /// Adds inline (code-defined) skills. + /// Adds one or more skills. /// - /// The inline skills to add. + /// The skills to add. /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseInlineSkills(params AgentInlineSkill[] skills) + public AgentSkillsProviderBuilder UseSkills(params AgentSkill[] skills) { var source = new AgentInMemorySkillsSource(skills); this._sourceFactories.Add((_, _) => source); @@ -92,11 +92,11 @@ public AgentSkillsProviderBuilder UseInlineSkills(params AgentInlineSkill[] skil } /// - /// Adds inline (code-defined) skills. + /// Adds skills from the specified collection. /// - /// The inline skills to add. + /// The skills to add. /// This builder instance for chaining. - public AgentSkillsProviderBuilder UseInlineSkills(IEnumerable skills) + public AgentSkillsProviderBuilder UseSkills(IEnumerable skills) { var source = new AgentInMemorySkillsSource(skills); this._sourceFactories.Add((_, _) => source); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs index a700d7fbf7..38791c3b21 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI; /// A skill resource defined in code, backed by either a static value or a delegate. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class AgentInlineSkillResource : AgentSkillResource +internal sealed class AgentInlineSkillResource : AgentSkillResource { private readonly object? _value; private readonly AIFunction? _function; diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 0d64a43294..acb4f4780b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI; /// A skill script backed by a delegate. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class AgentInlineSkillScript : AgentSkillScript +internal sealed class AgentInlineSkillScript : AgentSkillScript { private readonly AIFunction _function; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index 5856430ec9..87e98b3da3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -576,7 +576,7 @@ public async Task Build_PreservesSourceRegistrationOrderAsync() var provider = new AgentSkillsProviderBuilder() .UseFileSkill(dir1) - .UseInlineSkills(inlineSkill) + .UseSkills(inlineSkill) .UseFileSkill(dir2) .UseFileScriptRunner(s_noOpExecutor) .UseOptions(o => o.DisableCaching = true) @@ -605,7 +605,7 @@ public async Task Build_PreservesSourceRegistrationOrderAsync() [Fact] public async Task Build_MixedSources_AllSkillsDiscoveredAsync() { - // Arrange — use UseSource, UseInlineSkill, and UseFileSkill in mixed order + // Arrange — use UseSource, UseSkill, and UseFileSkill in mixed order string dir = Path.Combine(this._testRoot, "mixed-dir"); CreateSkillIn(dir, "file-skill", "File skill", "Body file."); @@ -617,7 +617,7 @@ public async Task Build_MixedSources_AllSkillsDiscoveredAsync() var provider = new AgentSkillsProviderBuilder() .UseSource(customSource) - .UseInlineSkills(inlineSkill) + .UseSkills(inlineSkill) .UseFileSkill(dir) .UseFileScriptRunner(s_noOpExecutor) .UseOptions(o => o.DisableCaching = true)