Skip to content
Merged
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 @@ -45,7 +45,7 @@ public static List<AIContextProvider> Build(

if (item.Skills.Count > 0)
{
providers.Add(SkillsProviderFactory.Create(RequireCatalog(item, context, pointer, "skills"), item.Skills, context.Loggers));
providers.Add(SkillsProviderFactory.Create(RequireCatalog(item, context, pointer, "skills"), item.Skills, item.Pinned, context.Loggers));
}

ModelReference? model = item.Model ?? defaults?.Model;
Expand Down
52 changes: 52 additions & 0 deletions src/AgentCore.Application/Skills/PinnedSkillRedirect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text.Json;
using Microsoft.Extensions.AI;

namespace AgentCore.Application.Skills
{
/// <summary>
/// MAF's <c>load_skill</c>, answering a pinned name with where its body already is.
/// </summary>
internal sealed class PinnedSkillRedirect : DelegatingAIFunction
{
/// <summary>The parameter name of MAF's own <c>load_skill</c> delegate.</summary>
private const string SkillNameArgument = "skillName";

private readonly IReadOnlySet<string> _pinned;

/// <summary>Creates the wrapper.</summary>
/// <param name="inner">MAF's <c>load_skill</c> tool.</param>
/// <param name="pinned">The names this agent pins.</param>
/// <exception cref="ArgumentNullException">An argument is <see langword="null"/>.</exception>
internal PinnedSkillRedirect(AIFunction inner, IReadOnlySet<string> pinned)
: base(inner)
{
ArgumentNullException.ThrowIfNull(pinned);
_pinned = pinned;
}

/// <inheritdoc />
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(arguments);

if (ReadName(arguments) is { } name && _pinned.Contains(name))
{
return ValueTask.FromResult<object?>(
$"The '{name}' skill is already in your instructions, inside <skill name=\"{name}\">. Use it from there.");
}

return base.InvokeCoreAsync(arguments, cancellationToken);
}

private static string? ReadName(AIFunctionArguments arguments)
{
_ = arguments.TryGetValue(SkillNameArgument, out object? value);
return value switch
{
string text => text,
JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(),
_ => null,
};
}
}
}
23 changes: 20 additions & 3 deletions src/AgentCore.Application/Skills/ReadOnlySkillsProvider.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace AgentCore.Application.Skills
{
/// <summary>
/// One agent's skills provider, with the script tool removed.
/// One agent's skills provider, with the script tool removed and <c>load_skill</c> pointing a
/// pinned name back at the instructions.
/// </summary>
internal sealed class ReadOnlySkillsProvider : AIContextProvider, IDisposable
{
private readonly AgentSkillsProvider _inner;

private readonly IReadOnlySet<string> _pinned;

/// <summary>Creates the wrapper, taking ownership of the provider it wraps.</summary>
/// <param name="inner">The provider whose tools are filtered.</param>
/// <exception cref="ArgumentNullException"><paramref name="inner"/> is <see langword="null"/>.</exception>
internal ReadOnlySkillsProvider(AgentSkillsProvider inner)
/// <param name="pinned">The names this agent pins, which <c>load_skill</c> answers without loading.</param>
/// <exception cref="ArgumentNullException">An argument is <see langword="null"/>.</exception>
internal ReadOnlySkillsProvider(AgentSkillsProvider inner, IReadOnlySet<string> pinned)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(pinned);
_inner = inner;
_pinned = pinned;
}

/// <inheritdoc/>
Expand All @@ -28,6 +35,7 @@ protected override async ValueTask<AIContext> InvokingCoreAsync(
// Null when the agent's filter matched no skill at all.
provided.Tools = provided.Tools?
.Where(tool => !string.Equals(tool.Name, AgentSkillsProvider.RunSkillScriptToolName, StringComparison.Ordinal))
.Select(RedirectPinned)
.ToList();

return provided;
Expand All @@ -39,5 +47,14 @@ public void Dispose()
_inner.Dispose();
GC.SuppressFinalize(this);
}

private AITool RedirectPinned(AITool tool)
{
return _pinned.Count > 0
&& tool is AIFunction function
&& string.Equals(function.Name, AgentSkillsProvider.LoadSkillToolName, StringComparison.Ordinal)
? new PinnedSkillRedirect(function, _pinned)
: tool;
}
}
}
6 changes: 5 additions & 1 deletion src/AgentCore.Application/Skills/SkillsProviderFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,19 @@ This host runs no skill scripts. Ignore any scripts a skill lists.
/// <summary>Builds the provider one agent binds.</summary>
/// <param name="catalog">The skills the host bound, shared by every agent.</param>
/// <param name="skills">The names this agent may load.</param>
/// <param name="pinned">The names this agent pins, which <c>load_skill</c> answers by pointing at the instructions.</param>
/// <param name="loggers">Where the provider writes its diagnostics, or <see langword="null"/>.</param>
/// <returns>The provider to hang on that agent.</returns>
/// <exception cref="ArgumentNullException">A required argument is <see langword="null"/>.</exception>
internal static AIContextProvider Create(
SkillCatalog catalog,
IReadOnlyList<string> skills,
IReadOnlyList<string> pinned,
ILoggerFactory? loggers)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(skills);
ArgumentNullException.ThrowIfNull(pinned);

HashSet<string> allowed = new(skills, StringComparer.Ordinal);

Expand All @@ -64,7 +67,8 @@ internal static AIContextProvider Create(
loggers),
options,
loggers,
ownsSource: false));
ownsSource: false),
new HashSet<string>(pinned, StringComparer.Ordinal));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public async Task InvokingAsync_RemovesTheScriptToolAndLeavesTheCallersContextIn
loggerFactory: null,
ownsSource: false);

