diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs index ba029331..987f6315 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs @@ -45,7 +45,7 @@ public static List 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; diff --git a/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs b/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs new file mode 100644 index 00000000..21f0b9fd --- /dev/null +++ b/src/AgentCore.Application/Skills/PinnedSkillRedirect.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Skills +{ + /// + /// MAF's load_skill, answering a pinned name with where its body already is. + /// + internal sealed class PinnedSkillRedirect : DelegatingAIFunction + { + /// The parameter name of MAF's own load_skill delegate. + private const string SkillNameArgument = "skillName"; + + private readonly IReadOnlySet _pinned; + + /// Creates the wrapper. + /// MAF's load_skill tool. + /// The names this agent pins. + /// An argument is . + internal PinnedSkillRedirect(AIFunction inner, IReadOnlySet pinned) + : base(inner) + { + ArgumentNullException.ThrowIfNull(pinned); + _pinned = pinned; + } + + /// + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(arguments); + + if (ReadName(arguments) is { } name && _pinned.Contains(name)) + { + return ValueTask.FromResult( + $"The '{name}' skill is already in your instructions, inside . 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, + }; + } + } +} diff --git a/src/AgentCore.Application/Skills/ReadOnlySkillsProvider.cs b/src/AgentCore.Application/Skills/ReadOnlySkillsProvider.cs index c94d8320..ca4b1057 100644 --- a/src/AgentCore.Application/Skills/ReadOnlySkillsProvider.cs +++ b/src/AgentCore.Application/Skills/ReadOnlySkillsProvider.cs @@ -1,21 +1,28 @@ using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; namespace AgentCore.Application.Skills { /// - /// One agent's skills provider, with the script tool removed. + /// One agent's skills provider, with the script tool removed and load_skill pointing a + /// pinned name back at the instructions. /// internal sealed class ReadOnlySkillsProvider : AIContextProvider, IDisposable { private readonly AgentSkillsProvider _inner; + private readonly IReadOnlySet _pinned; + /// Creates the wrapper, taking ownership of the provider it wraps. /// The provider whose tools are filtered. - /// is . - internal ReadOnlySkillsProvider(AgentSkillsProvider inner) + /// The names this agent pins, which load_skill answers without loading. + /// An argument is . + internal ReadOnlySkillsProvider(AgentSkillsProvider inner, IReadOnlySet pinned) { ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(pinned); _inner = inner; + _pinned = pinned; } /// @@ -28,6 +35,7 @@ protected override async ValueTask 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; @@ -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; + } } } diff --git a/src/AgentCore.Application/Skills/SkillsProviderFactory.cs b/src/AgentCore.Application/Skills/SkillsProviderFactory.cs index 89535573..678801ba 100644 --- a/src/AgentCore.Application/Skills/SkillsProviderFactory.cs +++ b/src/AgentCore.Application/Skills/SkillsProviderFactory.cs @@ -32,16 +32,19 @@ This host runs no skill scripts. Ignore any scripts a skill lists. /// Builds the provider one agent binds. /// The skills the host bound, shared by every agent. /// The names this agent may load. + /// The names this agent pins, which load_skill answers by pointing at the instructions. /// Where the provider writes its diagnostics, or . /// The provider to hang on that agent. /// A required argument is . internal static AIContextProvider Create( SkillCatalog catalog, IReadOnlyList skills, + IReadOnlyList pinned, ILoggerFactory? loggers) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(skills); + ArgumentNullException.ThrowIfNull(pinned); HashSet allowed = new(skills, StringComparer.Ordinal); @@ -64,7 +67,8 @@ internal static AIContextProvider Create( loggers), options, loggers, - ownsSource: false)); + ownsSource: false), + new HashSet(pinned, StringComparer.Ordinal)); } } } diff --git a/tests/AgentCore.Application.Tests/Skills/ReadOnlySkillsProviderTests.cs b/tests/AgentCore.Application.Tests/Skills/ReadOnlySkillsProviderTests.cs index 0dc384fc..02907a29 100644 --- a/tests/AgentCore.Application.Tests/Skills/ReadOnlySkillsProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Skills/ReadOnlySkillsProviderTests.cs @@ -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()); using SequencedChatClient client = new("hello there."); ChatClientAgent agent = new(client, new ChatClientAgentOptions { Name = "support" }); @@ -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()); using SequencedChatClient client = new("hello there."); ChatClientAgent agent = new(client, new ChatClientAgentOptions { Name = "support" }); diff --git a/tests/AgentCore.Application.Tests/Skills/SkillsProviderFactoryTests.cs b/tests/AgentCore.Application.Tests/Skills/SkillsProviderFactoryTests.cs index 0039b50e..8d244460 100644 --- a/tests/AgentCore.Application.Tests/Skills/SkillsProviderFactoryTests.cs +++ b/tests/AgentCore.Application.Tests/Skills/SkillsProviderFactoryTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AgentCore.Application.Skills; using AgentCore.Application.Tests.Runtime; using AgentCore.TestSupport; @@ -9,7 +10,8 @@ namespace AgentCore.Application.Tests.Skills { /// /// 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. /// public sealed class SkillsProviderFactoryTests { @@ -58,8 +60,8 @@ public async Task Create_TwoAgentsOverOneSource_BothKeepWorkingAfterOneIsDispose using CachingAgentSkillsSource shared = new(source); SkillCatalog catalog = new(shared, new HashSet(["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(); @@ -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 . 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); + } + + /// + /// Calls load_skill on an agent that may load warranty-returns and pins shipping-claims. A + /// model's arguments arrive as ; a direct caller may pass a string. + /// MAF hands its own answers back as a JSON string, so both shapes are read as text. + /// + private static async Task 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(["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().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 InvokeAsync(IReadOnlyList allowed) { using SkillFolder folder = SkillFolder.Create() @@ -78,7 +134,7 @@ private static async Task InvokeAsync(IReadOnlyList allowed) using CachingAgentSkillsSource shared = new(source); SkillCatalog catalog = new(shared, new HashSet(["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 InvokeAsync(AIContextProvider provider)