ADR to support a multi-source architecture for agent skills#4787
ADR to support a multi-source architecture for agent skills#4787SergeyMenshykh wants to merge 6 commits intomicrosoft:mainfrom
Conversation
…e for agent skills
There was a problem hiding this comment.
Pull request overview
Adds a new Architectural Decision Record proposing a multi-source “agent skills” architecture for the .NET Agent Framework, covering file-based, code-defined, and class-based skills along with composition/filtering/caching strategies.
Changes:
- Introduces a new ADR describing abstractions for skills, resources, scripts, and skill sources.
- Documents alternative approaches for composition, filtering, caching, and deduplication.
- Proposes a builder pattern for configuring an
AgentSkillsProviderfrom multiple sources.
| public abstract class AgentSkillScript | ||
| { | ||
| public string Name { get; } | ||
| public string? Description { get; } | ||
| public abstract Task<object?> ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); | ||
| } |
There was a problem hiding this comment.
Would make sense, making it an AIFunction ? It seems to address this purpose, given the AIFunctionArguments so you just add AgentSkillScriptAIFunction to the AgentSkillScript.
And use the InvokeAsync as is? Including all the IServiceProvider capabilities, etc?
There was a problem hiding this comment.
I've tried it both ways. I prefer the expressiveness of the ExecuteAsync signature - as an implementer of a custom script, you clearly see the parameters the method accepts (e.g., the owning AgentSkill). With AIFunction as the base, users would override InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) and rely on documentation or reverse engineering to discover which arguments it's called with and how to access them.
| public override Task<object?> ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) | ||
| { | ||
| return File.ReadAllTextAsync(path, cancellationToken); | ||
| } |
There was a problem hiding this comment.
Is AIFunctionArguments intended here?
| public override Task<object?> ReadAsync(AIFunctionArguments arguments, CancellationToken cancellationToken = default) | |
| { | |
| return File.ReadAllTextAsync(path, cancellationToken); | |
| } | |
| public override Task<object?> ReadAsync(CancellationToken cancellationToken = default) | |
| { | |
| return File.ReadAllTextAsync(path, cancellationToken); | |
| } |
There was a problem hiding this comment.
Yes, it's intentional for a few reasons:
- DI support:
AIFunctionArgumentscontains anIServiceProvider, enabling resource functions to access services/dependencies from the service collection. We could useIServiceProviderdirectly, but that would break symmetry (see point 2) and wouldn't allow us to support LLM-provided arguments in the future without a breaking change. - Symmetry: It keeps the signature consistent with
AgentSkillScript.ExecuteAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default).
The base class defines the contract broadly; concrete implementations use what they need. The arguments are used by scripts for code-defined skills. For file-based skills, they are not relevant and not used.
| AgentSkill skill, AgentFileSkillScript script, | ||
| AIFunctionArguments arguments, CancellationToken cancellationToken); | ||
|
|
||
| public sealed class AgentFileSkillScript : AgentSkillScript |
There was a problem hiding this comment.
Maybe we don't need the abstract AgentSkillScript at all if we can use the AIFunction directly and implement one specific.
| public sealed class AgentFileSkillScript : AgentSkillScript | |
| public sealed class AgentFileSkillScriptAIFunction : AIFunction |
There was a problem hiding this comment.
Similar to the reasoning above - with AIFunction, the owning AgentSkill has to be smuggled through AIFunctionArguments via a magic string key:
// With AgentSkillScript - contract is explicit
public override Task<object?> ExecuteAsync(
AgentSkill skill, // ← owning skill is right here
AIFunctionArguments arguments,
CancellationToken ct)
{
var scriptPath = Path.Combine(skill.SourcePath, this.Path);
return this._executor(skill, this, arguments, ct);
}
// With AIFunction - skill is hidden behind a convention
protected override ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken ct)
{
// Caller must remember to do: arguments["__agentSkill"] = skill
var skill = arguments["__agentSkill"] as AgentSkill
?? throw new InvalidOperationException("...");
var scriptPath = Path.Combine(skill.SourcePath, this.Path);
return this._executor(skill, this, arguments, ct);
}ExecuteAsync makes the skill-script relationship a compile-time contract. With AIFunction, it becomes a runtime convention.
| var resources = doc.Resources?.Select( | ||
| r => new AgentCodeSkillResource(r.Content, r.Name, r.Description)).ToList(); | ||
|
|
||
| skills.Add(new AgentCodeSkill(frontmatter, doc.Instructions) | ||
| .AddResources(resources)); |
There was a problem hiding this comment.
Any special reason in this example for retrieving a CodeSkillResource from the CosmosDB ?
Maybe a real Delegate sample would fit best for this type.
| .AddFileSkills("./skills") // file-based source | ||
| .AddCodeSkills(codeSkill) // code-defined source | ||
| .AddClassSkills(new ClassSkill()) // class-based source |
There was a problem hiding this comment.
Our other builders have the Use naming, we might consider UseFileSkills(...).
UseFileScriptExecutor()
UseScriptApproval()
UsePromptTemplate().
| 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)) |
There was a problem hiding this comment.
I would drop the CompositeAgentSkillSource and suggest we keep via builder similar how we do for AIAgents, Middlewares, etc.
i.e
| 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)) | |
| var provider = new AgentSkillsProvider(compositeSource); | |
| // Or via the builder (requires registering the source type with the builder) | |
| var provider = new AgentSkillsProviderBuilder() | |
| .UseSource(new CachingSkillsSource(new CosmosDbSkillsSource(cosmosClient)) | |
| .UseSource(new AgentFileSkillsSource(["./skills"]) |
This PR adds an ADR proposing a new design to support a multi-source architecture for agent skills.