diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs index f42335b798..7674688113 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs @@ -327,7 +327,7 @@ private async Task DeleteAsync(string fileName, CancellationToken cancel /// An optional glob pattern (e.g., "*.md") matched against entry names to filter the listing. /// A token to cancel the operation. /// A list of entries, each with a name and a type of "file" or "directory" (subdirectories first). - [Description("List the direct child files and subdirectories of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Optionally filter entries with a glob_pattern (e.g. \"*.md\"). Subdirectories are listed before files, and each entry has a name and a type of \"file\" or \"directory\".")] + [Description("List the direct child files and subdirectories of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Optionally filter entries with a globPattern (e.g. \"*.md\"). Subdirectories are listed before files, and each entry has a name and a type of \"file\" or \"directory\".")] private async Task> LsAsync(string? directory = null, string? globPattern = null, CancellationToken cancellationToken = default) { string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory!; @@ -346,7 +346,7 @@ private async Task> LsAsync(string? directory = null, strin /// When , replace every occurrence; otherwise fail unless exactly one occurrence exists. /// A token to cancel the operation. /// A confirmation message including the number of occurrences replaced, or a failure message. - [Description("Replace occurrences of old_string with new_string in a file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")] + [Description("Replace occurrences of oldString with newString in a file. Fails if oldString is not found, or if it occurs more than once and replaceAll is false. Returns the number of occurrences replaced.")] private async Task ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default) { await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 680fdfcef2..0b41e82fba 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -268,7 +268,7 @@ private async Task DeleteAsync(string fileName, CancellationToken cancel /// An optional glob pattern (e.g., "*.md") matched against file names to filter the listing. /// A token to cancel the operation. /// A list of file entries with names and optional descriptions. - [Description("List all memory files with their descriptions (if available). Optionally filter file names with a glob_pattern (e.g. \"*.md\"). Internal files (description sidecars and the memory index) are not shown.")] + [Description("List all memory files with their descriptions (if available). Optionally filter file names with a globPattern (e.g. \"*.md\"). Internal files (description sidecars and the memory index) are not shown.")] private async Task> LsAsync(string? globPattern = null, CancellationToken cancellationToken = default) { FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); @@ -319,7 +319,7 @@ private async Task> LsAsync(string? globPattern = null, Canc /// When , replace every occurrence; otherwise fail unless exactly one occurrence exists. /// A token to cancel the operation. /// A confirmation message including the number of occurrences replaced, or a failure message. - [Description("Replace occurrences of old_string with new_string in a memory file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")] + [Description("Replace occurrences of oldString with newString in a memory file. Fails if oldString is not found, or if it occurs more than once and replaceAll is false. Returns the number of occurrences replaced.")] private async Task ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default) { string normalized = StorePaths.NormalizeRelativePath(fileName); @@ -394,7 +394,7 @@ private async Task ReplaceLinesAsync(string fileName, List /// An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files. /// A token to cancel the operation. /// A list of search results with matching file names, snippets, and matching lines. - [Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob_pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")] + [Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a globPattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")] private async Task> GrepAsync(string regexPattern, string? globPattern = null, CancellationToken cancellationToken = default) { FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessToolDescriptionSchemaTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessToolDescriptionSchemaTests.cs new file mode 100644 index 0000000000..0e32ccc4f2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessToolDescriptionSchemaTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.Harness.FileAccess; + +/// +/// Verifies that each file access tool's description refers to arguments by the same +/// names that exposes in the generated JSON schema. +/// +public class FileAccessToolDescriptionSchemaTests +{ + [Fact] + public async Task LsTool_Description_UsesSchemaArgumentNameAsync() + { + // Arrange + AIFunction tool = await GetToolAsync(FileAccessProvider.LsToolName); + + // Act + List schemaArguments = GetSchemaArgumentNames(tool); + + // Assert — the description and the generated schema agree on the filter argument name. + Assert.Contains("globPattern", tool.Description); + Assert.DoesNotContain("glob_pattern", tool.Description); + Assert.Contains("globPattern", schemaArguments); + } + + [Fact] + public async Task ReplaceTool_Description_UsesSchemaArgumentNamesAsync() + { + // Arrange + AIFunction tool = await GetToolAsync(FileAccessProvider.ReplaceToolName); + + // Act + List schemaArguments = GetSchemaArgumentNames(tool); + + // Assert — the description and the generated schema agree on the replace argument names. + Assert.Contains("oldString", tool.Description); + Assert.Contains("newString", tool.Description); + Assert.Contains("replaceAll", tool.Description); + Assert.DoesNotContain("old_string", tool.Description); + Assert.DoesNotContain("new_string", tool.Description); + Assert.DoesNotContain("replace_all", tool.Description); + Assert.Contains("oldString", schemaArguments); + Assert.Contains("newString", schemaArguments); + Assert.Contains("replaceAll", schemaArguments); + } + + [Fact] + public async Task ReplaceLinesTool_Description_KeepsExplicitJsonPropertyNamesAsync() + { + // Arrange + AIFunction tool = await GetToolAsync(FileAccessProvider.ReplaceLinesToolName); + + // Act + List schemaArguments = GetSchemaArgumentNames(tool); + + // Assert — line_number/new_line are mapped explicitly via JsonPropertyName, so the + // description must keep referencing them as-is instead of a camelCase rename. + Assert.Contains("line_number", tool.Description); + Assert.Contains("new_line", tool.Description); + Assert.Contains("line_number", schemaArguments); + Assert.Contains("new_line", schemaArguments); + } + + private static async Task GetToolAsync(string toolName) + { + // Arrange + var provider = new FileAccessProvider(new InMemoryAgentFileStore()); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + AIContext result = await provider.InvokingAsync(context); + return (AIFunction)result.Tools!.First(t => t is AIFunction f && f.Name == toolName); + } + + private static List GetSchemaArgumentNames(AIFunction tool) + { + var names = new List(); + if (tool.JsonSchema is JsonElement schema) + { + CollectPropertyNames(schema, names); + } + + return names.Distinct().ToList(); + } + + private static void CollectPropertyNames(JsonElement element, List names) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.Name == "properties") + { + foreach (JsonProperty item in property.Value.EnumerateObject()) + { + names.Add(item.Name); + CollectPropertyNames(item.Value, names); + } + } + else + { + CollectPropertyNames(property.Value, names); + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + CollectPropertyNames(item, names); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryToolDescriptionSchemaTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryToolDescriptionSchemaTests.cs new file mode 100644 index 0000000000..a007ebaaf8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryToolDescriptionSchemaTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory; + +/// +/// Verifies that each file memory tool's description refers to arguments by the same +/// names that exposes in the generated JSON schema. +/// +public class FileMemoryToolDescriptionSchemaTests +{ + [Theory] + [InlineData(FileMemoryProvider.LsToolName)] + [InlineData(FileMemoryProvider.GrepToolName)] + public async Task FilteringTool_Description_UsesSchemaArgumentNameAsync(string toolName) + { + // Arrange + AIFunction tool = await GetToolAsync(toolName); + + // Act + List schemaArguments = GetSchemaArgumentNames(tool); + + // Assert — the description and the generated schema agree on the filter argument name. + Assert.Contains("globPattern", tool.Description); + Assert.DoesNotContain("glob_pattern", tool.Description); + Assert.Contains("globPattern", schemaArguments); + } + + [Fact] + public async Task ReplaceTool_Description_UsesSchemaArgumentNamesAsync() + { + // Arrange + AIFunction tool = await GetToolAsync(FileMemoryProvider.ReplaceToolName); + + // Act + List schemaArguments = GetSchemaArgumentNames(tool); + + // Assert — the description and the generated schema agree on the replace argument names. + Assert.Contains("oldString", tool.Description); + Assert.Contains("newString", tool.Description); + Assert.Contains("replaceAll", tool.Description); + Assert.DoesNotContain("old_string", tool.Description); + Assert.DoesNotContain("new_string", tool.Description); + Assert.DoesNotContain("replace_all", tool.Description); + Assert.Contains("oldString", schemaArguments); + Assert.Contains("newString", schemaArguments); + Assert.Contains("replaceAll", schemaArguments); + } + + [Fact] + public async Task ReplaceLinesTool_Description_KeepsExplicitJsonPropertyNamesAsync() + { + // Arrange + AIFunction tool = await GetToolAsync(FileMemoryProvider.ReplaceLinesToolName); + + // Act + List schemaArguments = GetSchemaArgumentNames(tool); + + // Assert — line_number/new_line are mapped explicitly via JsonPropertyName, so the + // description must keep referencing them as-is instead of a camelCase rename. + Assert.Contains("line_number", tool.Description); + Assert.Contains("new_line", tool.Description); + Assert.Contains("line_number", schemaArguments); + Assert.Contains("new_line", schemaArguments); + } + + private static async Task GetToolAsync(string toolName) + { + // Arrange + var provider = new FileMemoryProvider(new InMemoryAgentFileStore()); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + AIContext result = await provider.InvokingAsync(context); + return (AIFunction)result.Tools!.First(t => t is AIFunction f && f.Name == toolName); + } + + private static List GetSchemaArgumentNames(AIFunction tool) + { + var names = new List(); + if (tool.JsonSchema is JsonElement schema) + { + CollectPropertyNames(schema, names); + } + + return names.Distinct().ToList(); + } + + private static void CollectPropertyNames(JsonElement element, List names) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.Name == "properties") + { + foreach (JsonProperty item in property.Value.EnumerateObject()) + { + names.Add(item.Name); + CollectPropertyNames(item.Value, names); + } + } + else + { + CollectPropertyNames(property.Value, names); + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + CollectPropertyNames(item, names); + } + } + } +}