Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu

## [Unreleased]

### Feature: cancellation for host-owned external tools

Host-owned external tool callbacks are now cancelled when their runtime request completes or their SDK session terminates. The cancellation primitive is idiomatic per SDK: .NET passes a request token to `AIFunction`, Node.js exposes `ToolInvocation.signal`, Go cancels `ToolInvocation.TraceContext`, Java cancels the returned `CompletableFuture`, Python cancels the handler task, and Rust drops the handler future. Go handlers that retain `TraceContext` for background work must derive a separate lifetime because the invocation context is cancelled when the request ends.

### Feature: declare application identity with client info

Client options now accept optional client info (application name and version, integration name and version) across all six SDKs, exposed idiomatically per language (`clientInfo` in Node.js, `client_info` in Python and Rust, `ClientInfo` in Go and .NET, `setClientInfo` in Java). When set, the SDK forwards it on the `server.connect` handshake so the telemetry the runtime emits on the connection is attributed to the application and its Copilot integration instead of the runtime's own build. All fields are optional, and leaving client info unset keeps the runtime's default attribution. See [Client info](./docs/features/client-info.md).
Expand Down
28 changes: 28 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,10 @@
/// </example>
public async Task ForceStopAsync()
{
foreach (var session in _sessions.Values)
{
session.CancelPendingExternalTools();
}
_sessions.Clear();
ClearGitHubTokenProviders();

Expand Down Expand Up @@ -2686,6 +2690,7 @@
ClientGlobalApiRegistration.RegisterClientGlobalApiHandlers(rpc, _clientGlobalApis);
}
rpc.StartListening();
_ = CancelExternalToolsWhenConnectionClosesAsync(rpc);
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}",
setupTimestamp);
Expand All @@ -2709,6 +2714,29 @@
}
}

private async Task CancelExternalToolsWhenConnectionClosesAsync(JsonRpc rpc)
{
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
try
{
await rpc.Completion.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "JSON-RPC connection completed with an error");
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
var connectionTask = _connectionTask;
if (connectionTask is null
|| connectionTask.Status != System.Threading.Tasks.TaskStatus.RanToCompletion
|| !ReferenceEquals(connectionTask.Result.Rpc, rpc))
{
return;
}
foreach (var session in _sessions.Values)
{
session.CancelPendingExternalTools();
}
}

private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions();

/// <summary>
Expand Down
116 changes: 111 additions & 5 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using GitHub.Copilot.Rpc;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
Expand Down Expand Up @@ -59,6 +60,8 @@
private readonly Dictionary<string, AIFunction> _toolHandlers = [];
private readonly Dictionary<string, Func<CommandContext, Task>> _commandHandlers = [];
private readonly Dictionary<string, Func<ProviderTokenArgs, Task<string>>> _bearerTokenProviders = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CancellationTokenSource> _pendingExternalTools = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _externalToolLifetime = new();
private readonly ILogger _logger;
private readonly CopilotClient _parentClient;

Expand Down Expand Up @@ -225,6 +228,7 @@
/// </summary>
internal void Unregister()
{
CancelPendingExternalTools();
CloseEventChannel();
RemoveFromClient();
}
Expand Down Expand Up @@ -674,6 +678,10 @@
break;
}

case ExternalToolCompletedEvent completedEvent:
CancelExternalTool(completedEvent.Data.RequestId);
break;

