Skip to content
Open
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 @@ -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;
Expand Down Expand Up @@ -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],
})
Expand All @@ -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}");
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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**.
```
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion dotnet/samples/02-agents/AgentSkills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand All @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ public string? Compatibility
/// </summary>
public string? AllowedTools { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the skill's name and description are automatically
/// advertised in the system prompt. Defaults to <see langword="true"/>.
/// </summary>
/// <remarks>
/// When <see langword="false"/>, 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 <c>advertise</c> frontmatter key in SKILL.md files.
/// </remarks>
public bool Advertise { get; set; } = true;

/// <summary>
/// Gets or sets the arbitrary key-value metadata for this skill.
/// </summary>
Expand Down
6 changes: 5 additions & 1 deletion dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(" <skill>");
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down Expand Up @@ -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:").
Expand All @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?> { ["skillName"] = "unadvertised-skill" }));
Assert.Contains("Unadvertised body.", content!.ToString());
}

[Fact]
public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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",
Expand All @@ -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.");
Expand All @@ -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);
Comment thread
ron-clover marked this conversation as resolved.
Assert.Null(fm.Metadata);
}

Expand Down