Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public AgentMcpSkillsSource(McpClient client, AgentMcpSkillsSourceOptions? optio
}

/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
if (this.TryGetCachedSkills() is { } cached)
{
Expand All @@ -99,7 +99,7 @@ public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken c
{
// The refresh owner uses CancellationToken.None so that a single caller's cancellation
// does not abort the shared refresh for all concurrent waiters.
var skills = await this.GetCoreSkillsAsync(CancellationToken.None).ConfigureAwait(false);
var skills = await this.GetCoreSkillsAsync(context, CancellationToken.None).ConfigureAwait(false);

Comment on lines 99 to 103
this.UpdateCache(skills);

Expand Down Expand Up @@ -155,7 +155,7 @@ private void UpdateCache(IList<AgentSkill> skills)
/// Reads the skill index from the MCP server, dispatches entries to registered loaders, and
/// returns the aggregated skill list.
/// </summary>
private async Task<IList<AgentSkill>> GetCoreSkillsAsync(CancellationToken cancellationToken)
private async Task<IList<AgentSkill>> GetCoreSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken)
{
McpSkillIndex? index = await this.TryReadIndexAsync(cancellationToken).ConfigureAwait(false);

Expand Down Expand Up @@ -185,7 +185,7 @@ private async Task<IList<AgentSkill>> GetCoreSkillsAsync(CancellationToken cance
foreach (var loader in this._loaders.Values)
{
var entries = entriesByType.TryGetValue(loader.EntryType, out List<McpSkillIndexEntry>? matched) ? matched : [];
skills.AddRange(await loader.LoadAsync(entries, cancellationToken).ConfigureAwait(false));
skills.AddRange(await loader.LoadAsync(entries, context, cancellationToken).ConfigureAwait(false));
}

LogSkillsLoadedTotal(this._logger, skills.Count);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public ArchiveEntryLoader(McpClient client, AgentMcpSkillsSourceOptions? options
public string EntryType => "archive";

/// <inheritdoc/>
public async Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry> entries, CancellationToken cancellationToken)
public async Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry> entries, AgentSkillsSourceContext context, CancellationToken cancellationToken)
{
// Filter out entries that are missing required fields or have invalid names.
var archiveEntries = this.FilterValidEntries(entries);
Expand Down Expand Up @@ -81,7 +81,7 @@ public async Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry>
// Delegate discovery of extracted content to a file-based skills source.
AgentFileSkillsSource fileSource = this.CreateFileSkillsSource(skillDirectories);

return await fileSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
return await fileSource.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal interface IMcpSkillEntryLoader
/// loaded successfully.
/// </summary>
/// <param name="entries">The index entries of this loader's <see cref="EntryType"/>. May be empty.</param>
/// <param name="context">The skills source context for the current invocation.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry> entries, CancellationToken cancellationToken);
Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry> entries, AgentSkillsSourceContext context, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public SkillMdEntryLoader(McpClient client, ILoggerFactory loggerFactory)
public string EntryType => "skill-md";

/// <inheritdoc/>
public Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry> entries, CancellationToken cancellationToken)
public Task<IList<AgentSkill>> LoadAsync(IReadOnlyList<McpSkillIndexEntry> entries, AgentSkillsSourceContext context, CancellationToken cancellationToken)
{
var skills = new List<AgentSkill>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public AgentInMemorySkillsSource(IEnumerable<AgentSkill> skills)
}

/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
return Task.FromResult<IList<AgentSkill>>(this._skills);
}
Expand Down
3 changes: 2 additions & 1 deletion dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingCont

private async Task<AIContext> CreateContextAsync(InvokingContext context, CancellationToken cancellationToken)
{
var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
var skillsContext = new AgentSkillsSourceContext(context.Agent);
var skills = await this._source.GetSkillsAsync(skillsContext, cancellationToken).ConfigureAwait(false);
if (skills is not { Count: > 0 })
Comment on lines 233 to 237
{
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
Expand Down
3 changes: 2 additions & 1 deletion dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public abstract class AgentSkillsSource
/// <summary>
/// Gets the skills provided by this source.
/// </summary>
/// <param name="context">The context for the current invocation, exposing the invoking <see cref="AIAgent"/>.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A collection of skills from this source.</returns>
public abstract Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default);
public abstract Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default);
Comment on lines +21 to +24
}
35 changes: 35 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSourceContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI;

