mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
29dfcbb584
* Fix FileAgentSkillsProvider accepting SkillsInstructionPrompt without {0} placeholder (#4638)
BuildSkillsInstructionPrompt validated only format-string syntax via
string.Format(template, ""), which silently accepted templates without a
{0} placeholder. The generated skills list was then dropped from the final
instructions.
Tighten validation to format with a sentinel string and verify it appears
in the output, rejecting templates that do not reference argument 0 with
an ArgumentException.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix netstandard2.0 compat and simplify prompt template validation (#4638)
- Replace string.Contains(string, StringComparison) with IndexOf for
netstandard2.0/net472 compatibility
- Remove sentinel round-trip check; validate {0} directly on the raw
template string using IndexOf
- Add positive test verifying custom SkillsInstructionPrompt with {0}
is accepted and applied to output
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
267 lines
11 KiB
C#
267 lines
11 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="FileAgentSkillsProvider"/> class.
|
|
/// </summary>
|
|
public sealed class FileAgentSkillsProviderTests : IDisposable
|
|
{
|
|
private readonly string _testRoot;
|
|
private readonly TestAIAgent _agent = new();
|
|
|
|
public FileAgentSkillsProviderTests()
|
|
{
|
|
this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(this._testRoot);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(this._testRoot))
|
|
{
|
|
Directory.Delete(this._testRoot, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync()
|
|
{
|
|
// Arrange
|
|
var provider = new FileAgentSkillsProvider(this._testRoot);
|
|
var inputContext = new AIContext { Instructions = "Original instructions" };
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.Equal("Original instructions", result.Instructions);
|
|
Assert.Null(result.Tools);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body.");
|
|
var provider = new FileAgentSkillsProvider(this._testRoot);
|
|
var inputContext = new AIContext { Instructions = "Base instructions" };
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("Base instructions", result.Instructions);
|
|
Assert.Contains("provider-skill", result.Instructions);
|
|
Assert.Contains("Provider skill test", result.Instructions);
|
|
|
|
// Should have load_skill and read_skill_resource tools
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("load_skill", toolNames);
|
|
Assert.Contains("read_skill_resource", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("null-instr-skill", "Null instruction test", "Body.");
|
|
var provider = new FileAgentSkillsProvider(this._testRoot);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("null-instr-skill", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body.");
|
|
var options = new FileAgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "Custom template: {0}"
|
|
};
|
|
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.StartsWith("Custom template:", result.Instructions);
|
|
Assert.Contains("custom-prompt-skill", result.Instructions);
|
|
Assert.Contains("Custom prompt", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_InvalidPromptTemplate_ThrowsArgumentException()
|
|
{
|
|
// Arrange — template with unescaped braces and no valid {0} placeholder
|
|
var options = new FileAgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "Bad template with {unescaped} braces"
|
|
};
|
|
|
|
// Act & Assert
|
|
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
|
Assert.Contains("SkillsInstructionPrompt", ex.Message);
|
|
Assert.Equal("options", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException()
|
|
{
|
|
// Arrange -- valid format string but missing the required placeholder
|
|
var options = new FileAgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "No placeholder here"
|
|
};
|
|
|
|
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
|
Assert.Contains("{0}", ex.Message);
|
|
Assert.Equal("options", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync()
|
|
{
|
|
// Arrange — valid custom template with {0} placeholder
|
|
this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body.");
|
|
var options = new FileAgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "== Skills ==\n{0}\n== End =="
|
|
};
|
|
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — the custom template wraps the skill list
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.StartsWith("== Skills ==", result.Instructions);
|
|
Assert.Contains("custom-tpl-skill", result.Instructions);
|
|
Assert.Contains("== End ==", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
|
|
{
|
|
// Arrange — description with XML-sensitive characters
|
|
string skillDir = Path.Combine(this._testRoot, "xml-skill");
|
|
Directory.CreateDirectory(skillDir);
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
"---\nname: xml-skill\ndescription: Uses <tags> & \"quotes\"\n---\nBody.");
|
|
var provider = new FileAgentSkillsProvider(this._testRoot);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("<tags>", result.Instructions);
|
|
Assert.Contains("&", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync()
|
|
{
|
|
// Arrange
|
|
string dir1 = Path.Combine(this._testRoot, "dir1");
|
|
string dir2 = Path.Combine(this._testRoot, "dir2");
|
|
CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
|
|
CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
|
|
|
|
// Act
|
|
var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 });
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Assert
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("skill-a", result.Instructions);
|
|
Assert.Contains("skill-b", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("tools-skill", "Tools test", "Body.");
|
|
var provider = new FileAgentSkillsProvider(this._testRoot);
|
|
|
|
var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool.");
|
|
var inputContext = new AIContext { Tools = new[] { existingTool } };
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — existing tool should be preserved alongside the new skill tools
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("existing_tool", toolNames);
|
|
Assert.Contains("load_skill", toolNames);
|
|
Assert.Contains("read_skill_resource", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync()
|
|
{
|
|
// Arrange — create skills in reverse alphabetical order
|
|
this.CreateSkill("zulu-skill", "Zulu skill", "Body Z.");
|
|
this.CreateSkill("alpha-skill", "Alpha skill", "Body A.");
|
|
this.CreateSkill("mike-skill", "Mike skill", "Body M.");
|
|
var provider = new FileAgentSkillsProvider(this._testRoot);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — skills should appear in alphabetical order in the prompt
|
|
Assert.NotNull(result.Instructions);
|
|
int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal);
|
|
int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal);
|
|
int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal);
|
|
Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill");
|
|
Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill");
|
|
}
|
|
|
|
private void CreateSkill(string name, string description, string body)
|
|
{
|
|
CreateSkillIn(this._testRoot, name, description, body);
|
|
}
|
|
|
|
private static void CreateSkillIn(string root, string name, string description, string body)
|
|
{
|
|
string skillDir = Path.Combine(root, name);
|
|
Directory.CreateDirectory(skillDir);
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
|
}
|
|
}
|