diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs index 8e9b5f6faea..aed703f0887 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -7,7 +7,12 @@ // 3. Read resources — reference files read via read_skill_resource tool // 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor // -// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms. +// This sample uses two skills: +// - unit-converter: converts between miles, kilometers, pounds, and kilograms. +// - temperature-converter: marked with 'advertise: false' in its frontmatter, so it is NOT listed +// in the system prompt. It remains loadable by name via the load_skill tool, which the agent's +// own instructions point it to. Use this to keep the system prompt small or to reserve skills +// for explicit invocation. using Azure.AI.Projects; using Azure.Identity; @@ -35,7 +40,9 @@ ChatOptions = new() { ModelId = deploymentName, - Instructions = "You are a helpful assistant that can convert units.", + // The temperature-converter skill is not advertised (advertise: false), so the model + // only knows about it because these instructions reference it by name. + Instructions = "You are a helpful assistant that can convert units. For temperature conversions, load the 'temperature-converter' skill.", }, AIContextProviders = [skillsProvider], }) @@ -58,3 +65,14 @@ "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); Console.WriteLine($"Agent: {response.Text}"); + +// --- Example: Unadvertised skill --- +// The temperature-converter skill is excluded from the system prompt's skill listing +// (advertise: false), but the agent can still load it because its instructions mention it. +Console.WriteLine(); +Console.WriteLine("Converting temperatures with an unadvertised skill"); +Console.WriteLine(new string('-', 60)); + +response = await agent.RunAsync("What is 100 degrees Fahrenheit in Celsius?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md index 0c5b7f8416a..25dff26cd20 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -8,6 +8,7 @@ This sample demonstrates how to use **file-based Agent Skills** with a `ChatClie - The progressive disclosure pattern: advertise → load → read resources → run scripts - Using the `AgentSkillsProvider` constructor with a skill directory path and script runner - Running file-based scripts (Python) via a subprocess-based executor +- Opting a skill out of the system prompt listing with `advertise: false` in its frontmatter ## Skills Included @@ -18,6 +19,24 @@ Converts between common units (miles↔km, pounds↔kg) using a multiplication f - `references/conversion-table.md` — Conversion factor table - `scripts/convert.py` — Python script that performs the conversion +### temperature-converter + +Converts between Celsius and Fahrenheit. Its frontmatter sets `advertise: false`, so its name and +description are **not** auto-listed in the system prompt: + +```yaml +--- +name: temperature-converter +description: Convert between Celsius and Fahrenheit temperatures. +advertise: false +--- +``` + +The skill can still be loaded by name via the `load_skill` tool — in this sample the agent's +instructions reference it explicitly. Use this to keep the system prompt small, or for skills that +should only be used when the developer (or another skill) points the model at them. Omitting +`advertise` defaults to `true`. + ## Running the Sample ### Prerequisites @@ -48,4 +67,8 @@ Agent: Here are your conversions: 1. **26.2 miles → 42.16 km** (a marathon distance) 2. **75 kg → 165.35 lbs** + +Converting temperatures with an unadvertised skill +------------------------------------------------------------ +Agent: 100°F is approximately **37.78°C**. ``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/temperature-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/temperature-converter/SKILL.md new file mode 100644 index 00000000000..1459cd802ab --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/temperature-converter/SKILL.md @@ -0,0 +1,12 @@ +--- +name: temperature-converter +description: Convert between Celsius and Fahrenheit temperatures. +advertise: false +--- + +## Usage + +When the user requests a temperature conversion: +1. Celsius to Fahrenheit: multiply by 9/5, then add 32 +2. Fahrenheit to Celsius: subtract 32, then multiply by 5/9 +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 13232c5ab28..96bff539523 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -4,7 +4,7 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w | Sample | Description | |--------|-------------| -| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | +| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill, plus an unadvertised (`advertise: false`) temperature-converter skill. | | [Agent_Step02_CodeDefinedSkills](Agent_Step02_CodeDefinedSkills/) | Define skills entirely in C# code using `AgentInlineSkill`, with static/dynamic resources and scripts. | | [Agent_Step03_ClassBasedSkills](Agent_Step03_ClassBasedSkills/) | Define skills as C# classes using `AgentClassSkill`. | | [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) | **(Advanced)** Combine file-based, code-defined, and class-based skills using `AgentSkillsProviderBuilder`. | @@ -25,6 +25,15 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w | Sharing pattern | Copy skill directory | Inline or shared instances | Package in shared assemblies/NuGet | | DI support | No | Yes (via `IServiceProvider` parameter) | Yes (via `IServiceProvider` parameter) | +### Controlling skill advertisement + +By default, every skill's name and description are listed in the system prompt so the model can +discover it. Set `advertise: false` in a skill's frontmatter (or `Advertise = false` on +`AgentSkillFrontmatter` for code-defined skills) to exclude it from that listing while keeping it +loadable by name via the `load_skill` tool. This is useful for skills that should only be used when +referenced explicitly — e.g. from the agent's instructions or from another skill. To remove a skill +entirely (tools included), use skill filtering instead (see below). + ### `AgentSkillsProvider` vs `AgentSkillsProviderBuilder` For single-source scenarios, use the `AgentSkillsProvider` constructors directly — they accept a skill directory path, a set of skills, or a custom source. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs index 136d4d6833a..0ce9f3e1950 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -109,6 +109,18 @@ public string? Compatibility /// public string? AllowedTools { get; set; } + /// + /// Gets or sets a value indicating whether the skill's name and description are automatically + /// advertised in the system prompt. Defaults to . + /// + /// + /// When , the skill is omitted from the generated skills listing but remains + /// fully functional: it can still be loaded by name via the skill tools. Use this for skills that + /// should only be used when referenced explicitly (for example, from the agent's own instructions). + /// Corresponds to the advertise frontmatter key in SKILL.md files. + /// + public bool Advertise { get; set; } = true; + /// /// Gets or sets the arbitrary key-value metadata for this skill. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index 32750f1b8d9..8b60f8b417a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -318,7 +318,11 @@ private AIFunction WrapWithApprovalIfRequired(AIFunction function, bool requireA string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; var sb = new StringBuilder(); - foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + + // Skills with Advertise = false are omitted from the listing but remain loadable by name via the skill tools. + foreach (var skill in skills + .Where(s => s.Frontmatter.Advertise) + .OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) { sb.AppendLine(" "); sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index ae1714f237d..bcd5e941b57 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -221,6 +221,7 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW string? license = null; string? compatibility = null; string? allowedTools = null; + string? advertise = null; foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) { @@ -249,6 +250,10 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW { allowedTools = value; } + else if (string.Equals(key, "advertise", StringComparison.OrdinalIgnoreCase)) + { + advertise = value; + } } // Parse metadata block (indented key-value pairs under "metadata:"). @@ -274,6 +279,8 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW { License = license, AllowedTools = allowedTools, + // Anything other than an explicit boolean "false" keeps the default of true. + Advertise = !bool.TryParse(advertise, out bool advertiseValue) || advertiseValue, Metadata = metadata, }; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index ed79909f9fc..33793f85b15 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -93,6 +93,33 @@ public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync( Assert.Contains("null-instr-skill", result.Instructions); } + [Fact] + public async Task InvokingCoreAsync_NonAdvertisedSkill_ExcludedFromInstructionsButLoadableAsync() + { + // Arrange + this.CreateSkill("visible-skill", "Visible skill description", "Visible body."); + string hiddenSkillDir = Path.Combine(this._testRoot, "unadvertised-skill"); + Directory.CreateDirectory(hiddenSkillDir); + await File.WriteAllTextAsync( + Path.Combine(hiddenSkillDir, "SKILL.md"), + "---\nname: unadvertised-skill\ndescription: Unadvertised skill description\nadvertise: false\n---\nUnadvertised body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — the non-advertised skill is not listed in the instructions + Assert.NotNull(result.Instructions); + Assert.Contains("visible-skill", result.Instructions); + Assert.DoesNotContain("unadvertised-skill", result.Instructions); + + // But it can still be loaded by name via the load_skill tool + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "unadvertised-skill" })); + Assert.Contains("Unadvertised body.", content!.ToString()); + } + [Fact] public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index b185002f67f..fc3cd973aa1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -856,6 +856,42 @@ public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync() Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools); } + [Fact] + public async Task GetSkillsAsync_AdvertiseFalse_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "unadvertised-skill", + "---\nname: unadvertised-skill\ndescription: A skill that is not advertised\nadvertise: false\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Single(skills); + Assert.False(skills[0].Frontmatter.Advertise); + } + + [Theory] + [InlineData("advertise: true")] + [InlineData("advertise: not-a-boolean")] + public async Task GetSkillsAsync_AdvertiseTrueOrInvalid_DefaultsToTrueAsync(string advertiseLine) + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "advertised-skill", + $"---\nname: advertised-skill\ndescription: An advertised skill\n{advertiseLine}\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Single(skills); + Assert.True(skills[0].Frontmatter.Advertise); + } + [Fact] public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync() { @@ -906,6 +942,7 @@ public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() "license: Apache-2.0", "compatibility: Requires Python 3.10+", "allowed-tools: grep glob view", + "advertise: false", "metadata:", " org: contoso", " tier: premium", @@ -925,13 +962,14 @@ public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() Assert.Equal("Apache-2.0", fm.License); Assert.Equal("Requires Python 3.10+", fm.Compatibility); Assert.Equal("grep glob view", fm.AllowedTools); + Assert.False(fm.Advertise); Assert.NotNull(fm.Metadata); Assert.Equal("contoso", fm.Metadata!["org"]?.ToString()); Assert.Equal("premium", fm.Metadata!["tier"]?.ToString()); } [Fact] - public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() + public async Task GetSkillsAsync_NoOptionalFields_UsesDefaultValuesAsync() { // Arrange _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body."); @@ -946,6 +984,7 @@ public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() Assert.Null(fm.License); Assert.Null(fm.Compatibility); Assert.Null(fm.AllowedTools); + Assert.True(fm.Advertise); Assert.Null(fm.Metadata); }