/// <summary>
/// Provides context about the current invocation to <see cref="AgentSkillsSource.GetSkillsAsync"/>.
/// </summary>
/// <remarks>
/// This context is created internally by <see cref="AgentSkillsProvider"/> and passed through the
/// source pipeline, allowing skill sources to make context-aware decisions such as filtering skills
/// per agent.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillsSourceContext
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsSourceContext"/> class.
/// </summary>
/// <param name="agent">The agent that is invoking the skill source.</param>
/// <exception cref="System.ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public AgentSkillsSourceContext(AIAgent agent)
{
this.Agent = Throw.IfNull(agent);
}

/// <summary>
/// Gets the agent that is invoking the skill source.
/// </summary>
public AIAgent Agent { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ public AggregatingAgentSkillsSource(IEnumerable<AgentSkillsSource> sources)
}

/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var allSkills = new List<AgentSkill>();
foreach (var source in this._sources)
{
var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
var skills = await source.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);
allSkills.AddRange(skills);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFact
}

/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
var allSkills = await this.InnerSource.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);

var deduplicated = new List<AgentSkill>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource)
protected AgentSkillsSource InnerSource { get; }

/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
=> this.InnerSource.GetSkillsAsync(cancellationToken);
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
=> this.InnerSource.GetSkillsAsync(context, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ public FilteringAgentSkillsSource(
}

/// <inheritdoc/>
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
var allSkills = await this.InnerSource.GetSkillsAsync(context, cancellationToken).ConfigureAwait(false);

var filtered = new List<AgentSkill>();
foreach (var skill in allSkills)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public AgentFileSkillsSource(
}

/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -56,6 +56,8 @@ Content B.
private readonly string _extractionRoot =
Path.Combine(Path.GetTempPath(), "af-mcp-archive-tests", Guid.NewGuid().ToString("N"));

private readonly AgentSkillsSourceContext _context = new(new TestAIAgent());

private const int ManyFileArchiveFileCount = 60;

[Fact]
Expand All @@ -68,7 +70,7 @@ public async Task GetSkillsAsync_ZipArchive_DiscoversSkillAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
var skill = Assert.Single(skills);
Expand All @@ -87,7 +89,7 @@ public async Task GetSkillsAsync_TarGzArchive_DiscoversSkillAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
var skill = Assert.Single(skills);
Expand All @@ -110,7 +112,7 @@ public async Task GetSkillsAsync_ArchiveWithScript_SurfacesScriptAsReadableResou
var source = new AgentMcpSkillsSource(client, options);

// Act
var skill = Assert.Single(await source.GetSkillsAsync());
var skill = Assert.Single(await source.GetSkillsAsync(this._context));
var resource = await skill.GetResourceAsync("scripts/run.py");

// Assert - the .py file is readable as a resource (not an executable script).
Expand All @@ -133,8 +135,8 @@ public async Task GetSkillsAsync_TwoSourcesWithSeparateDirectories_DoNotCollideA
var sourceB = new AgentMcpSkillsSource(clientB, new() { ArchiveSkillsDirectory = Path.Combine(this._extractionRoot, "b") });

// Act
var skillA = Assert.Single(await sourceA.GetSkillsAsync());
var skillB = Assert.Single(await sourceB.GetSkillsAsync());
var skillA = Assert.Single(await sourceA.GetSkillsAsync(this._context));
var skillB = Assert.Single(await sourceB.GetSkillsAsync(this._context));

// Assert - each source kept its own content despite the shared skill name.
Assert.Equal("shared-skill", skillA.Frontmatter.Name);
Expand All @@ -157,7 +159,7 @@ public async Task GetSkillsAsync_FirstRun_PrunesLeftoverSkillDirectoryAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert - the leftover directory is pruned and only the advertised skill remains.
Assert.False(Directory.Exists(staleDir));
Expand All @@ -172,15 +174,15 @@ public async Task GetSkillsAsync_SkillNoLongerAdvertised_IsPrunedAsync()
await using var fullServer = new InMemoryMcpServer(builder => builder.WithResources<TwoSkillServer>());
await using var fullClient = await fullServer.CreateClientAsync();
var firstSource = new AgentMcpSkillsSource(fullClient, new() { ArchiveSkillsDirectory = this._extractionRoot });
var firstSkills = await firstSource.GetSkillsAsync();
var firstSkills = await firstSource.GetSkillsAsync(this._context);
Assert.Equal(2, firstSkills.Count);
Assert.True(Directory.Exists(Path.Combine(this._extractionRoot, "skill-b")));

// Act - a later run sees only skill-a.
await using var partialServer = new InMemoryMcpServer(builder => builder.WithResources<OneSkillServer>());
await using var partialClient = await partialServer.CreateClientAsync();
var secondSource = new AgentMcpSkillsSource(partialClient, new() { ArchiveSkillsDirectory = this._extractionRoot });
var secondSkills = await secondSource.GetSkillsAsync();
var secondSkills = await secondSource.GetSkillsAsync(this._context);

// Assert - skill-b's directory was pruned; only skill-a remains.
var skill = Assert.Single(secondSkills);
Expand All @@ -203,7 +205,7 @@ public async Task GetSkillsAsync_ServerListsNoArchives_PrunesLeftoversAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert - the leftover directory is pruned and no skills are returned.
Assert.Empty(skills);
Expand All @@ -218,14 +220,14 @@ public async Task GetSkillsAsync_SecondDiscovery_ReExtractsContentAsync()
await using (var clientA = await serverA.CreateClientAsync())
{
var firstSource = new AgentMcpSkillsSource(clientA, new() { ArchiveSkillsDirectory = this._extractionRoot });
Assert.Single(await firstSource.GetSkillsAsync());
Assert.Single(await firstSource.GetSkillsAsync(this._context));
}

// Act - a second run over the same directory re-extracts server B's content.
await using var serverB = new InMemoryMcpServer(builder => builder.WithResources<SharedNameServerB>());
await using var clientB = await serverB.CreateClientAsync();
var source = new AgentMcpSkillsSource(clientB, new() { ArchiveSkillsDirectory = this._extractionRoot });
var skill = Assert.Single(await source.GetSkillsAsync());
var skill = Assert.Single(await source.GetSkillsAsync(this._context));

// Assert - the content was replaced with server B's.
Assert.Contains("Content from server B.", await skill.GetContentAsync());
Expand Down Expand Up @@ -258,7 +260,7 @@ Stale content.
var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot });

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert - failed cleanup prevents the stale directory from being proxied to AgentFileSkillsSource.
Assert.Empty(skills);
Expand Down Expand Up @@ -412,7 +414,7 @@ public async Task GetSkillsAsync_ArchiveExceedsDefaultFileCount_SkillSkippedAsyn
var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot });

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
Assert.Empty(skills);
Expand All @@ -432,7 +434,7 @@ public async Task GetSkillsAsync_ArchiveExceedsConfiguredArchiveSize_SkillSkippe
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
Assert.Empty(skills);
Expand All @@ -452,7 +454,7 @@ public async Task GetSkillsAsync_RaisedFileCountOption_LoadsLargerArchiveAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skill = Assert.Single(await source.GetSkillsAsync());
var skill = Assert.Single(await source.GetSkillsAsync(this._context));

