mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add inline skills API (#4951)
* add inline skills * 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> * address issues * address comments * make inline skills script and resource model classes internal --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
4527dee64b
commit
ade295b122
@@ -105,6 +105,7 @@
|
||||
<Folder Name="/Samples/02-agents/AgentSkills/">
|
||||
<File Path="samples/02-agents/AgentSkills/README.md" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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}");
|
||||
@@ -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**
|
||||
```
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A skill source that holds <see cref="AgentSkill"/> instances in memory.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class AgentInMemorySkillsSource : AgentSkillsSource
|
||||
{
|
||||
private readonly List<AgentSkill> _skills;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentInMemorySkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skills">The skills to include in this source.</param>
|
||||
public AgentInMemorySkillsSource(IEnumerable<AgentSkill> skills)
|
||||
{
|
||||
this._skills = Throw.IfNull(skills).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IList<AgentSkill>>(this._skills);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ namespace Microsoft.Agents.AI;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A skill represents a domain-specific capability with instructions, resources, and scripts.
|
||||
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed).
|
||||
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed)
|
||||
/// and <see cref="AgentInlineSkill"/> (code-defined).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skill metadata follows the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
@@ -35,6 +36,8 @@ public abstract class AgentSkill
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
public abstract string Content { get; }
|
||||
|
||||
|
||||
@@ -116,6 +116,38 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
|
||||
/// with one or more inline (code-defined) skills.
|
||||
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
|
||||
/// </summary>
|
||||
/// <param name="skills">The inline skills to include.</param>
|
||||
public AgentSkillsProvider(params AgentInlineSkill[] skills)
|
||||
: this(skills as IEnumerable<AgentInlineSkill>)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
|
||||
/// with inline (code-defined) skills.
|
||||
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
|
||||
/// </summary>
|
||||
/// <param name="skills">The inline skills to include.</param>
|
||||
/// <param name="options">Optional provider configuration.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentSkillsProvider(
|
||||
IEnumerable<AgentInlineSkill> skills,
|
||||
AgentSkillsProviderOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
new DeduplicatingAgentSkillsSource(
|
||||
new AgentInMemorySkillsSource(Throw.IfNull(skills)),
|
||||
loggerFactory),
|
||||
options,
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
|
||||
/// from a custom <see cref="AgentSkillsSource"/>. Unlike other constructors, this one does not
|
||||
|
||||
@@ -13,9 +13,13 @@ namespace Microsoft.Agents.AI;
|
||||
/// Fluent builder for constructing an <see cref="AgentSkillsProvider"/> backed by a composite source.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Use this builder to combine multiple skill sources into a single provider:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// var provider = new AgentSkillsProviderBuilder()
|
||||
/// .UseFileSkills("/path/to/skills")
|
||||
/// .UseSkills(myInlineSkill1, myInlineSkill2)
|
||||
/// .Build();
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
@@ -65,6 +69,40 @@ public sealed class AgentSkillsProviderBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single skill.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill to add.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseSkill(AgentSkill skill)
|
||||
{
|
||||
return this.UseSkills(skill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds one or more skills.
|
||||
/// </summary>
|
||||
/// <param name="skills">The skills to add.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseSkills(params AgentSkill[] skills)
|
||||
{
|
||||
var source = new AgentInMemorySkillsSource(skills);
|
||||
this._sourceFactories.Add((_, _) => source);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds skills from the specified collection.
|
||||
/// </summary>
|
||||
/// <param name="skills">The skills to add.</param>
|
||||
/// <returns>This builder instance for chaining.</returns>
|
||||
public AgentSkillsProviderBuilder UseSkills(IEnumerable<AgentSkill> skills)
|
||||
{
|
||||
var source = new AgentInMemorySkillsSource(skills);
|
||||
this._sourceFactories.Add((_, _) => source);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom skill source.
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A skill defined entirely in code with resources (static values or delegates) and scripts (delegates).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All calls to <see cref="AddResource(string, object, string?)"/>,
|
||||
/// <see cref="AddResource(string, Delegate, string?)"/>, and <see cref="AddScript"/>
|
||||
/// must be made before the skill's <see cref="Content"/> is first accessed.
|
||||
/// Calls made after that point will not be reflected in the generated
|
||||
/// <see cref="Content"/>. In typical usage, this means configuring all
|
||||
/// resources and scripts before registering the skill with an
|
||||
/// <see cref="AgentSkillsProvider"/> or <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentInlineSkill : AgentSkill
|
||||
{
|
||||
private readonly string _instructions;
|
||||
private List<AgentSkillResource>? _resources;
|
||||
private List<AgentSkillScript>? _scripts;
|
||||
private string? _cachedContent;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentInlineSkill"/> class
|
||||
/// with a pre-built <see cref="AgentSkillFrontmatter"/>.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">The skill frontmatter containing name, description, and other metadata.</param>
|
||||
/// <param name="instructions">Skill instructions text.</param>
|
||||
public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this._instructions = Throw.IfNullOrWhitespace(instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentInlineSkill"/> class
|
||||
/// with all frontmatter properties specified individually.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name in kebab-case.</param>
|
||||
/// <param name="description">Skill description for discovery.</param>
|
||||
/// <param name="instructions">Skill instructions text.</param>
|
||||
/// <param name="license">Optional license name or reference.</param>
|
||||
/// <param name="compatibility">Optional compatibility information (max 500 chars).</param>
|
||||
/// <param name="allowedTools">Optional space-delimited list of pre-approved tools.</param>
|
||||
/// <param name="metadata">Optional arbitrary key-value metadata.</param>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content => this._cachedContent ??= this.BuildContent();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
/// <returns>This instance, for chaining.</returns>
|
||||
public AgentInlineSkill AddResource(string name, object value, string? description = null)
|
||||
{
|
||||
(this._resources ??= []).Add(new AgentInlineSkillResource(name, value, description));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a dynamic resource with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
/// <returns>This instance, for chaining.</returns>
|
||||
public AgentInlineSkill AddResource(string name, Delegate method, string? description = null)
|
||||
{
|
||||
(this._resources ??= []).Add(new AgentInlineSkillResource(name, method, description));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a script with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
/// <returns>This instance, for chaining.</returns>
|
||||
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($"<name>{EscapeXmlString(this.Frontmatter.Name)}</name>\n")
|
||||
.Append($"<description>{EscapeXmlString(this.Frontmatter.Description)}</description>\n\n")
|
||||
.Append("<instructions>\n")
|
||||
.Append(EscapeXmlString(this._instructions))
|
||||
.Append("\n</instructions>");
|
||||
|
||||
if (this.Resources is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<resources>\n");
|
||||
foreach (var resource in this.Resources)
|
||||
{
|
||||
if (resource.Description is not null)
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</resources>");
|
||||
}
|
||||
|
||||
if (this.Scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<scripts>\n");
|
||||
foreach (var script in this.Scripts)
|
||||
{
|
||||
JsonElement? parametersSchema = ((AgentInlineSkillScript)script).ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes XML special characters: always escapes <c>&</c>, <c><</c>, <c>></c>,
|
||||
/// <c>"</c>, and <c>'</c>. When <paramref name="preserveQuotes"/> is <see langword="true"/>,
|
||||
/// quotes are left unescaped to preserve readability of embedded content such as JSON.
|
||||
/// </summary>
|
||||
/// <param name="value">The string to escape.</param>
|
||||
/// <param name="preserveQuotes">
|
||||
/// When <see langword="true"/>, leaves <c>"</c> and <c>'</c> unescaped for use in XML element content (e.g., JSON).
|
||||
/// When <see langword="false"/> (default), escapes all XML special characters including quotes.
|
||||
/// </param>
|
||||
private static string EscapeXmlString(string value, bool preserveQuotes = false)
|
||||
{
|
||||
var result = value
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">");
|
||||
|
||||
if (!preserveQuotes)
|
||||
{
|
||||
result = result
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A skill resource defined in code, backed by either a static value or a delegate.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class AgentInlineSkillResource : AgentSkillResource
|
||||
{
|
||||
private readonly object? _value;
|
||||
private readonly AIFunction? _function;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class with a static value.
|
||||
/// The value is returned as-is when <see cref="ReadAsync"/> is called.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
public AgentInlineSkillResource(string name, object value, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
this._value = Throw.IfNull(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class with a delegate.
|
||||
/// The delegate is invoked via an <see cref="AIFunction"/> each time <see cref="ReadAsync"/> is called,
|
||||
/// producing a dynamic (computed) value.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
public AgentInlineSkillResource(string name, Delegate method, string? description = null)
|
||||
: base(name, description)
|
||||
{
|
||||
Throw.IfNull(method);
|
||||
this._function = AIFunctionFactory.Create(method, name: this.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A skill script backed by a delegate.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class AgentInlineSkillScript : AgentSkillScript
|
||||
{
|
||||
private readonly AIFunction _function;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentInlineSkillScript"/> class from a delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <see cref="AIFunctionFactory"/>.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked. Parameters are automatically deserialized from JSON.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JSON schema describing the parameters accepted by this script, or <see langword="null"/> if not available.
|
||||
/// </summary>
|
||||
public JsonElement? ParametersSchema => this._function.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentInMemorySkillsSource"/>.
|
||||
/// </summary>
|
||||
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<ArgumentException>(() =>
|
||||
new AgentInlineSkill(invalidName, "A skill.", "Instructions."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullSkills_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentInMemorySkillsSource(null!));
|
||||
}
|
||||
}
|
||||
+155
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentInlineSkillResource"/>.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() =>
|
||||
new AgentInlineSkillResource("my-res", (object)null!));
|
||||
#pragma warning restore IDE0004
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Delegate_NullMethod_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new AgentInlineSkillResource("my-res", null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullName_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new AgentInlineSkillResource(null!, "val"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhitespaceName_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+132
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentInlineSkillScript"/>.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() =>
|
||||
new AgentInlineSkillScript(null!, () => "ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhitespaceName_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new AgentInlineSkillScript(" ", () => "ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMethod_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentInlineSkill"/>.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() =>
|
||||
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<ArgumentNullException>(() =>
|
||||
new AgentInlineSkill(frontmatter, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithAllProps_NullInstructions_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
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("<name>my-skill</name>", content);
|
||||
Assert.Contains("<description>A valid skill.</description>", content);
|
||||
Assert.Contains("<instructions>\nDo the thing.\n</instructions>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_EscapesXmlCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
Assert.Contains("<description>x<y>z"w & it's more</description>", 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("<resources>", 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("<resources>", 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("<scripts>", 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("<resources>", content);
|
||||
Assert.Contains("r1", content);
|
||||
Assert.Contains("<scripts>", 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("<![CDATA[", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddResource_NullValue_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert — cast needed to target the object overload
|
||||
#pragma warning disable IDE0004
|
||||
Assert.Throws<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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("<resources>", content);
|
||||
Assert.DoesNotContain("<scripts>", 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);
|
||||
}
|
||||
}
|
||||
+133
-23
@@ -270,7 +270,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// 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 sealed class AgentSkillsProviderTests : IDisposable
|
||||
// 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 sealed class AgentSkillsProviderTests : IDisposable
|
||||
// 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 sealed class AgentSkillsProviderTests : IDisposable
|
||||
// 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 sealed class AgentSkillsProviderTests : IDisposable
|
||||
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 sealed class AgentSkillsProviderTests : IDisposable
|
||||
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<AgentInlineSkill>
|
||||
{
|
||||
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<string, object?> { ["skillName"] = "dup-inline" }));
|
||||
|
||||
// Assert — only one occurrence (first)
|
||||
Assert.Contains("First instructions.", content!.ToString()!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test skill source that counts how many times <see cref="GetSkillsAsync"/> is called.
|
||||
/// </summary>
|
||||
@@ -743,23 +872,4 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
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<AgentSkillResource>? Resources => null;
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-11
@@ -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 sealed class DeduplicatingAgentSkillsSourceTests
|
||||
// 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 sealed class DeduplicatingAgentSkillsSourceTests
|
||||
[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 sealed class DeduplicatingAgentSkillsSourceTests
|
||||
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(System.Array.Empty<AgentSkill>());
|
||||
var inner = new AgentInMemorySkillsSource(System.Array.Empty<AgentSkill>());
|
||||
var source = new DeduplicatingAgentSkillsSource(inner);
|
||||
|
||||
// Act
|
||||
@@ -90,8 +92,8 @@ public sealed class DeduplicatingAgentSkillsSourceTests
|
||||
// two skills with the same lowercase name to test case-insensitive dedup.
|
||||
var skills = new List<AgentSkill>
|
||||
{
|
||||
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<IList<AgentSkill>>(skills);
|
||||
}
|
||||
|
||||
+25
-17
@@ -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 sealed class FilteringAgentSkillsSourceTests
|
||||
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 sealed class FilteringAgentSkillsSourceTests
|
||||
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 sealed class FilteringAgentSkillsSourceTests
|
||||
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
|
||||
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
|
||||
var source = new FilteringAgentSkillsSource(inner, _ => true);
|
||||
|
||||
// Act
|
||||
@@ -81,7 +87,7 @@ public sealed class FilteringAgentSkillsSourceTests
|
||||
public void Constructor_NullPredicate_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
|
||||
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(inner, null!));
|
||||
@@ -98,11 +104,13 @@ public sealed class FilteringAgentSkillsSourceTests
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user