diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillFilterContext.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillFilterContext.cs
new file mode 100644
index 0000000000..34937baed5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillFilterContext.cs
@@ -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;
+
+///
+/// Provides contextual information about a discovered file to the
+/// and
+/// predicates.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class AgentFileSkillFilterContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the skill (from SKILL.md frontmatter).
+ ///
+ /// The path to the script or resource file relative to the skill directory (using forward slashes).
+ ///
+ internal AgentFileSkillFilterContext(string skillName, string relativeFilePath)
+ {
+ this.SkillName = Throw.IfNullOrWhitespace(skillName);
+ this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath);
+ }
+
+ ///
+ /// Gets the name of the skill as declared in the SKILL.md frontmatter.
+ ///
+ /// unit-converter
+ public string SkillName { get; }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// run.py for a script at skill root,
+ /// scripts/convert.js for a nested script, or
+ /// references/guide.md for a nested resource.
+ ///
+ public string RelativeFilePath { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
index d31501426e..54a5dec10c 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs
@@ -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 _skillPaths;
private readonly HashSet _allowedResourceExtensions;
private readonly HashSet _allowedScriptExtensions;
- private readonly IReadOnlyList _scriptDirectories;
- private readonly IReadOnlyList _resourceDirectories;
+ private readonly int _searchDepth;
+ private readonly Func? _scriptFilter;
+ private readonly Func? _resourceFilter;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
@@ -111,13 +106,9 @@ public AgentFileSkillsSource(
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 @@ private static void SearchDirectoriesForSkills(string directory, List re
results.Add(Path.GetFullPath(directory));
}
- if (currentDepth >= MaxSearchDepth)
+ if (currentDepth >= MaxSkillDirectorySearchDepth)
{
return;
}
@@ -305,216 +296,246 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW
}
///
- /// 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.
///
///
- /// By default, scans references/ and assets/ subdirectories as specified by the
- /// Agent Skills specification.
- /// Configure to scan different or
- /// additional directories, including "." for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
+ /// If a predicate is configured, files
+ /// that do not satisfy it are excluded.
///
private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
{
var resources = new List();
- foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
+ this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1);
+
+ return resources;
+ }
+
+ private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List resources, 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))
{
- continue;
+ LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(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))
- {
- LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
- }
-
- continue;
- }
+ return;
+ }
#if NET
- var enumerationOptions = new EnumerationOptions
- {
- RecurseSubdirectories = false,
- IgnoreInaccessible = true,
- AttributesToSkip = FileAttributes.ReparsePoint,
- };
+ var enumerationOptions = new EnumerationOptions
+ {
+ RecurseSubdirectories = false,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.ReparsePoint,
+ };
- foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
+ foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions))
#else
- foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly))
+ 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))
{
- string fileName = Path.GetFileName(filePath);
+ continue;
+ }
- // Exclude SKILL.md itself
- if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase))
+ // Filter by extension
+ string extension = Path.GetExtension(filePath);
+ if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension))
+ {
+ if (this._logger.IsEnabled(LogLevel.Debug))
{
- continue;
+ LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), string.IsNullOrEmpty(extension) ? "(none)" : extension);
}
- // 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;
- }
+ continue;
+ }
- // Normalize the enumerated path to guard against non-canonical forms.
- // e.g. "references/../../../etc/shadow" → "/etc/shadow"
- string resolvedFilePath = Path.GetFullPath(filePath);
+ // 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))
+ // 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))
{
- if (this._logger.IsEnabled(LogLevel.Warning))
- {
- LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
- }
-
- continue;
+ LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath));
}
- // 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;
+ }
- 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));
}
- // Compute relative path and normalize separators.
- // e.g. "/skills/myskill/references/guide.md" → "references/guide.md"
- string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
+ 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));
+ // Apply user-provided filter predicate
+ if (this._resourceFilter is not null && !this._resourceFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
+ {
+ continue;
}
+
+ resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath));
}
- return resources;
+ // 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);
+ }
+ }
}
///
- /// 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.
///
///
- /// By default, scans the scripts/ subdirectory as specified by the
- /// Agent Skills specification.
- /// Configure to scan different or
- /// additional directories, including "." for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
+ /// If a predicate is configured, files
+ /// that do not satisfy it are excluded.
///
private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
{
var scripts = new List();
- 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 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
- {
- 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
+ // 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))
{
- // 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;
- }
+ 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));
- }
+ // Compute relative path and normalize separators.
+ // e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py"
+ string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length));
- continue;
- }
+ // Apply user-provided filter predicate
+ if (this._scriptFilter is not null && !this._scriptFilter(new AgentFileSkillFilterContext(skillName, relativePath)))
+ {
+ 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));
+ }
- scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner));
+ // 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);
}
}
-
- return scripts;
}
///
@@ -542,6 +563,31 @@ private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
return false;
}
+#if !NET
+ ///
+ /// Best-effort directory enumeration for target frameworks without
+ /// EnumerationOptions.IgnoreInaccessible 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.
+ ///
+ 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();
+ }
+ }
+#endif
+
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
{
string value = kvMatch.Groups[3].Value;
@@ -664,46 +710,6 @@ private static void ValidateExtensions(IEnumerable? extensions)
}
}
- private static IEnumerable ValidateAndNormalizeDirectoryNames(IEnumerable 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 @@ private static bool ContainsParentTraversalSegment(string directory)
[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);
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
index b5c83c0220..c9604e166d 100644
--- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs
@@ -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? AllowedScriptExtensions { get; set; }
///
- /// Gets or sets relative directory paths to scan for script files within each skill directory.
- /// Values may be single-segment names (e.g., "scripts") or multi-segment relative
- /// paths (e.g., "sub/scripts"). Use "." to include files directly at the
- /// skill root. Leading "./" prefixes, trailing separators, and backslashes are
- /// normalized automatically; paths containing ".." segments or absolute paths are
- /// rejected.
- /// When , defaults to scripts (per the
- /// Agent Skills specification).
- /// 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 1 searches only the skill root directory. A value of 2 searches the root
+ /// and one level of subdirectories.
+ /// When , the source uses the default depth of 2.
///
- public IEnumerable? ScriptDirectories { get; set; }
+ ///
+ /// Must be greater than or equal to 1; lower values are rejected by the constructor.
+ ///
+ public int? SearchDepth { get; set; }
///
- /// Gets or sets relative directory paths to scan for resource files within each skill directory.
- /// Values may be single-segment names (e.g., "references") or multi-segment relative
- /// paths (e.g., "sub/resources"). Use "." to include files directly at the
- /// skill root. Leading "./" prefixes, trailing separators, and backslashes are
- /// normalized automatically; paths containing ".." segments or absolute paths are
- /// rejected.
- /// When , defaults to references and assets (per the
- /// Agent Skills specification).
- /// When set, replaces the defaults entirely.
+ /// Gets or sets a predicate that filters discovered script files.
+ /// The predicate receives an containing the skill's name
+ /// and the file's path relative to the skill directory.
+ /// Return to include the file or to exclude it.
+ /// When , all scripts matching the allowed extensions are included.
///
- public IEnumerable? ResourceDirectories { get; set; }
+ public Func? ScriptFilter { get; set; }
+
+ ///
+ /// Gets or sets a predicate that filters discovered resource files.
+ /// The predicate receives an containing the skill's name
+ /// and the file's path relative to the skill directory.
+ /// Return to include the file or to exclude it.
+ /// When , all resources matching the allowed extensions are included.
+ ///
+ public Func? ResourceFilter { get; set; }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
index 32e018ae90..b5d20c40dc 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs
@@ -111,10 +111,9 @@ public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync()
}
[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 async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync(
// 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 async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync()
}
[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 async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScript
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)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
index cd362ce64e..6eb561c5b6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs
@@ -425,9 +425,9 @@ public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException()
}
[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 async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync
// 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 async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectory
}
[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(() =>
+ new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
+ new AgentFileSkillsSourceOptions { SearchDepth = 0 }));
+
+ Assert.Throws(() =>
+ 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 async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefau
// 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 async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync
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 async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratin
}
[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 async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirecto
var source = new AgentFileSkillsSource(
this._testRoot,
s_noOpExecutor,
- new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
+ new AgentFileSkillsSourceOptions { SearchDepth = 4 });
// Act
var skills = await source.GetSkillsAsync();
@@ -957,100 +949,54 @@ public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync()
Assert.Null(fm.Metadata);
}
- [Theory]
- [InlineData("..")]
- [InlineData("../escape")]
- [InlineData("sub/../escape")]
- [InlineData("/absolute")]
- [InlineData("\\absolute")]
- public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
- {
- // 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] });
-
- // Assert
- Assert.NotNull(source1);
- Assert.NotNull(source2);
- }
-
- [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(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
- Assert.Throws(() => 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);
- }
-
[Fact]
- public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
+ public async Task GetSkillsAsync_SearchDepthOne_OnlyRootFilesDiscoveredAsync()
{
- // Arrange — "references" and "./references" refer to the same directory;
- // after normalization they should be deduplicated so resources appear only once.
- string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
- string refsDir = Path.Combine(skillDir, "references");
- Directory.CreateDirectory(refsDir);
- File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
+ // 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: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
+ "---\nname: depth-one-skill\ndescription: Depth one\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
- new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
+ new AgentFileSkillsSourceOptions { SearchDepth = 1 });
// Act
var skills = await source.GetSkillsAsync();
- // Assert — only one copy of the resource despite two equivalent directory entries
+ // Assert — scripts in subdirectories are NOT discovered at depth 1
Assert.Single(skills);
- Assert.Single(skills[0].GetTestResources()!);
- Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
+ Assert.Null(await skills[0].GetScriptAsync("scripts/run.py"));
}
[Fact]
- public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
+ public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredWithDefaultDepthAsync()
{
- // Arrange — "references/" should be normalized to "references"
- string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
+ // 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);
- File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
+ File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
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/"] });
+ "---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
+ var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
- // Assert — trailing slash variant deduplicated
+ // Assert — resource is discovered once
Assert.Single(skills);
Assert.Single(skills[0].GetTestResources()!);
- Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
+ Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
}
[Fact]
- public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
+ public async Task GetSkillsAsync_ScriptInSubdirectory_DiscoveredWithDefaultDepthAsync()
{
- // 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 async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScripts
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 async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversReso
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 async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync()
}
[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");