case PermissionRequestedEvent permEvent:
{
var data = permEvent.Data;
Expand Down Expand Up @@ -883,8 +891,25 @@
/// </summary>
private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, JsonElement? arguments, AIFunction tool)
{
if (_externalToolLifetime.IsCancellationRequested)
{
return;
}

var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(_externalToolLifetime.Token);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (!_pendingExternalTools.TryAdd(requestId, cancellationSource))
{
cancellationSource.Dispose();
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return;
}

try
{
if (cancellationSource.IsCancellationRequested)
{
return;
}

var invocation = new ToolInvocation
{
SessionId = SessionId,
Expand All @@ -902,7 +927,7 @@
{
try
{
var metadata = await Rpc.Tools.GetCurrentMetadataAsync();
var metadata = await Rpc.Tools.GetCurrentMetadataAsync(cancellationSource.Token);
invocation.AvailableTools = metadata.Tools;
}
catch (Exception ex) when (ex is RemoteRpcException or IOException or ObjectDisposedException or JsonException)
Expand Down Expand Up @@ -930,7 +955,7 @@
}

var toolTimestamp = Stopwatch.GetTimestamp();
var result = await tool.InvokeAsync(aiFunctionArgs);
var result = await tool.InvokeAsync(aiFunctionArgs, cancellationSource.Token);
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"CopilotSession.ExecuteToolAndRespondAsync tool dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, ToolCallId={ToolCallId}, Tool={ToolName}",
toolTimestamp,
Expand All @@ -940,9 +965,14 @@
toolName);

var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions);
if (!TryClaimExternalTool(requestId, cancellationSource))
{
return;
}

var responseRpcTimestamp = Stopwatch.GetTimestamp();
await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null);
await Rpc.Tools.HandlePendingToolCallAsync(
requestId, toolResultObject, error: null, cancellationSource.Token);
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"CopilotSession.ExecuteToolAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, ToolCallId={ToolCallId}, Tool={ToolName}",
responseRpcTimestamp,
Expand All @@ -951,11 +981,33 @@
toolCallId,
toolName);
}
catch (Exception ex)
catch (OperationCanceledException) when (cancellationSource.IsCancellationRequested)
{
// The runtime has already completed the request or the session is shutting down.
}
catch (RemoteRpcException) when (cancellationSource.IsCancellationRequested)
{
// Another client answered after this invocation completed locally.
}
catch (Exception) when (cancellationSource.IsCancellationRequested)
{
// Cancellation won the request; no response or error should escape.
}
catch (Exception ex) when (!cancellationSource.IsCancellationRequested)
{
if (!TryClaimExternalTool(requestId, cancellationSource))
{
return;
}

try
{
await Rpc.Tools.HandlePendingToolCallAsync(requestId, result: null, error: ex.Message);
await Rpc.Tools.HandlePendingToolCallAsync(
requestId, result: null, error: ex.Message, cancellationSource.Token);
}
catch (OperationCanceledException)
{
// Teardown canceled the in-flight error response.
}
catch (IOException)
{
Expand All @@ -965,7 +1017,60 @@
{
// Connection already disposed — nothing we can do
}
catch (RemoteRpcException)
{
// Another client may have answered the broadcast request first.
}
}
finally
{
((ICollection<KeyValuePair<string, CancellationTokenSource>>)_pendingExternalTools)
.Remove(new(requestId, cancellationSource));
cancellationSource.Dispose();
}
}

private bool TryClaimExternalTool(string requestId, CancellationTokenSource cancellationSource)
=> ((ICollection<KeyValuePair<string, CancellationTokenSource>>)_pendingExternalTools)
.Remove(new(requestId, cancellationSource));

private void CancelExternalTool(string requestId)
{
if (!string.IsNullOrEmpty(requestId) && _pendingExternalTools.TryRemove(requestId, out var cancellationSource))
{
_ = Task.Run(() =>
{
try
{
cancellationSource.Cancel();
}
catch (AggregateException)
{
// Cancellation callbacks are consumer code and must not disrupt event dispatch.
}
catch (ObjectDisposedException)
{
// The invocation completed while cancellation was being delivered.
}
});
}
}

internal void CancelPendingExternalTools()
{
try
{
_externalToolLifetime.Cancel();
}
catch (AggregateException)
{
// User cancellation callbacks must not prevent session teardown.
}
catch (ObjectDisposedException)
{
// Session teardown already completed.
}
_pendingExternalTools.Clear();
}

/// <summary>
Expand Down Expand Up @@ -1958,6 +2063,7 @@
return;
}

CancelPendingExternalTools();
CloseEventChannel();

try
Expand Down
Loading
Loading