From 29dfcbb58449632d9b6401906628683ae0dee0ba Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:18:39 +0900 Subject: [PATCH] .NET: Validate SkillsInstructionPrompt contains {0} placeholder in FileAgentSkillsProvider (#4642) * 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 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Skills/FileAgentSkillsProvider.cs | 12 +++++-- .../FileAgentSkillsProviderTests.cs | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs index cd64cdc723..460faced70 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs @@ -175,15 +175,23 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider try { _ = string.Format(optionsInstructions, string.Empty); - promptTemplate = optionsInstructions; } catch (FormatException ex) { throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').", + "The provided SkillsInstructionPrompt is not a valid format string.", nameof(options), ex); } + + if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + "The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.", + nameof(options)); + } + + promptTemplate = optionsInstructions; } if (skills.Count == 0) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs index 92dc5a5418..5da49525d4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs @@ -127,6 +127,42 @@ public sealed class FileAgentSkillsProviderTests : IDisposable 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(() => 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() {