using ReadOnlySkillsProvider provider = new(inner);
using ReadOnlySkillsProvider provider = new(inner, new HashSet<string>());

using SequencedChatClient client = new("hello there.");
ChatClientAgent agent = new(client, new ChatClientAgentOptions { Name = "support" });
Expand Down Expand Up @@ -57,7 +57,7 @@ public async Task InvokingAsync_WhenTheFilterMatchesNothing_ReturnsNoToolsAndDoe
using AgentFileSkillsSource source = new(folder.Root);
using FilteringAgentSkillsSource filtered = new(source, (_, _) => false);
using AgentSkillsProvider inner = new(filtered, options: null, loggerFactory: null, ownsSource: false);
using ReadOnlySkillsProvider provider = new(inner);
using ReadOnlySkillsProvider provider = new(inner, new HashSet<string>());

using SequencedChatClient client = new("hello there.");
ChatClientAgent agent = new(client, new ChatClientAgentOptions { Name = "support" });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using AgentCore.Application.Skills;
using AgentCore.Application.Tests.Runtime;
using AgentCore.TestSupport;
Expand All @@ -9,7 +10,8 @@ namespace AgentCore.Application.Tests.Skills
{
/// <summary>
/// One agent must see its own skills and no others, must never be asked to approve a tool call
/// mid-conversation, and must never be told a script tool exists.
/// mid-conversation, must never be told a script tool exists, and must be pointed at a pinned
/// skill's body when it tries to load one.
/// </summary>
public sealed class SkillsProviderFactoryTests
{
Expand Down Expand Up @@ -58,8 +60,8 @@ public async Task Create_TwoAgentsOverOneSource_BothKeepWorkingAfterOneIsDispose
using CachingAgentSkillsSource shared = new(source);
SkillCatalog catalog = new(shared, new HashSet<string>(["warranty-returns", "shipping-claims"], StringComparer.Ordinal));

AIContextProvider first = SkillsProviderFactory.Create(catalog, ["warranty-returns"], loggers: null);
AIContextProvider second = SkillsProviderFactory.Create(catalog, ["shipping-claims"], loggers: null);
AIContextProvider first = SkillsProviderFactory.Create(catalog, ["warranty-returns"], pinned: [], loggers: null);
AIContextProvider second = SkillsProviderFactory.Create(catalog, ["shipping-claims"], pinned: [], loggers: null);

(first as IDisposable)?.Dispose();

Expand All @@ -68,6 +70,60 @@ public async Task Create_TwoAgentsOverOneSource_BothKeepWorkingAfterOneIsDispose
Assert.Contains("shipping-claims", result.Instructions, StringComparison.Ordinal);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task LoadSkill_APinnedName_SaysTheBodyIsAlreadyInTheInstructions(bool asJson)
{
string? answer = await LoadSkillAsync("shipping-claims", asJson);

Assert.Equal(
"The 'shipping-claims' skill is already in your instructions, inside <skill name=\"shipping-claims\">. Use it from there.",
answer);
}

[Fact]
public async Task LoadSkill_ALoadableName_ReturnsItsBody()
{
string? answer = await LoadSkillAsync("warranty-returns", asJson: true);

Assert.Contains("Do the thing.", answer, StringComparison.Ordinal);
}

[Fact]
public async Task LoadSkill_AnUnknownName_KeepsTheFrameworksNotFoundAnswer()
{
string? answer = await LoadSkillAsync("returns", asJson: true);

Assert.Equal("Error: Skill 'returns' not found.", answer);
}

/// <summary>
/// Calls load_skill on an agent that may load warranty-returns and pins shipping-claims. A
/// model's arguments arrive as <see cref="JsonElement"/>; a direct caller may pass a string.
/// MAF hands its own answers back as a JSON string, so both shapes are read as text.
/// </summary>
private static async Task<string?> LoadSkillAsync(string skillName, bool asJson)
{
using SkillFolder folder = SkillFolder.Create()
.WithSkill("warranty-returns")
.WithSkill("shipping-claims");

using AgentFileSkillsSource source = new(folder.Root);
using CachingAgentSkillsSource shared = new(source);
SkillCatalog catalog = new(shared, new HashSet<string>(["warranty-returns", "shipping-claims"], StringComparer.Ordinal));

AIContext context = await InvokeAsync(SkillsProviderFactory.Create(catalog, ["warranty-returns"], pinned: ["shipping-claims"], loggers: null));
AIFunction loadSkill = context.Tools!.OfType<AIFunction>().Single(tool => tool.Name == AgentSkillsProvider.LoadSkillToolName);

AIFunctionArguments arguments = new()
{
["skillName"] = asJson ? JsonSerializer.SerializeToElement(skillName) : skillName,
};
object? answer = await loadSkill.InvokeAsync(arguments, TestContext.Current.CancellationToken);
return answer is JsonElement json ? json.GetString() : answer as string;
}

private static async Task<AIContext> InvokeAsync(IReadOnlyList<string> allowed)
{
using SkillFolder folder = SkillFolder.Create()
Expand All @@ -78,7 +134,7 @@ private static async Task<AIContext> InvokeAsync(IReadOnlyList<string> allowed)
using CachingAgentSkillsSource shared = new(source);
SkillCatalog catalog = new(shared, new HashSet<string>(["warranty-returns", "shipping-claims"], StringComparer.Ordinal));

return await InvokeAsync(SkillsProviderFactory.Create(catalog, allowed, loggers: null));
return await InvokeAsync(SkillsProviderFactory.Create(catalog, allowed, pinned: [], loggers: null));
}

private static async Task<AIContext> InvokeAsync(AIContextProvider provider)
Expand Down
Loading