Skip to content
Merged
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
17 changes: 10 additions & 7 deletions dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,19 @@ internal AgentFileSkill(

/// <inheritdoc/>
/// <remarks>
/// Returns the raw SKILL.md content. When the skill has scripts, a
/// <c>&lt;script_schemas&gt;</c> block is appended describing the argument format.
/// The result is cached after the first access.
/// Returns the raw SKILL.md content with an <c>&lt;available_resources&gt;</c> and an
/// <c>&lt;available_scripts&gt;</c> block appended, so the model gets an authoritative list for each
/// category. A category with no entries is appended as a self-closing element (e.g.
/// <c>&lt;available_scripts /&gt;</c>) so the model knows none are available and does not hallucinate
/// their names. The result is cached after the first access.
/// </remarks>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
var content = this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
: this._originalContent;
return new(content);
this._content ??=
this._originalContent
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(this._resources)
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(this._scripts);
return new(this._content);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ protected AgentClassSkill(Func<JsonElement?, AIFunctionArguments>? argumentMarsh
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts));
}

Expand Down Expand Up @@ -160,8 +161,9 @@ protected AgentClassSkill(Func<JsonElement?, AIFunctionArguments>? argumentMarsh
/// Override this property in derived classes to provide skill-specific resources.
/// </para>
/// <para>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
/// Resources are listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the LLM
/// knows which ones can be accessed. When empty, a self-closing element is emitted to prevent
/// hallucinated resource calls.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
Expand All @@ -178,8 +180,9 @@ protected AgentClassSkill(Func<JsonElement?, AIFunctionArguments>? argumentMarsh
/// Override this property in derived classes to provide skill-specific scripts.
/// </para>
/// <para>
/// Only script parameter schemas are included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
/// Scripts are listed in the <c>&lt;available_scripts&gt;</c> block of the skill body so the LLM
/// knows which ones can be called. When empty, a self-closing element is emitted to prevent
/// hallucinated script calls.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
Expand All @@ -202,8 +205,9 @@ protected AgentClassSkill(Func<JsonElement?, AIFunctionArguments>? argumentMarsh
/// Creates a skill resource backed by a static value.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the LLM
/// knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
Expand All @@ -216,8 +220,9 @@ protected AgentSkillResource CreateResource(string name, object value, string? d
/// Creates a skill resource backed by a delegate that produces a dynamic value.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the LLM
/// knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
Expand All @@ -234,8 +239,9 @@ protected AgentSkillResource CreateResource(string name, Delegate method, string
/// Creates a skill script backed by a delegate.
/// </summary>
/// <remarks>
/// Only the script's parameter schema is included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
/// The script is listed in the <c>&lt;available_scripts&gt;</c> block of the skill body so the LLM
/// knows it can be called. When no scripts are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated script calls.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public AgentInlineSkill(
/// <inheritdoc/>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
}

/// <inheritdoc/>
Expand All @@ -128,8 +128,9 @@ public override ValueTask<string> GetContentAsync(CancellationToken cancellation
/// Registers a static resource with this skill.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the
/// LLM knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
Expand All @@ -146,8 +147,9 @@ public AgentInlineSkill AddResource(string name, object value, string? descripti
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the
/// LLM knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
Expand All @@ -168,8 +170,9 @@ public AgentInlineSkill AddResource(string name, Delegate method, string? descri
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// Only the script's parameter schema is included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
/// The script is listed in the <c>&lt;available_scripts&gt;</c> block of the skill body so the
/// LLM knows it can be called. When no scripts are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated script calls.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ namespace Microsoft.Agents.AI;
internal static class AgentInlineSkillContentBuilder
{
/// <summary>
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
/// Builds the complete skill content containing name, description, instructions, resources, and script parameter schemas.
/// </summary>
/// <param name="name">The skill name.</param>
/// <param name="description">The skill description.</param>
/// <param name="instructions">The raw instructions text.</param>
/// <param name="resources">Optional resources associated with the skill.</param>
/// <param name="scripts">Optional scripts associated with the skill.</param>
/// <returns>An XML-structured content string.</returns>
public static string Build(
string name,
string description,
string instructions,
IReadOnlyList<AgentSkillResource>? resources,
IReadOnlyList<AgentSkillScript>? scripts)
{
_ = Throw.IfNullOrWhitespace(name);
Expand All @@ -37,50 +39,89 @@ public static string Build(
.Append(EscapeXmlString(instructions))
.Append("\n</instructions>");

if (scripts is { Count: > 0 })
sb.Append('\n');
sb.Append(BuildAvailableResourcesBlock(resources ?? []));
sb.Append('\n');
sb.Append(BuildAvailableScriptsBlock(scripts ?? []));

return sb.ToString();
}

/// <summary>
/// Builds an <c>&lt;available_resources&gt;...&lt;/available_resources&gt;</c> XML block for the given resources.
/// Each resource is emitted as a self-closing <c>&lt;resource name="..."/&gt;</c> element. When the list is empty,
/// a self-closing <c>&lt;available_resources /&gt;</c> element is returned. This block lets the model know which
/// resources can be read (or that there are none) so it does not hallucinate resource names.
/// </summary>
/// <param name="resources">The resources to include in the block.</param>
/// <returns>
/// An XML string starting with <c>\n&lt;available_resources&gt;</c>, or <c>\n&lt;available_resources /&gt;</c> if the list is empty.
/// </returns>
public static string BuildAvailableResourcesBlock(IReadOnlyList<AgentSkillResource> resources)
{
_ = Throw.IfNull(resources);

if (resources.Count == 0)
{
sb.Append('\n');
sb.Append(BuildScriptSchemasBlock(scripts));
// Emit an empty element so the model knows no resources are available and does not hallucinate resource names.
return "\n<available_resources />";
}

var sb = new StringBuilder();
sb.Append("\n<available_resources>\n");

foreach (var resource in resources)
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
}

sb.Append("</available_resources>");

return sb.ToString();
}

/// <summary>
/// Builds a <c>&lt;script_schemas&gt;...&lt;/script_schemas&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;schema script="..."&gt;</c> element containing only
/// the parameter schema. This block serves as a reference for the model to know how to
/// format arguments when calling scripts, not as a discovery mechanism.
/// Builds an <c>&lt;available_scripts&gt;...&lt;/available_scripts&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;script name="..."&gt;</c> element; when the script has a
/// parameter schema it is wrapped in a nested <c>&lt;parameters_schema&gt;</c> element, otherwise a
/// self-closing <c>&lt;script&gt;</c> element is used. When the list is empty, a self-closing
/// <c>&lt;available_scripts /&gt;</c> element is returned. This block lets the model know which scripts
/// can be called and how to format their arguments (or that there are none) so it does not hallucinate script names.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <returns>An XML string starting with <c>\n&lt;script_schemas&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
/// <returns>
/// An XML string starting with <c>\n&lt;available_scripts&gt;</c>, or <c>\n&lt;available_scripts /&gt;</c> if the list is empty.
/// </returns>
public static string BuildAvailableScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
{
_ = Throw.IfNull(scripts);

if (scripts.Count == 0)
{
return string.Empty;
// Emit an empty element so the model knows no scripts are available and does not hallucinate script names.
return "\n<available_scripts />";
}

var sb = new StringBuilder();
sb.Append("\n<script_schemas>\n");
sb.Append("\n<available_scripts>\n");

foreach (var script in scripts)
{
var parametersSchema = script.ParametersSchema;

if (parametersSchema is null)
{
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\">\n");
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
sb.Append(" </script>\n");
}
}

sb.Append("</script_schemas>");
sb.Append("</available_scripts>");

return sb.ToString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,9 +382,10 @@ public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers
// Arrange
var skill = new AttributedFullSkill();

// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
// Act & Assert — Content includes resources in body; scripts are in available_scripts
Assert.Contains("<available_resources>", await skill.GetContentAsync());
Assert.Contains("conversion-table", await skill.GetContentAsync());
Assert.Contains("<available_scripts>", await skill.GetContentAsync());
Assert.Contains("convert", await skill.GetContentAsync());

// Act & Assert — discovered members are cached
Expand Down Expand Up @@ -502,16 +503,18 @@ public async Task SerializerOptions_UsedForReflectedMembersAsync()
}

[Fact]
public async Task Content_DoesNotRenderResources_InBodyAsync()
public async Task Content_RendersResources_InBodyAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();

// Act
var content = await skill.GetContentAsync();

// Assert — resources are no longer rendered in body content
Assert.DoesNotContain("<resources>", content);
// Assert — resources are rendered in body content by name; descriptions are not emitted
Assert.Contains("<available_resources>", content);
Assert.Contains("ref-data", content);
Assert.DoesNotContain("Some important data.", content);
}

[Fact]
Expand Down
Loading
Loading