// Assert
Assert.Equal("archived-skill", skill.Frontmatter.Name);
Expand All @@ -468,7 +470,7 @@ public async Task GetSkillsAsync_EntryMissingName_SkillSkippedAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert - the invalid entry is skipped; no skills are surfaced.
Assert.Empty(skills);
Expand All @@ -484,7 +486,7 @@ public async Task GetSkillsAsync_EntryWithInvalidNameChars_SkillSkippedAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
Assert.Empty(skills);
Expand All @@ -500,7 +502,7 @@ public async Task GetSkillsAsync_EntryMissingUrl_SkillSkippedAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
Assert.Empty(skills);
Expand All @@ -517,7 +519,7 @@ public async Task GetSkillsAsync_ArchiveWithUnsupportedFormat_SkillSkippedAsync(
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
Assert.Empty(skills);
Expand All @@ -533,7 +535,7 @@ public async Task GetSkillsAsync_ArchiveReturnsTextNotBlob_SkillSkippedAsync()
var source = new AgentMcpSkillsSource(client, options);

// Act
var skills = await source.GetSkillsAsync();
var skills = await source.GetSkillsAsync(this._context);

// Assert
Assert.Empty(skills);
Expand Down Expand Up @@ -837,3 +839,4 @@ private sealed class TextOnlyArchiveServer

#endregion
}

Loading