From 91c44583a2ba5ecbc016608625d18067f5df25a3 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Fri, 14 Aug 2026 16:18:39 -0700 Subject: [PATCH] .NET: Align file tool descriptions with generated schema argument names The file_access and file_memory tool descriptions referenced arguments by snake_case names (glob_pattern, old_string, new_string, replace_all) while AIFunctionFactory generates the tool schema from the camelCase C# parameter names. A model that follows the description ends up emitting argument names that never bind, so filters were silently dropped and replace calls failed for reasons unrelated to the file. Renamed the references to match the schema (globPattern, oldString, newString, replaceAll). The replace_lines descriptions are untouched: line_number and new_line are explicitly mapped via JsonPropertyName and are genuinely snake_case in the schema. Added regression tests asserting that each affected tool description and its generated schema agree on argument names for both providers. --- .../Harness/FileAccess/FileAccessProvider.cs | 4 +- .../Harness/FileMemory/FileMemoryProvider.cs | 6 +- .../FileAccessToolDescriptionSchemaTests.cs | 124 +++++++++++++++++ .../FileMemoryToolDescriptionSchemaTests.cs | 126 ++++++++++++++++++ 4 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessToolDescriptionSchemaTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryToolDescriptionSchemaTests.cs 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); + } + } + } +}