From 213e16e733c199cf13b4ca9490879bd2046eb042 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:44:00 +0000 Subject: [PATCH 01/10] add adr suggesting a new design to support a multi-source architecture for agent skills --- dotnet/0021-agent-skills-design.md | 529 +++++++++++++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 dotnet/0021-agent-skills-design.md diff --git a/dotnet/0021-agent-skills-design.md b/dotnet/0021-agent-skills-design.md new file mode 100644 index 0000000000..b267d609f3 --- /dev/null +++ b/dotnet/0021-agent-skills-design.md @@ -0,0 +1,529 @@ +--- +status: proposed +date: 2026-03-19 +contact: sergeymenshykh +--- + +# Agent Skills: Multi-Source Architecture + +## Context and Problem Statement + +The Agent Framework needs a skills system that lets agents discover and use domain-specific knowledge, reference documents, and executable scripts. Skills can originate from different sources — filesystem directories (SKILL.md files), inline C# code, or reusable class libraries — and the framework must support all three uniformly while allowing extensibility, composition, and filtering. + +## Decision Drivers + +- Skills must be definable from multiple sources: filesystem, inline code, and reusable classes +- Common abstractions are needed so the provider and builder work uniformly regardless of skill origin +- File-based scripts must support user-defined executors, enabling custom runtimes and languages; code/class-based scripts execute in-process as C# delegates +- Skills must be filterable so consumers can include or exclude specific skills based on defined criteria +- Multiple skill sources must be composable into a single provider + +## Considered Options + +- Single monolithic `FileAgentSkillsProvider` (current design) +- Multi-source architecture with abstract base types and builder pattern + +## Architecture + +### Model-Facing Tools + +Skills are presented to the model as up to three tools that progressively disclose skill content. The system prompt lists available skill names and descriptions; the model then calls these tools on demand: + +- **`load_skill(skillName)`** — returns the full skill body (instructions, listed resources, listed scripts) +- **`read_skill_resource(skillName, resourceName)`** — reads a supplementary resource (file-based or code-defined) associated with a skill +- **`run_skill_script(skillName, scriptName, arguments?)`** — executes a script associated with a skill; only registered when at least one skill contains scripts + +Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.ExecuteAsync()` respectively. + +If skills have no scripts defined, the `run_skill_script` tool is **not advertised** to the model and instructions related to script execution are **not included** in the default skills instructions. + +### Abstract Base Types + +The architecture defines four abstract base types that all skill variants implement: + +```csharp +public abstract class AgentSkill +{ + public abstract AgentSkillFrontmatter Frontmatter { get; } + public abstract string Content { get; } + public abstract IReadOnlyList? Resources { get; } + public abstract IReadOnlyList? Scripts { get; } +} + +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillsSource +{ + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} +``` + +Skill metadata is captured via `AgentSkillFrontmatter`: + +```csharp +public sealed class AgentSkillFrontmatter +{ + public AgentSkillFrontmatter(string name, string description) { ... } + + public string Name { get; } + public string Description { get; } + public string? License { get; set; } + public string? Compatibility { get; set; } + public string? AllowedTools { get; set; } + public IDictionary? Metadata { get; set; } +} +``` + +The type hierarchy at a glance: + +``` +AgentSkill (abstract) AgentSkillsSource (abstract) +├── AgentFileSkill ├── AgentFileSkillsSource +├── AgentCodeSkill ├── AgentCodeSkillsSource +└── AgentClassSkill (abstract) ├── AgentClassSkillsSource + └── CompositeAgentSkillsSource (Composition approach only) +AgentSkillResource (abstract) AgentSkillScript (abstract) +├── AgentFileSkillResource ├── AgentFileSkillScript +└── AgentCodeSkillResource └── AgentCodeSkillScript +``` + +### File-Based Skills + +File-based skills are authored as `SKILL.md` files on disk. Resources and scripts are discovered from corresponding subfolders within the skill directory. + +**`AgentFileSkill`** — A filesystem-based skill discovered from a directory containing a `SKILL.md` file. Parsed from YAML frontmatter; content is the raw markdown body. Resources and scripts are discovered from files in corresponding subfolders: + +```csharp +public sealed class AgentFileSkill : AgentSkill +{ + public AgentFileSkill( + AgentSkillFrontmatter frontmatter, string content, string sourcePath, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) { ... } +} +``` + +**`AgentFileSkillResource`** — A file-based skill resource. Reads content from a file on disk relative to the skill directory: + +```csharp +public sealed class AgentFileSkillResource : AgentSkillResource +{ + public AgentFileSkillResource(string name, string path) { ... } + + public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + return File.ReadAllTextAsync(path, cancellationToken); + } +} +``` + +**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptExecutor` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured: + +```csharp +public delegate Task AgentFileSkillScriptExecutor( + AgentSkill skill, AgentFileSkillScript script, + AIFunctionArguments arguments, CancellationToken cancellationToken); + +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptExecutor? _executor; + + internal AgentFileSkillScript(string name, string path, AgentFileSkillScriptExecutor? executor = null) + : base(name) { ... } + + public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + if (_executor == null) + { + throw new NotSupportedException($"File-based script '{Name}' requires an external executor and cannot be executed directly."); + } + + return await _executor(skill, this, arguments, cancellationToken); + } +} +``` + +The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.WithFileScriptExecutor(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. + +**`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks: + +```csharp +public sealed partial class AgentFileSkillsSource : AgentSkillsSource +{ + public AgentFileSkillsSource( + IEnumerable skillPaths, + ILoggerFactory? loggerFactory = null, + AgentFileSkillScriptExecutor? scriptExecutor = null, + IEnumerable? allowedResourceExtensions = null, + IEnumerable? allowedScriptExtensions = null) { ... } +} +``` + +**Example** — A file-based skill on disk and how it is added to a source: + +``` +skills/ +└── unit-converter/ + ├── SKILL.md # frontmatter + instructions + ├── resources/ + │ └── conversion-table.csv # discovered as a resource + └── scripts/ + └── convert.py # discovered as a script +``` + +```csharp +var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptExecutor: SubprocessExecutor.RunAsync); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +### Code-Defined Skills + +Code-defined skills are built programmatically in C#. + +**`AgentCodeSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: + +```csharp +public sealed class AgentCodeSkill : AgentSkill +{ + public AgentCodeSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... } + public AgentCodeSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... } + + public AgentCodeSkill AddResource(object value, string name, string? description = null); + public AgentCodeSkill AddResource(Delegate handler, string name, string? description = null); + public AgentCodeSkill AddScript(Delegate handler, string name, string? description = null); +} +``` + +**`AgentCodeSkillResource`** — A code-defined skill resource. Wraps either a static value or a function: + +```csharp +public sealed class AgentCodeSkillResource : AgentSkillResource +{ + private readonly AIFunction? _function; + private readonly object? _staticValue; + + public AgentCodeSkillResource(object value, string name, string? description = null) + : base(name, description) + { + _staticValue = value; + } + + public AgentCodeSkillResource(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + if (_function is not null) + { + return _function.InvokeAsync(arguments, cancellationToken); + } + + return Task.FromResult(_staticValue); + } +} +``` + +**`AgentCodeSkillScript`** — A code-defined skill script. Wraps a function and provided JSON schema: + +```csharp +public sealed class AgentCodeSkillScript : AgentSkillScript +{ + private readonly AIFunction _function; + + public AgentCodeSkillScript(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public JsonElement? ParametersSchema => _function.JsonSchema; + + public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + return await _function.InvokeAsync(arguments, cancellationToken); + } +} +``` + +**`AgentCodeSkillsSource`** — A skill source that holds code-defined `AgentCodeSkill` instances: + +```csharp +public sealed class AgentCodeSkillsSource : AgentSkillsSource +{ + public AgentCodeSkillsSource( + IEnumerable skills, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +**Example** — Creating a code-defined skill with a resource and script, then adding it to a source: + +```csharp +var skill = new AgentCodeSkill( + name: "unit-converter", + description: "Converts between measurement units.", + instructions: """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """ + ) + .AddResource("kg=2.205lb, m=3.281ft, L=0.264gal", "conversion-table", "Supported unit pairs") + .AddScript(Convert, "convert", "Converts a value between units"); + +var source = new AgentCodeSkillsSource([skill]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); + +static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +``` + +### Class-Based Skills + +Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. + +**`AgentClassSkill`** — An abstract base class for defining skills as reusable C# classes that bundle all skill components (frontmatter, instructions, resources, scripts) together. Designed for packaging skills as distributable libraries: + +```csharp +public abstract class AgentClassSkill : AgentSkill +{ + public abstract string Instructions { get; } + + // Content is auto-synthesized from Frontmatter + Instructions + Resources + Scripts + public override string Content => + SkillContentBuilder.BuildContent(Frontmatter.Name, Frontmatter.Description, + SkillContentBuilder.BuildBody(Instructions, Resources, Scripts)); +} +``` + +**`AgentClassSkillsSource`** — A skill source that holds class-based `AgentClassSkill` instances: + +```csharp +public sealed class AgentClassSkillsSource : AgentSkillsSource +{ + public AgentClassSkillsSource( + IEnumerable skills, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +**Example** — Defining a class-based skill and adding it to a source: + +```csharp +public class UnitConverterSkill : AgentClassSkill +{ + public override AgentSkillFrontmatter Frontmatter { get; } = + new("unit-converter", "Converts between measurement units."); + + public override string Instructions => """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """; + + public override IReadOnlyList? Resources { get; } = + [ + new AgentCodeSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"), + ]; + + public override IReadOnlyList? Scripts { get; } = + [ + new AgentCodeSkillScript(Convert, "convert"), + ]; + + private static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +} + +var source = new AgentClassSkillsSource([new UnitConverterSkill()]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Filtering, Caching, and Deduplication + +The following subsections present alternative approaches for handling filtering, caching, and deduplication of skills across multiple sources. + +### Via Composition + +In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via `CompositeAgentSkillsSource`, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — wrappers around any `AgentSkillsSource` that intercept `GetSkillsAsync()`. + +**`CompositeAgentSkillsSource`** — Aggregates multiple child sources into a single flat list. The composite source can optionally load skills from all sources in parallel: + +```csharp +public sealed class CompositeAgentSkillsSource : AgentSkillsSource +{ + public override async Task> GetSkillsAsync(...) + { + var allSkills = new List(); + foreach (var source in _sources) + { + var skills = await source.GetSkillsAsync(cancellationToken); + allSkills.AddRange(skills); + } + return allSkills; + } +} +``` + +**`FilteringSkillsSource`** — A decorator that applies filter/transform logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters: + +```csharp +public sealed class FilteringSkillsSource : AgentSkillsSource +{ + private readonly AgentSkillsSource _inner; + private readonly Func _filter; + + public FilteringSkillsSource(AgentSkillsSource inner, Func filter) + { + _inner = inner; + _filter = filter; + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var skills = await _inner.GetSkillsAsync(cancellationToken); + return skills.Where(_filter).ToList(); + } +} +``` + +**`CachingSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached: + +```csharp +public sealed class CachingSkillsSource : AgentSkillsSource +{ + private readonly AgentSkillsSource _inner; + private IReadOnlyList? _cached; + + public CachingSkillsSource(AgentSkillsSource inner) + { + _inner = inner; + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return _cached ??= await _inner.GetSkillsAsync(cancellationToken); + } +} +``` + +**Deduplication** can similarly be implemented as a decorator that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates. + +**Example** — Combining file-based and code-defined sources with filtering and caching: + +```csharp +var fileSource = new CachingSkillsSource(new AgentFileSkillsSource(["./skills"])); +var codeSource = new AgentCodeSkillsSource([myCodeSkill]); + +var compositeSource = new FilteringSkillsSource( + new CompositeAgentSkillsSource([fileSource, codeSource]), + filter: s => s.Frontmatter.Name != "internal"); + +var provider = new AgentSkillsProvider(compositeSource); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- Clean single-responsibility: the provider serves skills, sources provide them. +- Caching, filtering, and deduplication are composable as source decorators — each concern is a separate, testable wrapper. + +**Cons:** +- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via `CompositeAgentSkillsSource`. +- Increased public API surface: requires additional public classes (`CompositeAgentSkillsSource`, caching decorators, filtering decorators) that consumers need to learn and use. + +### Via AgentSkillsProvider + +In this approach, the `AgentSkillsProvider` accepts **`IEnumerable`** and handles aggregation, filtering, caching, and deduplication internally. There is no need for `CompositeAgentSkillsSource` or decorator classes — these concerns are built into the provider. + +The provider aggregates skills from all registered sources, deduplicates by name (case-insensitive, first-one-wins), caches the result after the first load, and optionally applies filtering via a predicate on `AgentSkillsProviderOptions`. Duplicate skill names are logged as warnings. + +**Example** — Registering multiple sources directly with the provider: + +```csharp +var fileSource = new AgentFileSkillsSource(["./skills"]); +var codeSource = new AgentCodeSkillsSource([myCodeSkill]); + +var provider = new AgentSkillsProvider( + sources: [fileSource, codeSource], + options: new AgentSkillsProviderOptions + { + Filter = s => s.Frontmatter.Name != "internal", + }); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- DI-friendly: register multiple `AgentSkillsSource` implementations in the container, and they are all auto-injected into `AgentSkillsProvider` via `IEnumerable`. +- Smaller public API surface: no need for `CompositeAgentSkillsSource`, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider. + +**Cons:** +- The provider takes on multiple responsibilities — aggregation, caching, deduplication, and filtering. +- Less granular caching control: caching is all-or-nothing across sources rather than per-source as with decorators. +- Less extensible: new behaviors (e.g., ordering, TTL expiration) require modifying the provider rather than adding a decorator. + +### Builder Pattern + +**`AgentSkillsProviderBuilder`** provides a fluent API for composing skills from multiple sources. The builder centralizes configuration — script executors, approval callbacks, prompt templates, and filtering — so consumers don't need to know the underlying source types. + +The builder internally decides how to wire up the object graph: it creates the appropriate source instances, applies caching and filtering, and returns a fully configured `AgentSkillsProvider`. This keeps the setup code concise while still allowing fine-grained control when needed. + +**Example** — Using the builder to combine multiple source types with configuration: + +```csharp +var provider = new AgentSkillsProviderBuilder() + .AddFileSkills("./skills") // file-based source + .AddCodeSkills(codeSkill) // code-defined source + .AddClassSkills(new ClassSkill()) // class-based source + .WithFileScriptExecutor(SubprocessExecutor.RunAsync) // script runner + .WithScriptApproval() // optional human-in-the-loop + .WithPromptTemplate(customTemplate) // optional prompt customization + .Build(); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Decision Outcome From e9a834fc2192f16d9ea9358e9f90beac0e8834b9 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:55:22 +0000 Subject: [PATCH 02/10] add deciders --- dotnet/0021-agent-skills-design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/0021-agent-skills-design.md b/dotnet/0021-agent-skills-design.md index b267d609f3..a0b808b885 100644 --- a/dotnet/0021-agent-skills-design.md +++ b/dotnet/0021-agent-skills-design.md @@ -2,6 +2,7 @@ status: proposed date: 2026-03-19 contact: sergeymenshykh +deciders: rbarreto, westey-m --- # Agent Skills: Multi-Source Architecture From abe14956e5d7e74fc5ea76565bd9ad2f8ff86cdf Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 20 Mar 2026 10:23:53 +0000 Subject: [PATCH 03/10] move the adr to the decisions folder --- {dotnet => docs/decisions}/0021-agent-skills-design.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {dotnet => docs/decisions}/0021-agent-skills-design.md (100%) diff --git a/dotnet/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md similarity index 100% rename from dotnet/0021-agent-skills-design.md rename to docs/decisions/0021-agent-skills-design.md From 0aa9e810eb515c61804f60605cb5dce2ab69e8f3 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 20 Mar 2026 10:36:15 +0000 Subject: [PATCH 04/10] remove unnecessary section --- docs/decisions/0021-agent-skills-design.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index a0b808b885..e69e93a4ac 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -19,11 +19,6 @@ The Agent Framework needs a skills system that lets agents discover and use doma - Skills must be filterable so consumers can include or exclude specific skills based on defined criteria - Multiple skill sources must be composable into a single provider -## Considered Options - -- Single monolithic `FileAgentSkillsProvider` (current design) -- Multi-source architecture with abstract base types and builder pattern - ## Architecture ### Model-Facing Tools From 38c3c86a6efdf42f7924456b3587af5c1933c845 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 20 Mar 2026 10:45:27 +0000 Subject: [PATCH 05/10] describe adding a custom skill source --- docs/decisions/0021-agent-skills-design.md | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index e69e93a4ac..a6476483e0 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -522,4 +522,69 @@ AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions }); ``` +## Adding a Custom Skill Source + +The `AgentSkillsSource` abstraction is the extension point for loading skills from any origin — a database, a REST API, a package registry, or any other backend. To add a custom source, subclass `AgentSkillsSource` and implement `GetSkillsAsync`: + +```csharp +public class CosmosDbSkillsSource : AgentSkillsSource +{ + private readonly CosmosClient _client; + + public CosmosDbSkillsSource(CosmosClient client) + { + _client = client; + } + + public override async Task> GetSkillsAsync( + CancellationToken cancellationToken = default) + { + var container = _client.GetContainer("skills-db", "skills"); + var query = container.GetItemQueryIterator("SELECT * FROM c"); + + var skills = new List(); + + while (query.HasMoreResults) + { + var response = await query.ReadNextAsync(cancellationToken); + + foreach (var doc in response) + { + var frontmatter = new AgentSkillFrontmatter(doc.Name, doc.Description); + var resources = doc.Resources?.Select( + r => new AgentCodeSkillResource(r.Content, r.Name, r.Description)).ToList(); + + skills.Add(new AgentCodeSkill(frontmatter, doc.Instructions) + .AddResources(resources)); + } + } + + return skills; + } +} +``` + +Once the custom source is defined, it integrates with the rest of the skills system like any built-in source. It can be used directly with `AgentSkillsProvider`, composed with other sources, or wrapped with decorators for caching and filtering: + +```csharp +// Direct usage +var cosmosSource = new CosmosDbSkillsSource(cosmosClient); +var provider = new AgentSkillsProvider(cosmosSource); + +// Composed with other sources and wrapped with caching +var compositeSource = new CompositeAgentSkillsSource([ + new CachingSkillsSource(new CosmosDbSkillsSource(cosmosClient)), + new AgentFileSkillsSource(["./skills"]), +]); +var provider = new AgentSkillsProvider(compositeSource); + +// Or via the builder (requires registering the source type with the builder) +var provider = new AgentSkillsProviderBuilder() + .AddSource(new CosmosDbSkillsSource(cosmosClient)) + .AddFileSkills("./skills") + .Build(); +``` + +The custom source returns standard `AgentSkill` instances, so skills from any backend automatically participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), filtering, deduplication, and caching — no additional integration work is required. + ## Decision Outcome From 100436d6cfa6d7f97ede447c0495eba8031696c5 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 20 Mar 2026 11:00:47 +0000 Subject: [PATCH 06/10] update --- docs/decisions/0021-agent-skills-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index a6476483e0..2d5b29ede6 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -13,11 +13,12 @@ The Agent Framework needs a skills system that lets agents discover and use doma ## Decision Drivers -- Skills must be definable from multiple sources: filesystem, inline code, and reusable classes +- Skills must be definable from multiple sources: filesystem, inline code, reusable classes, etc - Common abstractions are needed so the provider and builder work uniformly regardless of skill origin - File-based scripts must support user-defined executors, enabling custom runtimes and languages; code/class-based scripts execute in-process as C# delegates - Skills must be filterable so consumers can include or exclude specific skills based on defined criteria - Multiple skill sources must be composable into a single provider +- It must be possible to add custom skill sources (e.g., databases, REST APIs, package registries) by implementing a common abstraction ## Architecture From 3a77c81b318ca7724ac5a67831ee2fa958250724 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 23 Mar 2026 18:30:02 +0000 Subject: [PATCH 07/10] address comments --- docs/decisions/0021-agent-skills-design.md | 491 +++++++++++++-------- 1 file changed, 317 insertions(+), 174 deletions(-) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index 2d5b29ede6..1a9288017e 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -1,6 +1,5 @@ ---- status: proposed -date: 2026-03-19 +date: 2026-03-23 contact: sergeymenshykh deciders: rbarreto, westey-m --- @@ -63,7 +62,7 @@ public abstract class AgentSkillScript public abstract class AgentSkillsSource { - public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); } ``` @@ -79,23 +78,36 @@ public sealed class AgentSkillFrontmatter public string? License { get; set; } public string? Compatibility { get; set; } public string? AllowedTools { get; set; } - public IDictionary? Metadata { get; set; } + public AdditionalPropertiesDictionary? Metadata { get; set; } } ``` The type hierarchy at a glance: ``` -AgentSkill (abstract) AgentSkillsSource (abstract) -├── AgentFileSkill ├── AgentFileSkillsSource -├── AgentCodeSkill ├── AgentCodeSkillsSource -└── AgentClassSkill (abstract) ├── AgentClassSkillsSource - └── CompositeAgentSkillsSource (Composition approach only) -AgentSkillResource (abstract) AgentSkillScript (abstract) -├── AgentFileSkillResource ├── AgentFileSkillScript -└── AgentCodeSkillResource └── AgentCodeSkillScript +AgentSkill (abstract) AgentSkillsSource (abstract) +├── AgentFileSkill ├── AgentFileSkillsSource (public) +└── [Programmatic] ├── AgentInMemorySkillsSource (public) + ├── AgentInlineSkill ├── AggregateAgentSkillsSource (public) + └── AgentClassSkill (abstract) └── DelegatingAgentSkillsSource (abstract, public) + ├── FilteringAgentSkillsSource (public) +AgentSkillResource (abstract) ├── CachingAgentSkillsSource (public) +├── AgentFileSkillResource └── DeduplicatingAgentSkillsSource (public) +└── AgentInlineSkillResource + AgentSkillScript (abstract) + ├── AgentFileSkillScript + └── AgentInlineSkillScript ``` +There are two top-level categories of skills: + +1. **File-Based Skills** — discovered from `SKILL.md` files on the filesystem. Resources and scripts are files in subdirectories. +2. **Programmatic Skills** — defined in C# code. These are further divided into: + - **Inline Skills** — built at runtime via the `AgentInlineSkill` class and its fluent API. Ideal for quick, agent-specific skill definitions. + - **Class-Based Skills** — defined as reusable C# classes that subclass `AgentClassSkill`. Ideal for packaging skills as shared libraries or NuGet packages. + +Both programmatic skill types use `AgentInlineSkillResource` and `AgentInlineSkillScript` for their resources and scripts. They are typically served by `AgentInMemorySkillsSource`, which accepts any `AgentSkill` and is not limited to programmatic skills. + ### File-Based Skills File-based skills are authored as `SKILL.md` files on disk. Resources and scripts are discovered from corresponding subfolders within the skill directory. @@ -105,8 +117,8 @@ File-based skills are authored as `SKILL.md` files on disk. Resources and script ```csharp public sealed class AgentFileSkill : AgentSkill { - public AgentFileSkill( - AgentSkillFrontmatter frontmatter, string content, string sourcePath, + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, string content, string path, IReadOnlyList? resources = null, IReadOnlyList? scripts = null) { ... } } @@ -115,13 +127,15 @@ public sealed class AgentFileSkill : AgentSkill **`AgentFileSkillResource`** — A file-based skill resource. Reads content from a file on disk relative to the skill directory: ```csharp -public sealed class AgentFileSkillResource : AgentSkillResource +internal sealed class AgentFileSkillResource : AgentSkillResource { - public AgentFileSkillResource(string name, string path) { ... } + public AgentFileSkillResource(string name, string fullPath) { ... } + + public string FullPath { get; } public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) { - return File.ReadAllTextAsync(path, cancellationToken); + return File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); } } ``` @@ -130,29 +144,25 @@ public sealed class AgentFileSkillResource : AgentSkillResource ```csharp public delegate Task AgentFileSkillScriptExecutor( - AgentSkill skill, AgentFileSkillScript script, + AgentFileSkill skill, AgentFileSkillScript script, AIFunctionArguments arguments, CancellationToken cancellationToken); public sealed class AgentFileSkillScript : AgentSkillScript { - private readonly AgentFileSkillScriptExecutor? _executor; + private readonly AgentFileSkillScriptExecutor _executor; - internal AgentFileSkillScript(string name, string path, AgentFileSkillScriptExecutor? executor = null) + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptExecutor executor) : base(name) { ... } public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, ...) { - if (_executor == null) - { - throw new NotSupportedException($"File-based script '{Name}' requires an external executor and cannot be executed directly."); - } - return await _executor(skill, this, arguments, cancellationToken); + return await _executor(fileSkill, this, arguments, cancellationToken); } } ``` -The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.WithFileScriptExecutor(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. +The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptExecutor(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. **`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks: @@ -161,10 +171,19 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource { public AgentFileSkillsSource( IEnumerable skillPaths, - ILoggerFactory? loggerFactory = null, - AgentFileSkillScriptExecutor? scriptExecutor = null, - IEnumerable? allowedResourceExtensions = null, - IEnumerable? allowedScriptExtensions = null) { ... } + AgentFileSkillScriptExecutor scriptExecutor, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +**`AgentFileSkillsSourceOptions`** — Configuration options for `AgentFileSkillsSource`. Allows customizing the allowed file extensions for resources and scripts without adding constructor parameters: + +```csharp +public sealed class AgentFileSkillsSourceOptions +{ + public IEnumerable? AllowedResourceExtensions { get; set; } + public IEnumerable? AllowedScriptExtensions { get; set; } } ``` @@ -191,64 +210,83 @@ AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions }); ``` -### Code-Defined Skills +### Programmatic Skills -Code-defined skills are built programmatically in C#. +Programmatic skills are defined in C# code rather than discovered from the filesystem. There are two kinds: **inline** and **class-based**. Both use `AgentInlineSkillResource` and `AgentInlineSkillScript` for resources and scripts, and are held by a single `AgentInMemorySkillsSource`. -**`AgentCodeSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: +**`AgentInMemorySkillsSource`** — A general-purpose skill source that holds any `AgentSkill` instances in memory. Although commonly used for programmatic skills (`AgentInlineSkill` and `AgentClassSkill`), it accepts any `AgentSkill` subclass and is not restricted to code-defined skills: ```csharp -public sealed class AgentCodeSkill : AgentSkill +public sealed class AgentInMemorySkillsSource : AgentSkillsSource { - public AgentCodeSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... } - public AgentCodeSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... } - - public AgentCodeSkill AddResource(object value, string name, string? description = null); - public AgentCodeSkill AddResource(Delegate handler, string name, string? description = null); - public AgentCodeSkill AddScript(Delegate handler, string name, string? description = null); + public AgentInMemorySkillsSource( + IEnumerable skills, + ILoggerFactory? loggerFactory = null) { ... } } ``` -**`AgentCodeSkillResource`** — A code-defined skill resource. Wraps either a static value or a function: +#### Inline Skills + +Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill. + +**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: ```csharp -public sealed class AgentCodeSkillResource : AgentSkillResource +public sealed class AgentInlineSkill : AgentSkill { - private readonly AIFunction? _function; - private readonly object? _staticValue; + public AgentInlineSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... } + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... } + + public AgentInlineSkill AddResource(object value, string name, string? description = null); + public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null); + public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null); +} +``` + +**`AgentInlineSkillResource`** — A skill resource that wraps a static value: - public AgentCodeSkillResource(object value, string name, string? description = null) +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(object value, string name, string? description = null) : base(name, description) { - _staticValue = value; + _value = value; } - public AgentCodeSkillResource(Delegate handler, string name, string? description = null) + public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + return Task.FromResult(_value); + } +} +``` + +**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(Delegate handler, string name, string? description = null) : base(name, description) { _function = AIFunctionFactory.Create(handler, name: name); } - public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) + public override async Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) { - if (_function is not null) - { - return _function.InvokeAsync(arguments, cancellationToken); - } - - return Task.FromResult(_staticValue); + return await _function.InvokeAsync(arguments, cancellationToken); } } ``` -**`AgentCodeSkillScript`** — A code-defined skill script. Wraps a function and provided JSON schema: +**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`: ```csharp -public sealed class AgentCodeSkillScript : AgentSkillScript +public sealed class AgentInlineSkillScript : AgentSkillScript { private readonly AIFunction _function; - public AgentCodeSkillScript(Delegate handler, string name, string? description = null) + public AgentInlineSkillScript(Delegate handler, string name, string? description = null) : base(name, description) { _function = AIFunctionFactory.Create(handler, name: name); @@ -263,21 +301,10 @@ public sealed class AgentCodeSkillScript : AgentSkillScript } ``` -**`AgentCodeSkillsSource`** — A skill source that holds code-defined `AgentCodeSkill` instances: +**Example** — Creating an inline skill with a resource and script, then adding it to a source: ```csharp -public sealed class AgentCodeSkillsSource : AgentSkillsSource -{ - public AgentCodeSkillsSource( - IEnumerable skills, - ILoggerFactory? loggerFactory = null) { ... } -} -``` - -**Example** — Creating a code-defined skill with a resource and script, then adding it to a source: - -```csharp -var skill = new AgentCodeSkill( +var skill = new AgentInlineSkill( name: "unit-converter", description: "Converts between measurement units.", instructions: """ @@ -289,7 +316,7 @@ var skill = new AgentCodeSkill( .AddResource("kg=2.205lb, m=3.281ft, L=0.264gal", "conversion-table", "Supported unit pairs") .AddScript(Convert, "convert", "Converts a value between units"); -var source = new AgentCodeSkillsSource([skill]); +var source = new AgentInMemorySkillsSource([skill]); var provider = new AgentSkillsProvider(source); @@ -302,9 +329,9 @@ static string Convert(double value, double factor) => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); ``` -### Class-Based Skills +#### Class-Based Skills -Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. +Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. Unlike inline skills, class-based skills are self-contained, can live in shared libraries or NuGet packages, and are well-suited for dependency injection. **`AgentClassSkill`** — An abstract base class for defining skills as reusable C# classes that bundle all skill components (frontmatter, instructions, resources, scripts) together. Designed for packaging skills as distributable libraries: @@ -320,17 +347,6 @@ public abstract class AgentClassSkill : AgentSkill } ``` -**`AgentClassSkillsSource`** — A skill source that holds class-based `AgentClassSkill` instances: - -```csharp -public sealed class AgentClassSkillsSource : AgentSkillsSource -{ - public AgentClassSkillsSource( - IEnumerable skills, - ILoggerFactory? loggerFactory = null) { ... } -} -``` - **Example** — Defining a class-based skill and adding it to a source: ```csharp @@ -347,19 +363,19 @@ public class UnitConverterSkill : AgentClassSkill public override IReadOnlyList? Resources { get; } = [ - new AgentCodeSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"), + new AgentInlineSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"), ]; public override IReadOnlyList? Scripts { get; } = [ - new AgentCodeSkillScript(Convert, "convert"), + new AgentInlineSkillScript(Convert, "convert"), ]; private static string Convert(double value, double factor) => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); } -var source = new AgentClassSkillsSource([new UnitConverterSkill()]); +var source = new AgentInMemorySkillsSource([new UnitConverterSkill()]); var provider = new AgentSkillsProvider(source); @@ -375,78 +391,58 @@ The following subsections present alternative approaches for handling filtering, ### Via Composition -In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via `CompositeAgentSkillsSource`, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — wrappers around any `AgentSkillsSource` that intercept `GetSkillsAsync()`. - -**`CompositeAgentSkillsSource`** — Aggregates multiple child sources into a single flat list. The composite source can optionally load skills from all sources in parallel: - -```csharp -public sealed class CompositeAgentSkillsSource : AgentSkillsSource -{ - public override async Task> GetSkillsAsync(...) - { - var allSkills = new List(); - foreach (var source in _sources) - { - var skills = await source.GetSkillsAsync(cancellationToken); - allSkills.AddRange(skills); - } - return allSkills; - } -} -``` +In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via an aggregate source, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — subclasses of `DelegatingAgentSkillsSource` that intercept `GetSkillsAsync()`. -**`FilteringSkillsSource`** — A decorator that applies filter/transform logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters: +**`FilteringAgentSkillsSource`** — A decorator that applies filter logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters: ```csharp -public sealed class FilteringSkillsSource : AgentSkillsSource +public sealed class FilteringAgentSkillsSource : DelegatingAgentSkillsSource { - private readonly AgentSkillsSource _inner; - private readonly Func _filter; + private readonly Func _predicate; - public FilteringSkillsSource(AgentSkillsSource inner, Func filter) + public FilteringAgentSkillsSource(AgentSkillsSource innerSource, Func predicate) + : base(innerSource) { - _inner = inner; - _filter = filter; + _predicate = predicate; } - public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) { - var skills = await _inner.GetSkillsAsync(cancellationToken); - return skills.Where(_filter).ToList(); + var skills = await this.InnerSource.GetSkillsAsync(cancellationToken); + return skills.Where(_predicate).ToList(); } } ``` -**`CachingSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached: +**`CachingAgentSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached: ```csharp -public sealed class CachingSkillsSource : AgentSkillsSource +public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource { - private readonly AgentSkillsSource _inner; - private IReadOnlyList? _cached; + private IList? _cached; - public CachingSkillsSource(AgentSkillsSource inner) + public CachingAgentSkillsSource(AgentSkillsSource innerSource) + : base(innerSource) { - _inner = inner; } - public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) { - return _cached ??= await _inner.GetSkillsAsync(cancellationToken); + return _cached ??= await this.InnerSource.GetSkillsAsync(cancellationToken); } } ``` -**Deduplication** can similarly be implemented as a decorator that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates. +**Deduplication** is similarly implemented as a decorator (`DeduplicatingAgentSkillsSource`) that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates. **Example** — Combining file-based and code-defined sources with filtering and caching: ```csharp -var fileSource = new CachingSkillsSource(new AgentFileSkillsSource(["./skills"])); -var codeSource = new AgentCodeSkillsSource([myCodeSkill]); +var fileSource = new CachingAgentSkillsSource(new AgentFileSkillsSource(["./skills"])); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); -var compositeSource = new FilteringSkillsSource( - new CompositeAgentSkillsSource([fileSource, codeSource]), +var compositeSource = new FilteringAgentSkillsSource( + new AggregateAgentSkillsSource([fileSource, codeSource]), filter: s => s.Frontmatter.Name != "internal"); var provider = new AgentSkillsProvider(compositeSource); @@ -462,20 +458,21 @@ AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions - Caching, filtering, and deduplication are composable as source decorators — each concern is a separate, testable wrapper. **Cons:** -- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via `CompositeAgentSkillsSource`. -- Increased public API surface: requires additional public classes (`CompositeAgentSkillsSource`, caching decorators, filtering decorators) that consumers need to learn and use. +- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via an aggregate source. +- Increased public API surface: requires additional public classes (aggregate source, caching decorators, filtering decorators) that consumers need to learn and use. ### Via AgentSkillsProvider -In this approach, the `AgentSkillsProvider` accepts **`IEnumerable`** and handles aggregation, filtering, caching, and deduplication internally. There is no need for `CompositeAgentSkillsSource` or decorator classes — these concerns are built into the provider. +In this approach, the `AgentSkillsProvider` accepts **`IEnumerable`** and handles aggregation, filtering, caching, and deduplication internally. The provider aggregates skills from all registered sources, deduplicates by name (case-insensitive, first-one-wins), caches the result after the first load, and optionally applies filtering via a predicate on `AgentSkillsProviderOptions`. Duplicate skill names are logged as warnings. **Example** — Registering multiple sources directly with the provider: ```csharp +// Conceptual example — in practice, use AgentSkillsProviderBuilder var fileSource = new AgentFileSkillsSource(["./skills"]); -var codeSource = new AgentCodeSkillsSource([myCodeSkill]); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); var provider = new AgentSkillsProvider( sources: [fileSource, codeSource], @@ -492,7 +489,7 @@ AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions **Pros:** - DI-friendly: register multiple `AgentSkillsSource` implementations in the container, and they are all auto-injected into `AgentSkillsProvider` via `IEnumerable`. -- Smaller public API surface: no need for `CompositeAgentSkillsSource`, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider. +- Smaller public API surface: no need for aggregate source, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider. **Cons:** - The provider takes on multiple responsibilities — aggregation, caching, deduplication, and filtering. @@ -509,12 +506,13 @@ The builder internally decides how to wire up the object graph: it creates the a ```csharp var provider = new AgentSkillsProviderBuilder() - .AddFileSkills("./skills") // file-based source - .AddCodeSkills(codeSkill) // code-defined source - .AddClassSkills(new ClassSkill()) // class-based source - .WithFileScriptExecutor(SubprocessExecutor.RunAsync) // script runner - .WithScriptApproval() // optional human-in-the-loop - .WithPromptTemplate(customTemplate) // optional prompt customization + .UseFileSkill("./skills") // file-based source + .UseInlineSkills(codeSkill) // code-defined source + .UseClassSkills(new ClassSkill()) // class-based source + .UseFileScriptExecutor(SubprocessExecutor.RunAsync) // script runner + .UseScriptApproval() // optional human-in-the-loop + .UsePromptTemplate(customTemplate) // optional prompt customization + .UseFilter(s => s.Frontmatter.Name != "internal") // optional skill filtering .Build(); AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions @@ -523,41 +521,187 @@ AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions }); ``` -## Adding a Custom Skill Source +## Adding a Custom Skill Type + +The skills framework is designed for extensibility. While file-based and inline skills cover common +scenarios, you can introduce entirely new skill types by subclassing the four base classes: + +| Base class | Purpose | +|-----------------------|-----------------------------------------------------| +| `AgentSkillsSource` | Discovers and loads skills from a particular origin | +| `AgentSkill` | Holds metadata, content, resources, and scripts | +| `AgentSkillResource` | Provides supplementary content to a skill | +| `AgentSkillScript` | Represents an executable action within a skill | + +The example below implements a **cloud-based skill type** where skills, resources, and scripts are +all stored in and executed through a remote cloud service (e.g., Azure Blob Storage + Azure Functions). + +### Step 1 — Define a custom resource + +A `CloudSkillResource` reads resource content from a cloud storage endpoint instead of the local +filesystem: + +```csharp +/// +/// A skill resource backed by a cloud storage endpoint. +/// +public sealed class CloudSkillResource : AgentSkillResource +{ + private readonly HttpClient _httpClient; + + public CloudSkillResource(string name, Uri blobUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + BlobUri = blobUri ?? throw new ArgumentNullException(nameof(blobUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud blob that holds this resource's content. + /// + public Uri BlobUri { get; } + + /// + public override async Task ReadAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken = default) + { + return await _httpClient.GetStringAsync(BlobUri, cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 2 — Define a custom script + +A `CloudSkillScript` executes a script by calling a cloud function endpoint, passing arguments as +the request body: + +```csharp +/// +/// A skill script executed via a cloud function endpoint. +/// +public sealed class CloudSkillScript : AgentSkillScript +{ + private readonly HttpClient _httpClient; + + public CloudSkillScript(string name, Uri functionUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + FunctionUri = functionUri ?? throw new ArgumentNullException(nameof(functionUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud function that runs this script. + /// + public Uri FunctionUri { get; } + + /// + public override async Task ExecuteAsync( + AgentSkill skill, + AIFunctionArguments arguments, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(arguments); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(FunctionUri, content, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 3 — Define a custom skill -The `AgentSkillsSource` abstraction is the extension point for loading skills from any origin — a database, a REST API, a package registry, or any other backend. To add a custom source, subclass `AgentSkillsSource` and implement `GetSkillsAsync`: +A `CloudSkill` bundles cloud-specific metadata (e.g., the base endpoint) with the standard skill +shape: ```csharp -public class CosmosDbSkillsSource : AgentSkillsSource +/// +/// An whose content, resources, and scripts are stored in a cloud service. +/// +public sealed class CloudSkill : AgentSkill { - private readonly CosmosClient _client; + public CloudSkill( + AgentSkillFrontmatter frontmatter, + string content, + Uri endpoint, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + Frontmatter = frontmatter ?? throw new ArgumentNullException(nameof(frontmatter)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Resources = resources; + Scripts = scripts; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } - public CosmosDbSkillsSource(CosmosClient client) + /// + public override string Content { get; } + + /// + /// Gets the base cloud endpoint for this skill. + /// + public Uri Endpoint { get; } + + /// + public override IReadOnlyList? Resources { get; } + + /// + public override IReadOnlyList? Scripts { get; } +} +``` + +### Step 4 — Define a custom source + +A `CloudSkillsSource` discovers skills from a cloud catalog API and constructs `CloudSkill` +instances with their associated resources and scripts: + +```csharp +/// +/// A skill source that discovers and loads skills from a cloud catalog API. +/// +public sealed class CloudSkillsSource : AgentSkillsSource +{ + private readonly Uri _catalogUri; + private readonly HttpClient _httpClient; + + public CloudSkillsSource(Uri catalogUri, HttpClient httpClient) { - _client = client; + _catalogUri = catalogUri ?? throw new ArgumentNullException(nameof(catalogUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); } - public override async Task> GetSkillsAsync( + /// + public override async Task> GetSkillsAsync( CancellationToken cancellationToken = default) { - var container = _client.GetContainer("skills-db", "skills"); - var query = container.GetItemQueryIterator("SELECT * FROM c"); + // Fetch the skill catalog from the cloud service. + var json = await _httpClient.GetStringAsync(_catalogUri, cancellationToken) + .ConfigureAwait(false); + var catalog = JsonSerializer.Deserialize(json)!; var skills = new List(); - while (query.HasMoreResults) + foreach (var entry in catalog.Skills) { - var response = await query.ReadNextAsync(cancellationToken); + var frontmatter = new AgentSkillFrontmatter(entry.Name, entry.Description); - foreach (var doc in response) - { - var frontmatter = new AgentSkillFrontmatter(doc.Name, doc.Description); - var resources = doc.Resources?.Select( - r => new AgentCodeSkillResource(r.Content, r.Name, r.Description)).ToList(); + // Build cloud-backed resources. + var resources = entry.Resources + .Select(r => new CloudSkillResource(r.Name, r.BlobUri, _httpClient, r.Description)) + .ToList(); - skills.Add(new AgentCodeSkill(frontmatter, doc.Instructions) - .AddResources(resources)); - } + // Build cloud-backed scripts. + var scripts = entry.Scripts + .Select(s => new CloudSkillScript(s.Name, s.FunctionUri, _httpClient, s.Description)) + .ToList(); + + skills.Add(new CloudSkill(frontmatter, entry.Content, entry.Endpoint, resources, scripts)); } return skills; @@ -565,27 +709,26 @@ public class CosmosDbSkillsSource : AgentSkillsSource } ``` -Once the custom source is defined, it integrates with the rest of the skills system like any built-in source. It can be used directly with `AgentSkillsProvider`, composed with other sources, or wrapped with decorators for caching and filtering: +### Step 5 — Register with the builder + +Use `UseSource` to wire the custom source into the provider: ```csharp -// Direct usage -var cosmosSource = new CosmosDbSkillsSource(cosmosClient); -var provider = new AgentSkillsProvider(cosmosSource); - -// Composed with other sources and wrapped with caching -var compositeSource = new CompositeAgentSkillsSource([ - new CachingSkillsSource(new CosmosDbSkillsSource(cosmosClient)), - new AgentFileSkillsSource(["./skills"]), -]); -var provider = new AgentSkillsProvider(compositeSource); +var httpClient = new HttpClient(); -// Or via the builder (requires registering the source type with the builder) var provider = new AgentSkillsProviderBuilder() - .AddSource(new CosmosDbSkillsSource(cosmosClient)) - .AddFileSkills("./skills") + .UseSource(new CloudSkillsSource( + new Uri("https://my-service.example.com/skills/catalog"), + httpClient)) + // Mix with other source types if needed: + .UseFileSkill("/local/skills", scriptExecutor) + .UseInlineSkills(someInlineSkill) .Build(); ``` -The custom source returns standard `AgentSkill` instances, so skills from any backend automatically participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), filtering, deduplication, and caching — no additional integration work is required. +The `AgentSkillsProvider` handles all skill types uniformly — any combination of file-based, inline, +class-based, and custom skills can coexist in the same provider. Custom skills automatically +participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), +filtering, deduplication, and caching — no additional integration work is required. ## Decision Outcome From 57edebb42dc6b044209c3711d4c3119da0576b7e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 23 Mar 2026 20:55:01 +0000 Subject: [PATCH 08/10] add constructor overloads to inline skill resource and script --- docs/decisions/0021-agent-skills-design.md | 45 ++++++++-------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index 1a9288017e..d11fea2778 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -229,7 +229,7 @@ public sealed class AgentInMemorySkillsSource : AgentSkillsSource Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill. -**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: +**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values, delegates, or pre-built `AIFunction` instances; scripts can be delegates or `AIFunction` instances. Constructed with name, description, and instructions, then extended with resources and scripts: ```csharp public sealed class AgentInlineSkill : AgentSkill @@ -239,47 +239,36 @@ public sealed class AgentInlineSkill : AgentSkill public AgentInlineSkill AddResource(object value, string name, string? description = null); public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null); + public AgentInlineSkill AddResource(AIFunction function); public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null); + public AgentInlineSkill AddScript(AIFunction function); } ``` -**`AgentInlineSkillResource`** — A skill resource that wraps a static value: +**`AgentInlineSkillResource`** — A skill resource backed by a static value, a delegate, or a pre-built `AIFunction`. Static resources return a fixed value; delegate-based resources use `AIFunctionFactory` for automatic parameter marshaling; `AIFunction`-based resources use the function's name and description directly: ```csharp public sealed class AgentInlineSkillResource : AgentSkillResource { public AgentInlineSkillResource(object value, string name, string? description = null) - : base(name, description) - { - _value = value; - } + : base(name, description) { _value = value; } - public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) - { - return Task.FromResult(_value); - } -} -``` - -**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value: - -```csharp -public sealed class AgentInlineSkillResource : AgentSkillResource -{ public AgentInlineSkillResource(Delegate handler, string name, string? description = null) - : base(name, description) - { - _function = AIFunctionFactory.Create(handler, name: name); - } + : base(name, description) { _function = AIFunctionFactory.Create(handler, name: name); } + + public AgentInlineSkillResource(AIFunction function) + : base(function.Name, function.Description) { _function = function; } public override async Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) { - return await _function.InvokeAsync(arguments, cancellationToken); + if (_function is not null) + return await _function.InvokeAsync(arguments, cancellationToken); + return _value; } } ``` -**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`: +**`AgentInlineSkillScript`** — A skill script backed by a delegate or a pre-built `AIFunction`. Delegate-based scripts use `AIFunctionFactory` for automatic parameter marshaling; `AIFunction`-based scripts use the function's name and description directly: ```csharp public sealed class AgentInlineSkillScript : AgentSkillScript @@ -287,10 +276,10 @@ public sealed class AgentInlineSkillScript : AgentSkillScript private readonly AIFunction _function; public AgentInlineSkillScript(Delegate handler, string name, string? description = null) - : base(name, description) - { - _function = AIFunctionFactory.Create(handler, name: name); - } + : base(name, description) { _function = AIFunctionFactory.Create(handler, name: name); } + + public AgentInlineSkillScript(AIFunction function) + : base(function.Name, function.Description) { _function = function; } public JsonElement? ParametersSchema => _function.JsonSchema; From 0da7b2b1b529d96d52f6f7340164d6265d4362c7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 24 Mar 2026 11:48:19 +0000 Subject: [PATCH 09/10] consider ai-function as an alternative for skill script and skill resource model classes --- docs/decisions/0021-agent-skills-design.md | 204 +++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index d11fea2778..69bc6c4e57 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -720,4 +720,208 @@ class-based, and custom skills can coexist in the same provider. Custom skills a participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), filtering, deduplication, and caching — no additional integration work is required. +## Script Representation: `AgentSkillScript` vs `AIFunction` + +Two approaches were considered for representing executable scripts within skills: + +### Option A — Custom `AgentSkillScript` abstract base class (original design) + +Scripts are modeled as a custom `AgentSkillScript` abstract class with `Name`, `Description`, and +`ExecuteAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations: +`AgentInlineSkillScript` (wraps a delegate/`AIFunction`) and `AgentFileSkillScript` (wraps a file path + executor delegate). + +```csharp +// Base type +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes scripts as: +public abstract IReadOnlyList? Scripts { get; } + +// Inline script wraps an AIFunction internally +var script = new AgentInlineSkillScript(ConvertUnits, "convert"); + +// Pre-built AIFunction must be wrapped +var script = new AgentInlineSkillScript(myAIFunction); + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + new AgentInlineSkillScript(ConvertUnits, "convert"), +]; + +// Provider executes scripts by passing the owning skill: +await script.ExecuteAsync(skill, arguments, cancellationToken); +``` + +**Pros:** + +- **Explicit skill context at execution time.** `ExecuteAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring. +- **Self-contained abstraction.** A dedicated type communicates clearly that scripts are a skills-framework concept, separate from general-purpose AI functions. +- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `ExecuteAsync` without special setup. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillScript` is a thin pass-through around `AIFunction` — it adds a class, a constructor, and an indirection layer for no behavioral difference. +- **Parallel abstraction.** `AgentSkillScript` and `AIFunction` serve overlapping purposes (named callable with arguments), creating two parallel hierarchies for the same concept. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillScript` to use them as scripts, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Scripts are represented as `AIFunction` (from `Microsoft.Extensions.AI`). `AgentSkill.Scripts` returns +`IReadOnlyList?`. `AgentInlineSkillScript` is eliminated entirely — callers use +`AIFunctionFactory.Create(delegate, name: ...)` or pass `AIFunction` instances directly. +`AgentFileSkillScript` becomes an `AIFunction` subclass that captures its owning `AgentFileSkill` via +an internal back-reference set during construction. + +```csharp +// AgentSkill exposes scripts as AIFunction directly: +public abstract IReadOnlyList? Scripts { get; } + +// Inline scripts use AIFunctionFactory — no wrapper class needed +var skill = new AgentInlineSkill("my-skill", "desc", "instructions"); +skill.AddScript(ConvertUnits, "convert"); // delegate +skill.AddScript(myAIFunction); // pre-built AIFunction — no wrapping + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + AIFunctionFactory.Create(ConvertUnits, name: "convert"), +]; + +// Provider executes scripts via standard AIFunction invocation: +await script.InvokeAsync(arguments, cancellationToken); + +// File-based scripts extend AIFunction and capture the owning skill internally: +public sealed class AgentFileSkillScript : AIFunction +{ + internal AgentFileSkill? Skill { get; set; } // set by AgentFileSkill constructor + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await _executor(Skill!, this, arguments, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer types.** Eliminates `AgentSkillScript` and `AgentInlineSkillScript`, reducing the public API surface by two classes. +- **Seamless interop.** Any `AIFunction` — whether from `AIFunctionFactory`, a custom subclass, or an external library — can be used as a skill script with zero wrapping. +- **Consistent with `Microsoft.Extensions.AI` ecosystem.** Scripts share the same type as tool functions used by `IChatClient` and `FunctionInvokingChatClient`, reducing conceptual overhead for developers already familiar with the ecosystem. + +**Cons:** + +- **No owning-skill context in invocation signature.** `AIFunction.InvokeAsync` does not accept an `AgentSkill` parameter, so `AgentFileSkillScript` must capture its owning skill via an internal setter during construction. This adds a construction-order dependency: the skill must set the back-reference on its scripts. +- **Custom script types lose automatic skill access.** Third-party `AIFunction` subclasses that need the owning skill must implement their own mechanism (e.g., constructor injection, closure capture) instead of receiving it as a method parameter. + +## Resource Representation: `AgentSkillResource` vs `AIFunction` + +Two approaches were considered for representing skill resources (supplementary content such as references, assets, or dynamic data): + +### Option A — Custom `AgentSkillResource` abstract base class (original design) + +Resources are modeled as a custom `AgentSkillResource` abstract class with `Name`, `Description`, and +`ReadAsync(AIFunctionArguments, CancellationToken)`. Concrete implementations: +`AgentInlineSkillResource` (static value, delegate, or `AIFunction` wrapper) and `AgentFileSkillResource` (reads file content from disk). + +```csharp +// Base type +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes resources as: +public abstract IReadOnlyList? Resources { get; } + +// Static resource +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource (delegate) +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction must be wrapped +var resource = new AgentInlineSkillResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via: +await resource.ReadAsync(arguments, cancellationToken); +``` + +**Pros:** + +- **Clear semantic distinction.** A dedicated `AgentSkillResource` type distinguishes resources (data providers) from scripts (executable actions), making the API self-documenting. +- **Purpose-built API.** `ReadAsync` communicates intent better than `InvokeAsync` for a data-access operation. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillResource` wraps `AIFunction` internally for delegate/function cases — adding a class and indirection for no behavioral difference. +- **Parallel abstraction.** `AgentSkillResource` and `AIFunction` serve overlapping purposes (named callable that returns data), creating two parallel hierarchies. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillResource`, adding ceremony. + +### Option B — Reuse `AIFunction` directly (adopted) + +Resources are represented as `AIFunction`. `AgentSkill.Resources` returns `IReadOnlyList?`. +`AgentInlineSkillResource` becomes an `AIFunction` subclass (retained as a convenience for the static-value +pattern: `new AgentInlineSkillResource("data", "name")`). `AgentFileSkillResource` becomes an `AIFunction` +subclass that reads file content. + +```csharp +// AgentSkill exposes resources as AIFunction directly: +public abstract IReadOnlyList? Resources { get; } + +// Static resource — AgentInlineSkillResource is retained as a convenience AIFunction subclass +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource — AgentInlineSkillResource wraps delegate as AIFunction +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction can be used directly — no wrapping needed +skill.AddResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via standard AIFunction invocation: +await resource.InvokeAsync(arguments, cancellationToken); + +// File-based resources extend AIFunction directly: +internal sealed class AgentFileSkillResource : AIFunction +{ + public string FullPath { get; } + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer base types.** Eliminates the `AgentSkillResource` abstract class, reducing the public API surface. +- **Seamless interop.** Any `AIFunction` can be used as a skill resource with zero wrapping. + +**Cons:** + +- **Loss of semantic distinction.** Resources and scripts are now both `AIFunction`, which could make it less obvious which list a function belongs to when reading code. +- **Static values require a wrapper.** Unlike the original `ReadAsync` which could return a stored value directly, `AIFunction.InvokeAsync` implies invocation. `AgentInlineSkillResource` is retained as a convenience subclass to handle the static-value case, so this is not eliminated — just moved to a different class. + ## Decision Outcome From edfa389bf2aedf69c160fe8f15fd442d763395e5 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 25 Mar 2026 21:40:06 +0000 Subject: [PATCH 10/10] update decision outcome section and sync adr with latest changes in the code --- docs/decisions/0021-agent-skills-design.md | 127 +++++++++++++-------- 1 file changed, 80 insertions(+), 47 deletions(-) diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md index 69bc6c4e57..d63f38c734 100644 --- a/docs/decisions/0021-agent-skills-design.md +++ b/docs/decisions/0021-agent-skills-design.md @@ -1,7 +1,7 @@ status: proposed date: 2026-03-23 contact: sergeymenshykh -deciders: rbarreto, westey-m +deciders: rbarreto, westey-m, eavanvalkenburg --- # Agent Skills: Multi-Source Architecture @@ -29,7 +29,7 @@ Skills are presented to the model as up to three tools that progressively disclo - **`read_skill_resource(skillName, resourceName)`** — reads a supplementary resource (file-based or code-defined) associated with a skill - **`run_skill_script(skillName, scriptName, arguments?)`** — executes a script associated with a skill; only registered when at least one skill contains scripts -Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.ExecuteAsync()` respectively. +Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.RunAsync()` respectively. If skills have no scripts defined, the `run_skill_script` tool is **not advertised** to the model and instructions related to script execution are **not included** in the default skills instructions. @@ -50,14 +50,14 @@ public abstract class AgentSkillResource { public string Name { get; } public string? Description { get; } - public abstract Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); } public abstract class AgentSkillScript { public string Name { get; } public string? Description { get; } - public abstract Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); } public abstract class AgentSkillsSource @@ -88,7 +88,7 @@ The type hierarchy at a glance: AgentSkill (abstract) AgentSkillsSource (abstract) ├── AgentFileSkill ├── AgentFileSkillsSource (public) └── [Programmatic] ├── AgentInMemorySkillsSource (public) - ├── AgentInlineSkill ├── AggregateAgentSkillsSource (public) + ├── AgentInlineSkill ├── AggregatingAgentSkillsSource (public) └── AgentClassSkill (abstract) └── DelegatingAgentSkillsSource (abstract, public) ├── FilteringAgentSkillsSource (public) AgentSkillResource (abstract) ├── CachingAgentSkillsSource (public) @@ -133,28 +133,28 @@ internal sealed class AgentFileSkillResource : AgentSkillResource public string FullPath { get; } - public override Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { return File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); } } ``` -**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptExecutor` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured: +**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptRunner` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured: ```csharp -public delegate Task AgentFileSkillScriptExecutor( +public delegate Task AgentFileSkillScriptRunner( AgentFileSkill skill, AgentFileSkillScript script, AIFunctionArguments arguments, CancellationToken cancellationToken); public sealed class AgentFileSkillScript : AgentSkillScript { - private readonly AgentFileSkillScriptExecutor _executor; + private readonly AgentFileSkillScriptRunner _executor; - internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptExecutor executor) + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner executor) : base(name) { ... } - public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) { return await _executor(fileSkill, this, arguments, cancellationToken); @@ -162,7 +162,7 @@ public sealed class AgentFileSkillScript : AgentSkillScript } ``` -The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptExecutor(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. +The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptRunner(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. **`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks: @@ -171,7 +171,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource { public AgentFileSkillsSource( IEnumerable skillPaths, - AgentFileSkillScriptExecutor scriptExecutor, + AgentFileSkillScriptRunner scriptRunner, AgentFileSkillsSourceOptions? options = null, ILoggerFactory? loggerFactory = null) { ... } } @@ -200,7 +200,7 @@ skills/ ``` ```csharp -var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptExecutor: SubprocessExecutor.RunAsync); +var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptRunner: SubprocessScriptRunner.RunAsync); var provider = new AgentSkillsProvider(source); @@ -229,7 +229,7 @@ public sealed class AgentInMemorySkillsSource : AgentSkillsSource Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill. -**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values, delegates, or pre-built `AIFunction` instances; scripts can be delegates or `AIFunction` instances. Constructed with name, description, and instructions, then extended with resources and scripts: +**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: ```csharp public sealed class AgentInlineSkill : AgentSkill @@ -239,36 +239,47 @@ public sealed class AgentInlineSkill : AgentSkill public AgentInlineSkill AddResource(object value, string name, string? description = null); public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null); - public AgentInlineSkill AddResource(AIFunction function); public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null); - public AgentInlineSkill AddScript(AIFunction function); } ``` -**`AgentInlineSkillResource`** — A skill resource backed by a static value, a delegate, or a pre-built `AIFunction`. Static resources return a fixed value; delegate-based resources use `AIFunctionFactory` for automatic parameter marshaling; `AIFunction`-based resources use the function's name and description directly: +**`AgentInlineSkillResource`** — A skill resource that wraps a static value: ```csharp public sealed class AgentInlineSkillResource : AgentSkillResource { public AgentInlineSkillResource(object value, string name, string? description = null) - : base(name, description) { _value = value; } + : base(name, description) + { + _value = value; + } - public AgentInlineSkillResource(Delegate handler, string name, string? description = null) - : base(name, description) { _function = AIFunctionFactory.Create(handler, name: name); } + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(_value); + } +} +``` - public AgentInlineSkillResource(AIFunction function) - : base(function.Name, function.Description) { _function = function; } +**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } - public override async Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { - if (_function is not null) - return await _function.InvokeAsync(arguments, cancellationToken); - return _value; + return await _function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken); } } ``` -**`AgentInlineSkillScript`** — A skill script backed by a delegate or a pre-built `AIFunction`. Delegate-based scripts use `AIFunctionFactory` for automatic parameter marshaling; `AIFunction`-based scripts use the function's name and description directly: +**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`: ```csharp public sealed class AgentInlineSkillScript : AgentSkillScript @@ -276,14 +287,14 @@ public sealed class AgentInlineSkillScript : AgentSkillScript private readonly AIFunction _function; public AgentInlineSkillScript(Delegate handler, string name, string? description = null) - : base(name, description) { _function = AIFunctionFactory.Create(handler, name: name); } - - public AgentInlineSkillScript(AIFunction function) - : base(function.Name, function.Description) { _function = function; } + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } public JsonElement? ParametersSchema => _function.JsonSchema; - public override async Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) { return await _function.InvokeAsync(arguments, cancellationToken); } @@ -431,7 +442,7 @@ var fileSource = new CachingAgentSkillsSource(new AgentFileSkillsSource(["./skil var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); var compositeSource = new FilteringAgentSkillsSource( - new AggregateAgentSkillsSource([fileSource, codeSource]), + new AggregatingAgentSkillsSource([fileSource, codeSource]), filter: s => s.Frontmatter.Name != "internal"); var provider = new AgentSkillsProvider(compositeSource); @@ -498,7 +509,7 @@ var provider = new AgentSkillsProviderBuilder() .UseFileSkill("./skills") // file-based source .UseInlineSkills(codeSkill) // code-defined source .UseClassSkills(new ClassSkill()) // class-based source - .UseFileScriptExecutor(SubprocessExecutor.RunAsync) // script runner + .UseFileScriptRunner(SubprocessScriptRunner.RunAsync) // script runner .UseScriptApproval() // optional human-in-the-loop .UsePromptTemplate(customTemplate) // optional prompt customization .UseFilter(s => s.Frontmatter.Name != "internal") // optional skill filtering @@ -552,7 +563,7 @@ public sealed class CloudSkillResource : AgentSkillResource /// public override async Task ReadAsync( - AIFunctionArguments arguments, + IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { return await _httpClient.GetStringAsync(BlobUri, cancellationToken).ConfigureAwait(false); @@ -586,7 +597,7 @@ public sealed class CloudSkillScript : AgentSkillScript public Uri FunctionUri { get; } /// - public override async Task ExecuteAsync( + public override async Task RunAsync( AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) @@ -710,7 +721,7 @@ var provider = new AgentSkillsProviderBuilder() new Uri("https://my-service.example.com/skills/catalog"), httpClient)) // Mix with other source types if needed: - .UseFileSkill("/local/skills", scriptExecutor) + .UseFileSkill("/local/skills", scriptRunner) .UseInlineSkills(someInlineSkill) .Build(); ``` @@ -727,7 +738,7 @@ Two approaches were considered for representing executable scripts within skills ### Option A — Custom `AgentSkillScript` abstract base class (original design) Scripts are modeled as a custom `AgentSkillScript` abstract class with `Name`, `Description`, and -`ExecuteAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations: +`RunAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations: `AgentInlineSkillScript` (wraps a delegate/`AIFunction`) and `AgentFileSkillScript` (wraps a file path + executor delegate). ```csharp @@ -736,7 +747,7 @@ public abstract class AgentSkillScript { public string Name { get; } public string? Description { get; } - public abstract Task ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); } // AgentSkill exposes scripts as: @@ -755,14 +766,14 @@ public override IReadOnlyList? Scripts { get; } = ]; // Provider executes scripts by passing the owning skill: -await script.ExecuteAsync(skill, arguments, cancellationToken); +await script.RunAsync(skill, arguments, cancellationToken); ``` **Pros:** -- **Explicit skill context at execution time.** `ExecuteAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring. +- **Explicit skill context at execution time.** `RunAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring. - **Self-contained abstraction.** A dedicated type communicates clearly that scripts are a skills-framework concept, separate from general-purpose AI functions. -- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `ExecuteAsync` without special setup. +- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `RunAsync` without special setup. **Cons:** @@ -819,6 +830,7 @@ public sealed class AgentFileSkillScript : AIFunction - **No owning-skill context in invocation signature.** `AIFunction.InvokeAsync` does not accept an `AgentSkill` parameter, so `AgentFileSkillScript` must capture its owning skill via an internal setter during construction. This adds a construction-order dependency: the skill must set the back-reference on its scripts. - **Custom script types lose automatic skill access.** Third-party `AIFunction` subclasses that need the owning skill must implement their own mechanism (e.g., constructor injection, closure capture) instead of receiving it as a method parameter. +- **Semantic overloading.** `AIFunction` now means both "a tool the model can call" and "a script within a skill", which could blur the distinction for framework users. ## Resource Representation: `AgentSkillResource` vs `AIFunction` @@ -827,7 +839,7 @@ Two approaches were considered for representing skill resources (supplementary c ### Option A — Custom `AgentSkillResource` abstract base class (original design) Resources are modeled as a custom `AgentSkillResource` abstract class with `Name`, `Description`, and -`ReadAsync(AIFunctionArguments, CancellationToken)`. Concrete implementations: +`ReadAsync(IServiceProvider?, CancellationToken)`. Concrete implementations: `AgentInlineSkillResource` (static value, delegate, or `AIFunction` wrapper) and `AgentFileSkillResource` (reads file content from disk). ```csharp @@ -836,7 +848,7 @@ public abstract class AgentSkillResource { public string Name { get; } public string? Description { get; } - public abstract Task ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); } // AgentSkill exposes resources as: @@ -858,7 +870,7 @@ public override IReadOnlyList? Resources { get; } = ]; // Provider reads resources via: -await resource.ReadAsync(arguments, cancellationToken); +await resource.ReadAsync(serviceProvider, cancellationToken); ``` **Pros:** @@ -872,7 +884,7 @@ await resource.ReadAsync(arguments, cancellationToken); - **Parallel abstraction.** `AgentSkillResource` and `AIFunction` serve overlapping purposes (named callable that returns data), creating two parallel hierarchies. - **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillResource`, adding ceremony. -### Option B — Reuse `AIFunction` directly (adopted) +### Option B — Reuse `AIFunction` directly Resources are represented as `AIFunction`. `AgentSkill.Resources` returns `IReadOnlyList?`. `AgentInlineSkillResource` becomes an `AIFunction` subclass (retained as a convenience for the static-value @@ -925,3 +937,24 @@ internal sealed class AgentFileSkillResource : AIFunction - **Static values require a wrapper.** Unlike the original `ReadAsync` which could return a stored value directly, `AIFunction.InvokeAsync` implies invocation. `AgentInlineSkillResource` is retained as a convenience subclass to handle the static-value case, so this is not eliminated — just moved to a different class. ## Decision Outcome + +### 1. Keep `AgentSkillResource` and `AgentSkillScript` (Option A for both sections) + +We are staying with the custom `AgentSkillResource` and `AgentSkillScript` model classes instead of reusing `AIFunction`: + +- **Resources have no parameters.** If a consumer provides an `AIFunction` with parameters, those parameters will never be advertised to the LLM, and the resulting call will fail. +- **Approval breaks for `AIFunction`-based representations.** When a resource or script represented by an `AIFunction` is configured with approval, the second approval invocation will not work correctly. +- **Injecting the owning skill into an `AIFunction`-based script is problematic.** Constructor injection would introduce a circular reference between the skill and the script. An internal property setter is possible but adds coupling. + +### 2. Make all agent skill classes internal + +All agent-skill-related classes are made `internal` to minimize the public API surface while the feature matures. We can reconsider and promote types to `public` later based on community signal. + +This leaves two public entry points: + +- **`AgentSkillsProvider`** — use directly when all skills come from a single source and filtering is not needed. +- **`AgentSkillsProviderBuilder`** — use when mixing skill types or when filtering support is required. + +### 3. Caching at provider level + +Caching of tools and instructions is implemented inside `AgentSkillsProvider` rather than as an external decorator. Recreating tools and instructions on every provider call is wasteful, and a caching decorator sitting outside the provider would not have the information needed to cache them effectively.