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");