.NET: [Breaking] Refactor AgentFileSkillsSource for depth-based discovery and predicate filters (#6109)

* Refactor AgentFileSkillsSource to use filter predicates and add AgentFileSkillFilterContext

- Replace hardcoded script/resource directory lists with configurable ScriptFilter and ResourceFilter predicates
- Add AgentFileSkillFilterContext class to provide contextual file information to filter predicates
- Replace MaxSearchDepth constant with configurable SearchDepth option
- Update AgentFileSkillsSourceOptions with new filter and search depth properties
- Update tests to reflect the new filtering approach

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Log '(none)' instead of empty string for missing file extensions in debug output

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
semenshi-m
2026-05-28 19:14:57 +01:00
committed by GitHub
Unverified
parent 945647a065
commit 3ee1bb4f9f
5 changed files with 417 additions and 408 deletions
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides contextual information about a discovered file to the
/// <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> and
/// <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicates.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentFileSkillFilterContext
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillFilterContext"/> class.
/// </summary>
/// <param name="skillName">The name of the skill (from SKILL.md frontmatter).</param>
/// <param name="relativeFilePath">
/// The path to the script or resource file relative to the skill directory (using forward slashes).
/// </param>
internal AgentFileSkillFilterContext(string skillName, string relativeFilePath)
{
this.SkillName = Throw.IfNullOrWhitespace(skillName);
this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath);
}
/// <summary>
/// Gets the name of the skill as declared in the SKILL.md frontmatter.
/// </summary>
/// <example><c>unit-converter</c></example>
public string SkillName { get; }
/// <summary>
/// Gets the path to the script or resource file relative to the skill directory (using forward slashes).
/// For root-level files this is just the filename; for nested files it includes the subdirectory.
/// </summary>
/// <example>
/// <c>run.py</c> for a script at skill root,
/// <c>scripts/convert.js</c> for a nested script, or
/// <c>references/guide.md</c> for a nested resource.
/// </example>
public string RelativeFilePath { get; }
}
@@ -30,18 +30,12 @@ namespace Microsoft.Agents.AI;
internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
// "." means the skill directory root itself (no subdirectory descent constraint)
private const string RootDirectoryIndicator = ".";
private const int DefaultSearchDepth = 2;
private const int MaxSkillDirectorySearchDepth = 2;
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Standard subdirectory names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptDirectories = ["scripts"];
private static readonly string[] s_defaultResourceDirectories = ["references", "assets"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
@@ -63,8 +57,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private readonly IEnumerable<string> _skillPaths;
private readonly HashSet<string> _allowedResourceExtensions;
private readonly HashSet<string> _allowedScriptExtensions;
private readonly IReadOnlyList<string> _scriptDirectories;
private readonly IReadOnlyList<string> _resourceDirectories;
private readonly int _searchDepth;
private readonly Func<AgentFileSkillFilterContext, bool>? _scriptFilter;
private readonly Func<AgentFileSkillFilterContext, bool>? _resourceFilter;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
@@ -111,13 +106,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
this._scriptDirectories = options?.ScriptDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)]
: s_defaultScriptDirectories;
this._resourceDirectories = options?.ResourceDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)]
: s_defaultResourceDirectories;
this._searchDepth = Throw.IfLessThan(options?.SearchDepth ?? DefaultSearchDepth, 1);
this._scriptFilter = options?.ScriptFilter;
this._resourceFilter = options?.ResourceFilter;
this._scriptRunner = scriptRunner;
}
@@ -174,7 +165,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
results.Add(Path.GetFullPath(directory));
}
if (currentDepth >= MaxSearchDepth)
if (currentDepth >= MaxSkillDirectorySearchDepth)
{
return;
}
@@ -305,216 +296,246 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans configured resource directories within a skill directory for resource files matching the configured extensions.
/// Scans the skill directory recursively (up to the configured search depth) for resource files
/// matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// If a <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicate is configured, files
/// that do not satisfy it are excluded.
/// </remarks>
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
var resources = new List<AgentFileSkillResource>();
foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
continue;
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension);
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
}
this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1);
return resources;
}
private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillResource> resources, int currentDepth)
{
if (currentDepth > this._searchDepth)
{
return;
}
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
}
return;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
string fileName = Path.GetFileName(filePath);
// Exclude SKILL.md itself
if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), string.IsNullOrEmpty(extension) ? "(none)" : extension);
}
continue;
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the skill directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
// Apply user-provided filter predicate
if (this._resourceFilter is not null && !this._resourceFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
{
continue;
}
resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
{
this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1);
}
}
}
/// <summary>
/// Scans configured script directories within a skill directory for script files matching the configured extensions.
/// Scans the skill directory recursively (up to the configured search depth) for script files
/// matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// If a <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> predicate is configured, files
/// that do not satisfy it are excluded.
/// </remarks>
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
var scripts = new List<AgentFileSkillScript>();
foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
this.ScanDirectoryForScripts(skillDirectoryFullPath, skillDirectoryFullPath, skillName, scripts, currentDepth: 1);
return scripts;
}
private void ScanDirectoryForScripts(string targetDirectory, string skillDirectoryFullPath, string skillName, List<AgentFileSkillScript> scripts, int currentDepth)
{
if (currentDepth > this._searchDepth)
{
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
return;
}
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
if (!Directory.Exists(targetDirectory))
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory));
}
return;
}
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
{
continue;
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the skill directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip
if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
#if NET
var enumerationOptions = new EnumerationOptions
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
#endif
{
// Filter by extension
string extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension))
if (this._logger.IsEnabled(LogLevel.Warning))
{
continue;
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
// Normalize the enumerated path to guard against non-canonical forms.
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, targetDirectory))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath));
}
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
continue;
}
// Compute relative path and normalize separators.
// e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
// Apply user-provided filter predicate
if (this._scriptFilter is not null && !this._scriptFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
{
continue;
}
scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
}
return scripts;
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
{
this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1);
}
}
}
/// <summary>
@@ -542,6 +563,31 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
return false;
}
#if !NET
/// <summary>
/// Best-effort directory enumeration for target frameworks without
/// <c>EnumerationOptions.IgnoreInaccessible</c> support. Returns an empty
/// array when the caller lacks permission to read the directory contents,
/// so a single inaccessible child does not abort the entire skill scan.
/// </summary>
private string[] SafeEnumerateDirectories(string path)
{
try
{
return Directory.GetDirectories(path);
}
catch (UnauthorizedAccessException)
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path));
}
return Array.Empty<string>();
}
}
#endif
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
{
string value = kvMatch.Groups[3].Value;
@@ -664,46 +710,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
}
private static IEnumerable<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> directories, ILogger logger)
{
foreach (string directory in directories)
{
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories));
}
// "." is valid — it means the skill root directory.
if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal))
{
yield return directory;
continue;
}
// Reject absolute paths and any path segments that escape upward.
if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory))
{
LogDirectoryNameSkippedInvalid(logger, directory);
continue;
}
yield return NormalizePath(directory);
}
}
private static bool ContainsParentTraversalSegment(string directory)
{
foreach (string segment in directory.Split('/', '\\'))
{
if (segment == "..")
{
return true;
}
}
return false;
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
@@ -743,6 +749,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")]
private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")]
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
@@ -32,28 +33,31 @@ public sealed class AgentFileSkillsSourceOptions
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
/// <summary>
/// Gets or sets relative directory paths to scan for script files within each skill directory.
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
/// rejected.
/// When <see langword="null"/>, defaults to <c>scripts</c> (per the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// Gets or sets the maximum depth to search for script and resource files within each skill directory.
/// A value of <c>1</c> searches only the skill root directory. A value of <c>2</c> searches the root
/// and one level of subdirectories.
/// When <see langword="null"/>, the source uses the default depth of <c>2</c>.
/// </summary>
public IEnumerable<string>? ScriptDirectories { get; set; }
/// <remarks>
/// Must be greater than or equal to <c>1</c>; lower values are rejected by the constructor.
/// </remarks>
public int? SearchDepth { get; set; }
/// <summary>
/// Gets or sets relative directory paths to scan for resource files within each skill directory.
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
/// rejected.
/// When <see langword="null"/>, defaults to <c>references</c> and <c>assets</c> (per the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// Gets or sets a predicate that filters discovered script files.
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
/// and the file's path relative to the skill directory.
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
/// When <see langword="null"/>, all scripts matching the allowed extensions are included.
/// </summary>
public IEnumerable<string>? ResourceDirectories { get; set; }
public Func<AgentFileSkillFilterContext, bool>? ScriptFilter { get; set; }
/// <summary>
/// Gets or sets a predicate that filters discovered resource files.
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
/// and the file's path relative to the skill directory.
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
/// When <see langword="null"/>, all resources matching the allowed extensions are included.
/// </summary>
public Func<AgentFileSkillFilterContext, bool>? ResourceFilter { get; set; }
}
@@ -111,10 +111,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
public async Task GetSkillsAsync_ScriptsInRootAndSubdirectories_AreDiscoveredByDefaultAsync()
{
// Arrange — scripts outside configured directories are not discovered; only files directly
// inside the configured directory are picked up (no subdirectory recursion)
// Arrange — with default depth=2, scripts in root and immediate subdirectories are discovered
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
@@ -123,9 +122,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
// Assert — both root and subdirectory scripts are discovered
Assert.Single(skills);
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
Assert.NotNull(await skills[0].GetScriptAsync("convert.py"));
Assert.NotNull(await skills[0].GetScriptAsync("tools/helper.sh"));
}
[Fact]
@@ -225,13 +225,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync()
public async Task GetSkillsAsync_DeepScript_DiscoveredWithHigherDepthAsync()
{
// Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3)
// Arrange — script at depth 4 (f1/f2/f3/run.py) discovered with SearchDepth=5
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] });
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
@@ -243,36 +243,25 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
}
[Theory]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("./scripts/f1", "./f2")]
public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories)
[Fact]
public async Task GetSkillsAsync_ScriptFilter_ExcludesFilteredScriptsAsync()
{
// Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix;
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
foreach (string directory in directories)
{
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')");
}
// Arrange — ScriptFilter excludes scripts in the "f2" subdirectory
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Filter test", "Body.");
CreateFile(skillDir, "scripts/run.py", "print('scripts')");
CreateFile(skillDir, "f2/run.py", "print('f2')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptDirectories = directories });
new AgentFileSkillsSourceOptions { ScriptFilter = ctx => !ctx.RelativeFilePath.StartsWith("f2/", StringComparison.OrdinalIgnoreCase) });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — scripts are discovered with names identical to using directories without "./"
// Assert — only scripts/ script is included; f2/ is excluded by filter
Assert.Single(skills);
foreach (string directory in directories)
{
string expectedName = $"{directory.Substring(2)}/run.py";
var script = await skills[0].GetScriptAsync(expectedName);
Assert.NotNull(script);
Assert.Equal(expectedName, script!.Name);
}
var script = await skills[0].GetScriptAsync("scripts/run.py");
Assert.NotNull(script);
Assert.Equal("scripts/run.py", script!.Name);
Assert.Null(await skills[0].GetScriptAsync("f2/run.py"));
}
private static string CreateSkillDir(string root, string name, string description, string body)
@@ -425,9 +425,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredByDefaultAsync()
{
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
// Arrange — resource files directly in the skill directory are discovered with default depth=2
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -440,29 +440,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
Assert.Single(skills);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
// Assert — root-level files are discovered by default (depth=2 includes root)
Assert.Single(skills);
var skill = skills[0];
Assert.Equal(2, skill.GetTestResources()!.Count);
@@ -471,9 +449,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
public void Constructor_SearchDepthBelowOne_Throws()
{
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
// Arrange / Act / Assert — SearchDepth must be >= 1
Assert.Throws<ArgumentOutOfRangeException>(() =>
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 0 }));
Assert.Throws<ArgumentOutOfRangeException>(() =>
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = -1 }));
}
[Fact]
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredByDefaultAsync()
{
// Arrange — resource in any subdirectory is discovered with default depth=2
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
string customDir = Path.Combine(skillDir, "docs");
Directory.CreateDirectory(customDir);
@@ -486,15 +477,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — non-spec directories are not scanned by default
// Assert — subdirectory files are discovered by default
Assert.Single(skills);
Assert.Empty(skills[0].GetTestResources()!);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("docs/readme.md", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
public async Task GetSkillsAsync_ResourceFilter_ExcludesFilteredFilesAsync()
{
// Arrange — custom ResourceDirectories replaces the spec defaults
// Arrange — ResourceFilter excludes files in the "docs" subdirectory
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
string customDir = Path.Combine(skillDir, "docs");
string refsDir = Path.Combine(skillDir, "references");
@@ -506,16 +498,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => !ctx.RelativeFilePath.StartsWith("docs/", StringComparison.OrdinalIgnoreCase) });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only docs/ is scanned; references/ is NOT scanned
// Assert — only references/ resource is included; docs/ is excluded by filter
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.GetTestResources()!);
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
Assert.Equal("references/ref.md", skill.GetTestResources()![0].Name);
}
[Fact]
@@ -755,9 +747,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsSymlinkedDirectoryAsync()
{
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
// Arrange — "sub" directory is a symlink pointing outside the skill directory.
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
Directory.CreateDirectory(skillDir);
@@ -783,7 +775,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
var source = new AgentFileSkillsSource(
this._testRoot,
s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
new AgentFileSkillsSourceOptions { SearchDepth = 4 });
// Act
var skills = await source.GetSkillsAsync();
@@ -957,54 +949,32 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Assert.Null(fm.Metadata);
}
[Theory]
[InlineData("..")]
[InlineData("../escape")]
[InlineData("sub/../escape")]
[InlineData("/absolute")]
[InlineData("\\absolute")]
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
[Fact]
public async Task GetSkillsAsync_SearchDepthOne_OnlyRootFilesDiscoveredAsync()
{
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
// Arrange — with SearchDepth = 1, only root-level files are discovered
string skillDir = Path.Combine(this._testRoot, "depth-one-skill");
string scriptsDir = Path.Combine(skillDir, "scripts");
Directory.CreateDirectory(scriptsDir);
File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: depth-one-skill\ndescription: Depth one\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { SearchDepth = 1 });
// Assert
Assert.NotNull(source1);
Assert.NotNull(source2);
}
// Act
var skills = await source.GetSkillsAsync();
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
{
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
}
[Theory]
[InlineData("scripts")]
[InlineData("my-scripts")]
[InlineData("sub/directory")]
[InlineData(".")]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("my..scripts")]
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
{
// Arrange & Act & Assert
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
Assert.NotNull(source);
// Assert — scripts in subdirectories are NOT discovered at depth 1
Assert.Single(skills);
Assert.Null(await skills[0].GetScriptAsync("scripts/run.py"));
}
[Fact]
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredWithDefaultDepthAsync()
{
// Arrange — "references" and "./references" refer to the same directory;
// after normalization they should be deduplicated so resources appear only once.
// Arrange — resources in a subdirectory are discovered by default (depth=2)
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -1012,45 +982,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — only one copy of the resource despite two equivalent directory entries
// Assert — resource is discovered once
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
public async Task GetSkillsAsync_ScriptInSubdirectory_DiscoveredWithDefaultDepthAsync()
{
// Arrange — "references/" should be normalized to "references"
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — trailing slash variant deduplicated
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
{
// Arrange — ".\\scripts" should be normalized to "scripts"
// Arrange — scripts in a subdirectory are discovered by default (depth=2)
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
string scriptsDir = Path.Combine(skillDir, "scripts");
Directory.CreateDirectory(scriptsDir);
@@ -1058,50 +1004,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — backslash variant deduplicated
// Assert — script is discovered
Assert.Single(skills);
var script = await skills[0].GetScriptAsync("scripts/run.py");
Assert.NotNull(script);
Assert.Equal("scripts/run.py", script!.Name);
}
[Theory]
[InlineData("./references")]
[InlineData("./assets/docs")]
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
[Fact]
public async Task GetSkillsAsync_ResourceFilterWhitelist_OnlyMatchingFilesDiscoveredAsync()
{
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
// Arrange — ResourceFilter acts as whitelist: only references/ paths included
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(targetDir);
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
string refsDir = Path.Combine(skillDir, "references");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(refsDir);
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
File.WriteAllText(Path.Combine(assetsDir, "image.txt"), "data");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => ctx.RelativeFilePath.StartsWith("references/", StringComparison.OrdinalIgnoreCase) });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the resource is discovered with a name identical to using the directory without "./"
// Assert — only the references/ resource is included
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
}
[Fact]
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
public async Task GetSkillsAsync_DeepResource_NotDiscoveredWithDefaultDepthAsync()
{
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
// Arrange — resource at depth 3 (f1/f2/f3/data.json) exceeds default depth=2
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
@@ -1109,8 +1053,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — resource at depth 4 is NOT discovered with default depth=2
Assert.Single(skills);
Assert.Empty(skills[0].GetTestResources()!);
}
[Fact]
public async Task GetSkillsAsync_DeepResource_DiscoveredWithHigherDepthAsync()
{
// Arrange — resource at depth 4 (f1/f2/f3/data.json) discovered with SearchDepth=5
string skillDir = Path.Combine(this._testRoot, "deep-res-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: deep-res-skill\ndescription: Deep resource\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
// Act
var skills = await source.GetSkillsAsync();
@@ -1171,22 +1136,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredByDefaultAsync()
{
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
// Arrange — script file directly in the skill directory is discovered with default depth=2
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — script at the skill root should be discovered
// Assert — script at the skill root is discovered by default
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
Assert.NotNull(skill);
var script = await skill.GetScriptAsync("run.py");