From c2b92d1ef437213bf0d214900529eb11e1a88b6b Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 2 Sep 2026 23:50:27 -0400 Subject: [PATCH] Support non-object external tool arguments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Session.cs | 60 ++++++- .../test/Unit/ClientSessionLifetimeTests.cs | 165 ++++++++++++++++++ 2 files changed, 223 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 5995abaaff..22563c2682 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -898,9 +898,16 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, if (arguments is JsonElement incomingJsonArgs) { - foreach (var prop in incomingJsonArgs.EnumerateObject()) + if (incomingJsonArgs.ValueKind == JsonValueKind.Object) { - aiFunctionArgs[prop.Name] = prop.Value; + foreach (var prop in incomingJsonArgs.EnumerateObject()) + { + aiFunctionArgs[prop.Name] = prop.Value; + } + } + else + { + aiFunctionArgs[GetSingleParameterName(tool)] = incomingJsonArgs; } } @@ -941,6 +948,55 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, // Connection already disposed — nothing we can do } } + + static string GetSingleParameterName(AIFunction tool) + { + if (tool.JsonSchema.TryGetProperty("properties", out var properties) && + properties.ValueKind == JsonValueKind.Object) + { + string? parameterName = null; + foreach (var property in properties.EnumerateObject()) + { + if (parameterName is not null) + { + parameterName = null; + break; + } + + parameterName = property.Name; + } + + if (parameterName is not null) + { + return parameterName; + } + + if (tool.JsonSchema.TryGetProperty("required", out var required) && + required.ValueKind == JsonValueKind.Array) + { + string? requiredParameterName = null; + foreach (var requiredParameter in required.EnumerateArray()) + { + if (requiredParameterName is not null) + { + requiredParameterName = null; + break; + } + + requiredParameterName = requiredParameter.GetString(); + } + + if (requiredParameterName is not null && + properties.TryGetProperty(requiredParameterName, out _)) + { + return requiredParameterName; + } + } + } + + throw new ArgumentException( + $"Tool '{tool.Name}' received non-object arguments, but its schema does not define exactly one parameter or one required parameter."); + } } /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index a59257b3da..204626a6f5 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -12,6 +12,7 @@ using System.Text; using System.Text.Json; using GitHub.Copilot.Rpc; +using Microsoft.Extensions.AI; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -665,6 +666,166 @@ public async Task SessionRequests_Serialize_Terminal_Tools() Assert.True(resumeRequest.Params.GetProperty("tools")[0].GetProperty("isTerminal").GetBoolean()); } + [Fact] + public async Task ExternalTool_String_Arguments_Bind_To_Single_Function_Parameter() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + string? receivedPatch = null; + var tool = CopilotTool.DefineTool( + (string patch) => + { + receivedPatch = patch; + return "applied"; + }, + new CopilotToolOptions { OverridesBuiltInTool = true }, + new AIFunctionFactoryOptions { Name = "apply_patch" }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Tools = [tool], + OnPermissionRequest = PermissionHandler.ApproveAll + }); + server.ClearRequests(); + using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\""); + + DispatchEvent(session, new ExternalToolRequestedEvent + { + Data = new ExternalToolRequestedData + { + Arguments = arguments.RootElement.Clone(), + RequestId = "apply-patch-request", + SessionId = session.SessionId, + ToolCallId = "apply-patch-call", + ToolName = "apply_patch" + } + }); + + var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); + Assert.Equal("*** Begin Patch\n*** End Patch", receivedPatch); + Assert.False(request.Params.TryGetProperty("error", out _)); + Assert.Equal("applied", request.Params.GetProperty("result").GetProperty("textResultForLlm").GetString()); + } + + [Fact] + public async Task ExternalTool_String_Arguments_Reject_Ambiguous_Function_Parameters() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var invoked = false; + var tool = CopilotTool.DefineTool( + (string patch, string explanation) => + { + invoked = true; + return "applied"; + }, + new CopilotToolOptions { OverridesBuiltInTool = true }, + new AIFunctionFactoryOptions { Name = "apply_patch" }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Tools = [tool], + OnPermissionRequest = PermissionHandler.ApproveAll + }); + server.ClearRequests(); + using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\""); + + DispatchEvent(session, new ExternalToolRequestedEvent + { + Data = new ExternalToolRequestedData + { + Arguments = arguments.RootElement.Clone(), + RequestId = "ambiguous-apply-patch-request", + SessionId = session.SessionId, + ToolCallId = "ambiguous-apply-patch-call", + ToolName = "apply_patch" + } + }); + + var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); + Assert.False(invoked); + Assert.Contains("received non-object arguments", request.Params.GetProperty("error").GetString()); + Assert.False(request.Params.TryGetProperty("result", out _)); + } + + [Fact] + public async Task ExternalTool_Number_Arguments_Bind_To_Single_Function_Parameter() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + int? receivedLine = null; + var tool = CopilotTool.DefineTool( + (int line) => + { + receivedLine = line; + return "selected"; + }, + factoryOptions: new AIFunctionFactoryOptions { Name = "select_line" }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Tools = [tool], + OnPermissionRequest = PermissionHandler.ApproveAll + }); + server.ClearRequests(); + using var arguments = JsonDocument.Parse("42"); + + DispatchEvent(session, new ExternalToolRequestedEvent + { + Data = new ExternalToolRequestedData + { + Arguments = arguments.RootElement.Clone(), + RequestId = "select-line-request", + SessionId = session.SessionId, + ToolCallId = "select-line-call", + ToolName = "select_line" + } + }); + + var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); + Assert.Equal(42, receivedLine); + Assert.False(request.Params.TryGetProperty("error", out _)); + } + + [Fact] + public async Task ExternalTool_String_Arguments_Bind_To_Sole_Required_Function_Parameter() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + string? receivedPatch = null; + string? receivedExplanation = null; + var tool = CopilotTool.DefineTool( + (string patch, string? explanation = null) => + { + receivedPatch = patch; + receivedExplanation = explanation; + return "applied"; + }, + new CopilotToolOptions { OverridesBuiltInTool = true }, + new AIFunctionFactoryOptions { Name = "apply_patch" }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Tools = [tool], + OnPermissionRequest = PermissionHandler.ApproveAll + }); + server.ClearRequests(); + using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\""); + + DispatchEvent(session, new ExternalToolRequestedEvent + { + Data = new ExternalToolRequestedData + { + Arguments = arguments.RootElement.Clone(), + RequestId = "optional-apply-patch-request", + SessionId = session.SessionId, + ToolCallId = "optional-apply-patch-call", + ToolName = "apply_patch" + } + }); + + var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); + Assert.Equal("*** Begin Patch\n*** End Patch", receivedPatch); + Assert.Null(receivedExplanation); + Assert.False(request.Params.TryGetProperty("error", out _)); + } + [Fact] public async Task EmptyMode_Create_Sends_Empty_IncludedBuiltinSkills() { @@ -1667,6 +1828,10 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel { ["success"] = true }, + "session.tools.handlePendingToolCall" => new Dictionary + { + ["success"] = true + }, "session.delete" => new Dictionary { ["success"] = true