/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using StreamJsonRpc; using System.Collections.Concurrent; using System.Data; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace GitHub.Copilot.SDK; /// /// Provides a client for interacting with the Copilot CLI server. /// /// /// /// The manages the connection to the Copilot CLI server and provides /// methods to create and manage conversation sessions. It can either spawn a CLI server process /// or connect to an existing server. /// /// /// The client supports both stdio (default) and TCP transport modes for communication with the CLI server. /// /// /// /// /// // Create a client with default options (spawns CLI server) /// await using var client = new CopilotClient(); /// /// // Create a session /// await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-4" }); /// /// // Handle events /// using var subscription = session.On(evt => /// { /// if (evt is AssistantMessageEvent assistantMessage) /// Console.WriteLine(assistantMessage.Data?.Content); /// }); /// /// // Send a message /// await session.SendAsync(new MessageOptions { Prompt = "Hello!" }); /// /// public partial class CopilotClient : IDisposable, IAsyncDisposable { private readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; private readonly ILogger _logger; private Task? _connectionTask; private bool _disposed; private readonly int? _optionsPort; private readonly string? _optionsHost; /// /// Creates a new instance of . /// /// Options for creating the client. If null, default options are used. /// Thrown when mutually exclusive options are provided (e.g., CliUrl with UseStdio or CliPath). /// /// /// // Default options - spawns CLI server using stdio /// var client = new CopilotClient(); /// /// // Connect to an existing server /// var client = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:3000" }); /// /// // Custom CLI path with specific log level /// var client = new CopilotClient(new CopilotClientOptions /// { /// CliPath = "/usr/local/bin/copilot", /// LogLevel = "debug" /// }); /// /// public CopilotClient(CopilotClientOptions? options = null) { _options = options ?? new(); // Validate mutually exclusive options if (!string.IsNullOrEmpty(_options.CliUrl) && (_options.UseStdio || _options.CliPath != null)) { throw new ArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath"); } _logger = _options.Logger ?? NullLogger.Instance; // Parse CliUrl if provided if (!string.IsNullOrEmpty(_options.CliUrl)) { var uri = ParseCliUrl(_options.CliUrl!); _optionsHost = uri.Host; _optionsPort = uri.Port; } } /// /// Parses a CLI URL into a URI with host and port. /// /// The URL to parse. Supports formats: "port", "host:port", "http://host:port". /// A containing the parsed host and port. private static Uri ParseCliUrl(string url) { // If it's just a port number, treat as localhost if (int.TryParse(url, out var port)) { return new Uri($"http://localhost:{port}"); } // Add scheme if missing if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { url = "https://" + url; } return new Uri(url); } /// /// Starts the Copilot client and connects to the server. /// /// A that can be used to cancel the operation. /// A representing the asynchronous operation. /// /// /// If the server is not already running and the client is configured to spawn one (default), it will be started. /// If connecting to an external server (via CliUrl), only establishes the connection. /// /// /// This method is called automatically when creating a session if is true (default). /// /// /// /// /// var client = new CopilotClient(new CopilotClientOptions { AutoStart = false }); /// await client.StartAsync(); /// // Now ready to create sessions /// /// public Task StartAsync(CancellationToken cancellationToken = default) { return _connectionTask ??= StartCoreAsync(cancellationToken); async Task StartCoreAsync(CancellationToken ct) { _logger.LogDebug("Starting Copilot client"); Task result; if (_optionsHost is not null && _optionsPort is not null) { // External server (TCP) result = ConnectToServerAsync(null, _optionsHost, _optionsPort, ct); } else { // Child process (stdio or TCP) var (cliProcess, portOrNull) = await StartCliServerAsync(_options, _logger, ct); result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, ct); } var connection = await result; // Verify protocol version compatibility await VerifyProtocolVersionAsync(connection, ct); _logger.LogInformation("Copilot client connected"); return connection; } } /// /// Disconnects from the Copilot server and stops all active sessions. /// /// A representing the asynchronous operation. /// /// /// This method performs graceful cleanup: /// /// Destroys all active sessions /// Closes the JSON-RPC connection /// Terminates the CLI server process (if spawned by this client) /// /// /// /// Thrown when multiple errors occur during cleanup. /// /// /// await client.StopAsync(); /// /// public async Task StopAsync() { var errors = new List(); foreach (var session in _sessions.Values.ToArray()) { try { await session.DisposeAsync(); } catch (Exception ex) { errors.Add(new Exception($"Failed to destroy session {session.SessionId}: {ex.Message}", ex)); } } _sessions.Clear(); await CleanupConnectionAsync(errors); _connectionTask = null; ThrowErrors(errors); } /// /// Forces an immediate stop of the client without graceful cleanup. /// /// A representing the asynchronous operation. /// /// Use this when fails or takes too long. This method: /// /// Clears all sessions immediately without destroying them /// Force closes the connection /// Kills the CLI process (if spawned by this client) /// /// /// /// /// // If normal stop hangs, force stop /// var stopTask = client.StopAsync(); /// if (!stopTask.Wait(TimeSpan.FromSeconds(5))) /// { /// await client.ForceStopAsync(); /// } /// /// public async Task ForceStopAsync() { var errors = new List(); _sessions.Clear(); await CleanupConnectionAsync(errors); _connectionTask = null; ThrowErrors(errors); } private static void ThrowErrors(List errors) { if (errors.Count == 1) { throw errors[0]; } else if (errors.Count > 0) { throw new AggregateException(errors); } } private async Task CleanupConnectionAsync(List? errors) { if (_connectionTask is null) { return; } var ctx = await _connectionTask; _connectionTask = null; try { ctx.Rpc.Dispose(); } catch (Exception ex) { errors?.Add(ex); } if (ctx.NetworkStream is not null) { try { await ctx.NetworkStream.DisposeAsync(); } catch (Exception ex) { errors?.Add(ex); } } if (ctx.TcpClient is not null) { try { ctx.TcpClient.Dispose(); } catch (Exception ex) { errors?.Add(ex); } } if (ctx.CliProcess is { } childProcess) { try { if (!childProcess.HasExited) childProcess.Kill(); childProcess.Dispose(); } catch (Exception ex) { errors?.Add(ex); } } } /// /// Creates a new Copilot session with the specified configuration. /// /// Configuration for the session. If null, default settings are used. /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// Thrown when the client is not connected and AutoStart is disabled, or when a session with the same ID already exists. /// /// Sessions maintain conversation state, handle events, and manage tool execution. /// If the client is not connected and is enabled (default), /// this will automatically start the connection. /// /// /// /// // Basic session /// var session = await client.CreateSessionAsync(); /// /// // Session with model and tools /// var session = await client.CreateSessionAsync(new SessionConfig /// { /// Model = "gpt-4", /// Tools = [AIFunctionFactory.Create(MyToolMethod)] /// }); /// /// public async Task CreateSessionAsync(SessionConfig? config = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var request = new CreateSessionRequest( config?.Model, config?.SessionId, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config?.SystemMessage, config?.AvailableTools, config?.ExcludedTools, config?.Provider, config?.OnPermissionRequest != null ? true : null, config?.Streaming == true ? true : null, config?.McpServers, config?.CustomAgents, config?.ConfigDir, config?.SkillDirectories, config?.DisabledSkills, config?.InfiniteSessions); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.create", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); session.RegisterTools(config?.Tools ?? []); if (config?.OnPermissionRequest != null) { session.RegisterPermissionHandler(config.OnPermissionRequest); } if (!_sessions.TryAdd(response.SessionId, session)) { throw new InvalidOperationException($"Session {response.SessionId} already exists"); } return session; } /// /// Resumes an existing Copilot session with the specified configuration. /// /// The ID of the session to resume. /// Configuration for the resumed session. If null, default settings are used. /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// Thrown when the session does not exist or the client is not connected. /// /// This allows you to continue a previous conversation, maintaining all conversation history. /// The session must have been previously created and not deleted. /// /// /// /// // Resume a previous session /// var session = await client.ResumeSessionAsync("session-123"); /// /// // Resume with new tools /// var session = await client.ResumeSessionAsync("session-123", new ResumeSessionConfig /// { /// Tools = [AIFunctionFactory.Create(MyNewToolMethod)] /// }); /// /// public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var request = new ResumeSessionRequest( sessionId, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config?.Provider, config?.OnPermissionRequest != null ? true : null, config?.Streaming == true ? true : null, config?.McpServers, config?.CustomAgents, config?.SkillDirectories, config?.DisabledSkills); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.resume", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); session.RegisterTools(config?.Tools ?? []); if (config?.OnPermissionRequest != null) { session.RegisterPermissionHandler(config.OnPermissionRequest); } // Replace any existing session entry to ensure new config (like permission handler) is used _sessions[response.SessionId] = session; return session; } /// /// Gets the current connection state of the client. /// /// /// The current : Disconnected, Connecting, Connected, or Error. /// /// /// /// if (client.State == ConnectionState.Connected) /// { /// var session = await client.CreateSessionAsync(); /// } /// /// public ConnectionState State { get { if (_connectionTask == null) return ConnectionState.Disconnected; if (_connectionTask.IsFaulted) return ConnectionState.Error; if (!_connectionTask.IsCompleted) return ConnectionState.Connecting; return ConnectionState.Connected; } } /// /// Validates the health of the connection by sending a ping request. /// /// An optional message that will be reflected back in the response. /// A that can be used to cancel the operation. /// A task that resolves with the containing the message and server timestamp. /// Thrown when the client is not connected. /// /// /// var response = await client.PingAsync("health check"); /// Console.WriteLine($"Server responded at {response.Timestamp}"); /// /// public async Task PingAsync(string? message = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await connection.Rpc.InvokeWithCancellationAsync( "ping", [new PingRequest { Message = message }], cancellationToken); } /// /// Gets CLI status including version and protocol information. /// /// A that can be used to cancel the operation. /// A task that resolves with the status response containing version and protocol version. /// Thrown when the client is not connected. public async Task GetStatusAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await connection.Rpc.InvokeWithCancellationAsync( "status.get", [], cancellationToken); } /// /// Gets current authentication status. /// /// A that can be used to cancel the operation. /// A task that resolves with the authentication status. /// Thrown when the client is not connected. public async Task GetAuthStatusAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await connection.Rpc.InvokeWithCancellationAsync( "auth.getStatus", [], cancellationToken); } /// /// Lists available models with their metadata. /// /// A that can be used to cancel the operation. /// A task that resolves with a list of available models. /// Thrown when the client is not connected or not authenticated. public async Task> ListModelsAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await connection.Rpc.InvokeWithCancellationAsync( "models.list", [], cancellationToken); return response.Models; } /// /// Gets the ID of the most recently used session. /// /// A that can be used to cancel the operation. /// A task that resolves with the session ID, or null if no sessions exist. /// Thrown when the client is not connected. /// /// /// var lastId = await client.GetLastSessionIdAsync(); /// if (lastId != null) /// { /// var session = await client.ResumeSessionAsync(lastId); /// } /// /// public async Task GetLastSessionIdAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.getLastId", [], cancellationToken); return response.SessionId; } /// /// Deletes a Copilot session by its ID. /// /// The ID of the session to delete. /// A that can be used to cancel the operation. /// A task that represents the asynchronous delete operation. /// Thrown when the session does not exist or deletion fails. /// /// This permanently removes the session and all its conversation history. /// The session cannot be resumed after deletion. /// /// /// /// await client.DeleteSessionAsync("session-123"); /// /// public async Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.delete", [new DeleteSessionRequest(sessionId)], cancellationToken); if (!response.Success) { throw new InvalidOperationException($"Failed to delete session {sessionId}: {response.Error}"); } _sessions.TryRemove(sessionId, out _); } /// /// Lists all sessions known to the Copilot server. /// /// A that can be used to cancel the operation. /// A task that resolves with a list of for all available sessions. /// Thrown when the client is not connected. /// /// /// var sessions = await client.ListSessionsAsync(); /// foreach (var session in sessions) /// { /// Console.WriteLine($"{session.SessionId}: {session.Summary}"); /// } /// /// public async Task> ListSessionsAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await connection.Rpc.InvokeWithCancellationAsync( "session.list", [], cancellationToken); return response.Sessions; } private Task EnsureConnectedAsync(CancellationToken cancellationToken) { if (_connectionTask is null && !_options.AutoStart) { throw new InvalidOperationException($"Client not connected. Call {nameof(StartAsync)}() first."); } // If already started or starting, this will return the existing task return (Task)StartAsync(cancellationToken); } private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) { var expectedVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await connection.Rpc.InvokeWithCancellationAsync( "ping", [new PingRequest()], cancellationToken); if (!pingResponse.ProtocolVersion.HasValue) { throw new InvalidOperationException( $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " + $"but server does not report a protocol version. " + $"Please update your server to ensure compatibility."); } if (pingResponse.ProtocolVersion.Value != expectedVersion) { throw new InvalidOperationException( $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " + $"but server reports version {pingResponse.ProtocolVersion.Value}. " + $"Please update your SDK or server to ensure compatibility."); } } private static async Task<(Process Process, int? DetectedLocalhostTcpPort)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) { var cliPath = options.CliPath ?? "copilot"; var args = new List(); if (options.CliArgs != null) { args.AddRange(options.CliArgs); } args.AddRange(["--server", "--log-level", options.LogLevel]); if (options.UseStdio) { args.Add("--stdio"); } else if (options.Port > 0) { args.AddRange(["--port", options.Port.ToString()]); } var (fileName, processArgs) = ResolveCliCommand(cliPath, args); var startInfo = new ProcessStartInfo { FileName = fileName, Arguments = string.Join(" ", processArgs.Select(ProcessArgumentEscaper.Escape)), UseShellExecute = false, RedirectStandardInput = options.UseStdio, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = options.Cwd, CreateNoWindow = true }; if (options.Environment != null) { startInfo.Environment.Clear(); foreach (var (key, value) in options.Environment) { startInfo.Environment[key] = value; } } startInfo.Environment.Remove("NODE_DEBUG"); var cliProcess = new Process { StartInfo = startInfo }; cliProcess.Start(); // Forward stderr to logger _ = Task.Run(async () => { while (cliProcess != null && !cliProcess.HasExited) { var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken); if (line != null) { logger.LogDebug("[CLI] {Line}", line); } } }, cancellationToken); var detectedLocalhostTcpPort = (int?)null; if (!options.UseStdio) { // Wait for port announcement using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); while (!cts.Token.IsCancellationRequested) { var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token); if (line == null) throw new Exception("CLI process exited unexpectedly"); var match = Regex.Match(line, @"listening on port (\d+)", RegexOptions.IgnoreCase); if (match.Success) { detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value); break; } } } return (cliProcess, detectedLocalhostTcpPort); } private static (string FileName, IEnumerable Args) ResolveCliCommand(string cliPath, IEnumerable args) { var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase); if (isJsFile) { return ("node", new[] { cliPath }.Concat(args)); } // On Windows with UseShellExecute=false, Process.Start doesn't search PATHEXT, // so use cmd /c to let the shell resolve the executable if (OperatingSystem.IsWindows() && !Path.IsPathRooted(cliPath)) { return ("cmd", new[] { "/c", cliPath }.Concat(args)); } return (cliPath, args); } private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, CancellationToken cancellationToken) { Stream inputStream, outputStream; TcpClient? tcpClient = null; NetworkStream? networkStream = null; if (_options.UseStdio) { if (cliProcess == null) throw new InvalidOperationException("CLI process not started"); inputStream = cliProcess.StandardOutput.BaseStream; outputStream = cliProcess.StandardInput.BaseStream; } else { if (tcpHost is null || tcpPort is null) { throw new InvalidOperationException("Cannot connect because TCP host or port are not available"); } tcpClient = new(); await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken); networkStream = tcpClient.GetStream(); inputStream = networkStream; outputStream = networkStream; } var rpc = new JsonRpc(new HeaderDelimitedMessageHandler( outputStream, inputStream, CreateSystemTextJsonFormatter())) { TraceSource = new LoggerTraceSource(_logger), }; var handler = new RpcHandler(this); rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall); rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest); rpc.StartListening(); return new Connection(rpc, cliProcess, tcpClient, networkStream); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() => new SystemTextJsonFormatter() { JsonSerializerOptions = SerializerOptionsForMessageFormatter }; private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions(); private static JsonSerializerOptions CreateSerializerOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { AllowOutOfOrderMetadataProperties = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; options.TypeInfoResolverChain.Add(ClientJsonContext.Default); options.TypeInfoResolverChain.Add(TypesJsonContext.Default); options.TypeInfoResolverChain.Add(CopilotSession.SessionJsonContext.Default); options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); options.MakeReadOnly(); return options; } internal CopilotSession? GetSession(string sessionId) => _sessions.TryGetValue(sessionId, out var session) ? session : null; /// /// Disposes the synchronously. /// /// /// Prefer using for better performance in async contexts. /// public void Dispose() { DisposeAsync().GetAwaiter().GetResult(); } /// /// Disposes the asynchronously. /// /// A representing the asynchronous dispose operation. /// /// This method calls to immediately release all resources. /// public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; await ForceStopAsync(); } private class RpcHandler(CopilotClient client) { public void OnSessionEvent(string sessionId, JsonElement? @event) { var session = client.GetSession(sessionId); if (session != null && @event != null) { var evt = SessionEvent.FromJson(@event.Value.GetRawText()); if (evt != null) { session.DispatchEvent(evt); } } } public async Task OnToolCall(string sessionId, string toolCallId, string toolName, object? arguments) { var session = client.GetSession(sessionId); if (session == null) { 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 { // 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 OnPermissionRequest(string sessionId, JsonElement permissionRequest) { var session = client.GetSession(sessionId); if (session == null) { return new PermissionRequestResponse(new PermissionRequestResult { Kind = "denied-no-approval-rule-and-could-not-request-from-user" }); } 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 = "denied-no-approval-rule-and-could-not-request-from-user" }); } } } private class Connection( JsonRpc rpc, Process? cliProcess, // Set if we created the child process TcpClient? tcpClient, // Set if using TCP NetworkStream? networkStream) // Set if using TCP { public Process? CliProcess => cliProcess; public TcpClient? TcpClient => tcpClient; public JsonRpc Rpc => rpc; public NetworkStream? NetworkStream => networkStream; } private static class ProcessArgumentEscaper { public static string Escape(string arg) { if (string.IsNullOrEmpty(arg)) return "\"\""; if (!arg.Contains(' ') && !arg.Contains('"')) return arg; return "\"" + arg.Replace("\"", "\\\"") + "\""; } } // Request/Response types for RPC internal record CreateSessionRequest( string? Model, string? SessionId, List? Tools, SystemMessageConfig? SystemMessage, List? AvailableTools, List? ExcludedTools, ProviderConfig? Provider, bool? RequestPermission, bool? Streaming, Dictionary? McpServers, List? CustomAgents, string? ConfigDir, List? SkillDirectories, List? DisabledSkills, InfiniteSessionConfig? InfiniteSessions); internal record ToolDefinition( string Name, string? Description, JsonElement Parameters /* JSON schema */) { public static ToolDefinition FromAIFunction(AIFunction function) => new ToolDefinition(function.Name, function.Description, function.JsonSchema); } internal record CreateSessionResponse( string SessionId, string? WorkspacePath); internal record ResumeSessionRequest( string SessionId, List? Tools, ProviderConfig? Provider, bool? RequestPermission, bool? Streaming, Dictionary? McpServers, List? CustomAgents, List? SkillDirectories, List? DisabledSkills); internal record ResumeSessionResponse( string SessionId, string? WorkspacePath); internal record GetLastSessionIdResponse( string? SessionId); internal record DeleteSessionRequest( string SessionId); internal record DeleteSessionResponse( bool Success, string? Error); internal record ListSessionsResponse( List Sessions); internal record ToolCallResponse( ToolResultObject? Result); internal record PermissionRequestResponse( PermissionRequestResult Result); /// Trace source that forwards all logs to the ILogger. internal sealed class LoggerTraceSource : TraceSource { public LoggerTraceSource(ILogger logger) : base(nameof(LoggerTraceSource), SourceLevels.All) { Listeners.Clear(); Listeners.Add(new LoggerTraceListener(logger)); } private sealed class LoggerTraceListener(ILogger logger) : TraceListener { public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? message) => logger.Log(MapLevel(eventType), "[{Source}] {Message}", source, message); public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? format, params object?[]? args) => logger.Log(MapLevel(eventType), "[{Source}] {Message}", source, args is null || args.Length == 0 ? format : string.Format(format ?? "", args)); public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, object? data) => logger.Log(MapLevel(eventType), "[{Source}] {Data}", source, data); public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, params object?[]? data) => logger.Log(MapLevel(eventType), "[{Source}] {Data}", source, data is null ? null : string.Join(", ", data)); public override void Write(string? message) => logger.LogTrace("{Message}", message); public override void WriteLine(string? message) => logger.LogTrace("{Message}", message); private static LogLevel MapLevel(TraceEventType eventType) => eventType switch { TraceEventType.Critical => LogLevel.Critical, TraceEventType.Error => LogLevel.Error, TraceEventType.Warning => LogLevel.Warning, TraceEventType.Information => LogLevel.Information, TraceEventType.Verbose => LogLevel.Debug, _ => LogLevel.Trace }; } } [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(CreateSessionRequest))] [JsonSerializable(typeof(CreateSessionResponse))] [JsonSerializable(typeof(CustomAgentConfig))] [JsonSerializable(typeof(DeleteSessionRequest))] [JsonSerializable(typeof(DeleteSessionResponse))] [JsonSerializable(typeof(GetLastSessionIdResponse))] [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))] internal partial class ClientJsonContext : JsonSerializerContext; } // Must inherit from AIContent as a signal to MEAI to avoid JSON-serializing the // value before passing it back to us public class ToolResultAIContent(ToolResultObject toolResult) : AIContent { public ToolResultObject Result => toolResult; }