diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a5b93487b3..df1c91eea4 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -105,6 +105,7 @@ + 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..5b7c8747dd --- /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 `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 + +### 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..6011384997 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) | `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 | + +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..70d7939227 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -116,6 +116,38 @@ public AgentSkillsProvider( { } + /// + /// 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) + { + } + /// /// Initializes a new instance of the class /// from a custom . Unlike other constructors, this one does not diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 17d7f2d6f3..8e52cc522e 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") +/// .UseSkills(myInlineSkill1, myInlineSkill2) /// .Build(); /// /// @@ -65,6 +69,40 @@ public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, return this; } + /// + /// Adds a single skill. + /// + /// The skill to add. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSkill(AgentSkill skill) + { + return this.UseSkills(skill); + } + + /// + /// Adds one or more skills. + /// + /// The skills to add. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSkills(params AgentSkill[] skills) + { + var source = new AgentInMemorySkillsSource(skills); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Adds skills from the specified collection. + /// + /// The skills to add. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSkills(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..d326a47a59 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -0,0 +1,215 @@ +// 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'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 +{ + 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.IfNullOrWhitespace(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(EscapeXmlString(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..38791c3b21 --- /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)] +internal 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..acb4f4780b --- /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)] +internal 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..69e96f0957 --- /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_ReturnsAllAsync() + { + // 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..202a90d460 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -0,0 +1,155 @@ +// 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 — cast needed to target the object overload +#pragma warning disable IDE0004 + Assert.Throws(() => + new AgentInlineSkillResource("my-res", (object)null!)); +#pragma warning restore IDE0004 + } + + [Fact] + public void Constructor_Delegate_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource("my-res", 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..9a1f6a4951 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillTests.cs @@ -0,0 +1,420 @@ +// 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 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", (object)null!)); +#pragma warning restore IDE0004 + } + + [Fact] + public void AddResource_NullDelegate_Throws() + { + // Arrange + var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."); + + // Act & Assert + Assert.Throws(() => skill.AddResource("config", 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..87e98b3da3 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,78 @@ 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) + .UseSkills(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); + 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] + public async Task Build_MixedSources_AllSkillsDiscoveredAsync() + { + // 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."); + + 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) + .UseSkills(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 +794,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 +872,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..7895023681 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -16,9 +16,11 @@ public sealed class DeduplicatingAgentSkillsSourceTests public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() { // 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 @@ -34,11 +36,11 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() // 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 @@ -53,7 +55,7 @@ public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() [Fact] public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() { - // 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); @@ -69,7 +71,7 @@ public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() { // 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..12bdb28e05 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -15,9 +15,11 @@ public sealed class FilteringAgentSkillsSourceTests public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() { // 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 @@ -31,9 +33,11 @@ public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() { // 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 @@ -47,10 +51,12 @@ public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() { // 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)); @@ -67,7 +73,7 @@ public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() { // 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!)); @@ -98,11 +104,13 @@ public void Constructor_NullInnerSource_Throws() public async Task GetSkillsAsync_PreservesOrderAsync() { // 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(