Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 7 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:
options:
- latest
- prerelease
- unstable
version:
description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments."
type: string
Expand Down Expand Up @@ -66,8 +67,8 @@ jobs:
fi
else
if [[ "$VERSION" != *-* ]]; then
echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is 'prerelease'" >> $GITHUB_STEP_SUMMARY
echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease"
echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY
echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable"
exit 1
fi
fi
Expand Down Expand Up @@ -107,11 +108,12 @@ jobs:
name: nodejs-package
path: nodejs/*.tgz
- name: Publish to npm
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable'
run: npm publish --tag ${{ github.event.inputs.dist-tag }} --access public --registry https://registry.npmjs.org

publish-dotnet:
name: Publish .NET SDK
if: github.event.inputs.dist-tag != 'unstable'
needs: version
runs-on: ubuntu-latest
defaults:
Expand Down Expand Up @@ -147,6 +149,7 @@ jobs:

publish-python:
name: Publish Python SDK
if: github.event.inputs.dist-tag != 'unstable'
needs: version
runs-on: ubuntu-latest
defaults:
Expand Down Expand Up @@ -183,7 +186,7 @@ jobs:
github-release:
name: Create GitHub Release
needs: [version, publish-nodejs, publish-dotnet, publish-python]
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
Expand Down
128 changes: 8 additions & 120 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
private bool _disposed;
private readonly int? _optionsPort;
private readonly string? _optionsHost;
private int? _actualPort;
private List<ModelInfo>? _modelsCache;
private readonly SemaphoreSlim _modelsCacheLock = new(1, 1);
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
Expand All @@ -80,6 +81,11 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
? throw new ObjectDisposedException(nameof(CopilotClient))
: _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first.");

/// <summary>
/// Gets the actual TCP port the CLI server is listening on, if using TCP transport.
/// </summary>
public int? ActualPort => _actualPort;

/// <summary>
/// Creates a new instance of <see cref="CopilotClient"/>.
/// </summary>
Expand Down Expand Up @@ -191,12 +197,14 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
if (_optionsHost is not null && _optionsPort is not null)
{
// External server (TCP)
_actualPort = _optionsPort;
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct);
}
else
{
// Child process (stdio or TCP)
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
_actualPort = portOrNull;
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
}

Expand Down Expand Up @@ -1129,8 +1137,6 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
var handler = new RpcHandler(this);
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle);
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall);
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest);
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
rpc.StartListening();
Expand Down Expand Up @@ -1231,116 +1237,6 @@ public void OnSessionLifecycle(string type, string sessionId, JsonElement? metad
client.DispatchLifecycleEvent(evt);
}

public async Task<ToolCallResponse> OnToolCall(string sessionId,
string toolCallId,
string toolName,
object? arguments)
{
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
if (session.GetTool(toolName) is not { } tool)
{
return new ToolCallResponse(new ToolResultObject
{
TextResultForLlm = $"Tool '{toolName}' is not supported.",
ResultType = "failure",
Error = $"tool '{toolName}' not supported"
});
}

try
{
var invocation = new ToolInvocation
{
SessionId = sessionId,
ToolCallId = toolCallId,
ToolName = toolName,
Arguments = arguments
};

// Map args from JSON into AIFunction format
var aiFunctionArgs = new AIFunctionArguments
{
Context = new Dictionary<object, object?>
{
// Allow recipient to access the raw ToolInvocation if they want, e.g., to get SessionId
// This is an alternative to using MEAI's ConfigureParameterBinding, which we can't use
// because we're not the ones producing the AIFunction.
[typeof(ToolInvocation)] = invocation
}
};

if (arguments is not null)
{
if (arguments is not JsonElement incomingJsonArgs)
{
throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}");
}

foreach (var prop in incomingJsonArgs.EnumerateObject())
{
// MEAI will deserialize the JsonElement value respecting the delegate's parameter types
aiFunctionArgs[prop.Name] = prop.Value;
}
}

var result = await tool.InvokeAsync(aiFunctionArgs);

// If the function returns a ToolResultObject, use it directly; otherwise, wrap the result
// This lets the developer provide BinaryResult, SessionLog, etc. if they deal with that themselves
var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject
{
ResultType = "success",

// In most cases, result will already have been converted to JsonElement by the AIFunction.
// We special-case string for consistency with our Node/Python/Go clients.
// TODO: I don't think it's right to special-case string here, and all the clients should
// always serialize the result to JSON (otherwise what stringification is going to happen?
// something we don't control? an error?)
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
? je.GetString()!
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
};
return new ToolCallResponse(toolResultObject);
}
catch (Exception ex)
{
return new ToolCallResponse(new()
{
// TODO: We should offer some way to control whether or not to expose detailed exception information to the LLM.
// For security, the default must be false, but developers can opt into allowing it.
TextResultForLlm = $"Invoking this tool produced an error. Detailed information is not available.",
ResultType = "failure",
Error = ex.Message
});
}
}

public async Task<PermissionRequestResponse> OnPermissionRequest(string sessionId, JsonElement permissionRequest)
{
var session = client.GetSession(sessionId);
if (session == null)
{
return new PermissionRequestResponse(new PermissionRequestResult
{
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
});
}

try
{
var result = await session.HandlePermissionRequestAsync(permissionRequest);
return new PermissionRequestResponse(result);
}
catch
{
// If permission handler fails, deny the permission
return new PermissionRequestResponse(new PermissionRequestResult
{
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
});
}
}

public async Task<UserInputRequestResponse> OnUserInputRequest(string sessionId, string question, List<string>? choices = null, bool? allowFreeform = null)
{
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
Expand Down Expand Up @@ -1473,12 +1369,6 @@ internal record ListSessionsRequest(
internal record ListSessionsResponse(
List<SessionMetadata> Sessions);

internal record ToolCallResponse(
ToolResultObject? Result);

internal record PermissionRequestResponse(
PermissionRequestResult Result);

internal record UserInputRequestResponse(
string Answer,
bool WasFreeform);
Expand Down Expand Up @@ -1578,14 +1468,12 @@ private static LogLevel MapLevel(TraceEventType eventType)
[JsonSerializable(typeof(HooksInvokeResponse))]
[JsonSerializable(typeof(ListSessionsRequest))]
[JsonSerializable(typeof(ListSessionsResponse))]
[JsonSerializable(typeof(PermissionRequestResponse))]
[JsonSerializable(typeof(PermissionRequestResult))]
[JsonSerializable(typeof(ProviderConfig))]
[JsonSerializable(typeof(ResumeSessionRequest))]
[JsonSerializable(typeof(ResumeSessionResponse))]
[JsonSerializable(typeof(SessionMetadata))]
[JsonSerializable(typeof(SystemMessageConfig))]
[JsonSerializable(typeof(ToolCallResponse))]
[JsonSerializable(typeof(ToolDefinition))]
[JsonSerializable(typeof(ToolResultAIContent))]
[JsonSerializable(typeof(ToolResultObject))]
Expand Down
Loading
Loading