diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index d721e208ff..cbddb41dad 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -89,6 +89,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 8b1b00fd2b..2a9b81d44b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -65,6 +65,7 @@ + @@ -395,6 +396,7 @@ + @@ -424,6 +426,7 @@ + @@ -438,6 +441,7 @@ + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/Agent_With_GithubCopilot.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/Agent_With_GithubCopilot.csproj new file mode 100644 index 0000000000..69d7086d5e --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/Agent_With_GithubCopilot.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/Program.cs new file mode 100644 index 0000000000..b233259dcc --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/Program.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create a GitHub Copilot agent with shell command permissions. + +using GitHub.Copilot.SDK; +using Microsoft.Agents.AI; + +// Permission handler that prompts the user for approval +static Task PromptPermission(PermissionRequest request, PermissionInvocation invocation) +{ + Console.WriteLine($"\n[Permission Request: {request.Kind}]"); + Console.Write("Approve? (y/n): "); + + string? input = Console.ReadLine()?.Trim().ToUpperInvariant(); + string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user"; + + return Task.FromResult(new PermissionRequestResult { Kind = kind }); +} + +// Create and start a Copilot client +await using CopilotClient copilotClient = new(); +await copilotClient.StartAsync(); + +// Create an agent with a session config that enables permission handling +SessionConfig sessionConfig = new() +{ + OnPermissionRequest = PromptPermission, +}; + +AIAgent agent = copilotClient.AsAIAgent(sessionConfig, ownsClient: true); + +// Toggle between streaming and non-streaming modes +bool useStreaming = true; + +string prompt = "List all files in the current directory"; +Console.WriteLine($"User: {prompt}\n"); + +if (useStreaming) +{ + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(prompt)) + { + Console.Write(update); + } + + Console.WriteLine(); +} +else +{ + AgentResponse response = await agent.RunAsync(prompt); + Console.WriteLine(response); +} diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/README.md new file mode 100644 index 0000000000..885988dbcb --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GithubCopilot/README.md @@ -0,0 +1,76 @@ +# Prerequisites + +> **⚠️ WARNING: Container Recommendation** +> +> GitHub Copilot can execute tools and commands that may interact with your system. For safety, it is strongly recommended to run this sample in a containerized environment (e.g., Docker, Dev Container) to avoid unintended consequences to your machine. + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- GitHub Copilot CLI installed and available in your PATH (or provide a custom path) + +## Setting up GitHub Copilot CLI + +To use this sample, you need to have the GitHub Copilot CLI installed. You can install it by following the instructions at: +https://github.com/github/copilot-sdk + +Once installed, ensure the `copilot` command is available in your PATH, or configure a custom path using `CopilotClientOptions`. + +## Running the Sample + +No additional environment variables are required if using default configuration. The sample will: + +1. Create a GitHub Copilot client with default options +2. Create an AI agent using the Copilot SDK +3. Send a message to the agent +4. Display the response + +Run the sample: + +```powershell +dotnet run +``` + +## Advanced Usage + +You can customize the agent by providing additional configuration: + +```csharp +using GitHub.Copilot.SDK; +using Microsoft.Agents.AI; + +// Create and start a Copilot client +await using CopilotClient copilotClient = new(); +await copilotClient.StartAsync(); + +// Create session configuration with specific model +SessionConfig sessionConfig = new() +{ + Model = "claude-opus-4.5", + Streaming = false +}; + +// Create an agent with custom configuration using the extension method +AIAgent agent = copilotClient.AsAIAgent( + sessionConfig, + ownsClient: true, + id: "my-copilot-agent", + name: "My Copilot Assistant", + description: "A helpful AI assistant powered by GitHub Copilot" +); + +// Use the agent - ask it to write code for us +AgentResponse response = await agent.RunAsync("Write a small .NET 10 C# hello world single file application"); +Console.WriteLine(response); +``` + +## Streaming Responses + +To get streaming responses: + +```csharp +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("Write a C# function to calculate Fibonacci numbers")) +{ + Console.Write(update.Text); +} +``` diff --git a/dotnet/samples/GettingStarted/AgentProviders/README.md b/dotnet/samples/GettingStarted/AgentProviders/README.md index 964e560c9a..4b276215db 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/README.md +++ b/dotnet/samples/GettingStarted/AgentProviders/README.md @@ -22,6 +22,7 @@ See the README.md for each sample for the prerequisites for that sample. |[Creating an AIAgent with Azure OpenAI ChatCompletion](./Agent_With_AzureOpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using Azure OpenAI ChatCompletion as the underlying inference service| |[Creating an AIAgent with Azure OpenAI Responses](./Agent_With_AzureOpenAIResponses/)|This sample demonstrates how to create an AIAgent using Azure OpenAI Responses as the underlying inference service| |[Creating an AIAgent with a custom implementation](./Agent_With_CustomImplementation/)|This sample demonstrates how to create an AIAgent with a custom implementation| +|[Creating an AIAgent with GitHub Copilot](./Agent_With_GithubCopilot/)|This sample demonstrates how to create an AIAgent using GitHub Copilot SDK as the underlying inference service| |[Creating an AIAgent with Ollama](./Agent_With_Ollama/)|This sample demonstrates how to create an AIAgent using Ollama as the underlying inference service| |[Creating an AIAgent with ONNX](./Agent_With_ONNX/)|This sample demonstrates how to create an AIAgent using ONNX as the underlying inference service| |[Creating an AIAgent with OpenAI Assistants](./Agent_With_OpenAIAssistants/)|This sample demonstrates how to create an AIAgent using OpenAI Assistants as the underlying inference service.
WARNING: The Assistants API is deprecated and will be shut down. For more information see the OpenAI documentation: https://platform.openai.com/docs/assistants/migration| diff --git a/dotnet/src/Microsoft.Agents.AI.GithubCopilot/CopilotClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/CopilotClientExtensions.cs new file mode 100644 index 0000000000..ea20d46c46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/CopilotClientExtensions.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.GithubCopilot; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace GitHub.Copilot.SDK; + +/// +/// Provides extension methods for +/// to simplify the creation of GitHub Copilot agents. +/// +/// +/// These extensions bridge the gap between GitHub Copilot SDK client objects +/// and the Microsoft Agent Framework. +/// +/// They allow developers to easily create AI agents that can interact +/// with GitHub Copilot by handling the conversion from Copilot clients to +/// instances that implement the interface. +/// +/// +public static class CopilotClientExtensions +{ + /// + /// Retrieves an instance of for a GitHub Copilot client. + /// + /// The to use for the agent. + /// Optional session configuration for the agent. + /// Whether the agent owns the client and should dispose it. Default is false. + /// The unique identifier for the agent. + /// The name of the agent. + /// The description of the agent. + /// An instance backed by the GitHub Copilot client. + public static AIAgent AsAIAgent( + this CopilotClient client, + SessionConfig? sessionConfig = null, + bool ownsClient = false, + string? id = null, + string? name = null, + string? description = null) + { + Throw.IfNull(client); + + return new GithubCopilotAgent(client, sessionConfig, ownsClient, id, name, description); + } + + /// + /// Retrieves an instance of for a GitHub Copilot client. + /// + /// The to use for the agent. + /// Whether the agent owns the client and should dispose it. Default is false. + /// The unique identifier for the agent. + /// The name of the agent. + /// The description of the agent. + /// The tools to make available to the agent. + /// Optional instructions to append as a system message. + /// An instance backed by the GitHub Copilot client. + public static AIAgent AsAIAgent( + this CopilotClient client, + bool ownsClient = false, + string? id = null, + string? name = null, + string? description = null, + IList? tools = null, + string? instructions = null) + { + Throw.IfNull(client); + + return new GithubCopilotAgent(client, ownsClient, id, name, description, tools, instructions); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotAgent.cs new file mode 100644 index 0000000000..481410b835 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotAgent.cs @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.GithubCopilot; + +/// +/// Represents an that uses the GitHub Copilot SDK to provide agentic capabilities. +/// +public sealed class GithubCopilotAgent : AIAgent, IAsyncDisposable +{ + private const string DefaultName = "GitHub Copilot Agent"; + private const string DefaultDescription = "An AI agent powered by GitHub Copilot"; + + private readonly CopilotClient _copilotClient; + private readonly string? _id; + private readonly string _name; + private readonly string _description; + private readonly SessionConfig? _sessionConfig; + private readonly bool _ownsClient; + + /// + /// Initializes a new instance of the class. + /// + /// The Copilot client to use for interacting with GitHub Copilot. + /// Optional session configuration for the agent. + /// Whether the agent owns the client and should dispose it. Default is false. + /// The unique identifier for the agent. + /// The name of the agent. + /// The description of the agent. + public GithubCopilotAgent( + CopilotClient copilotClient, + SessionConfig? sessionConfig = null, + bool ownsClient = false, + string? id = null, + string? name = null, + string? description = null) + { + _ = Throw.IfNull(copilotClient); + + this._copilotClient = copilotClient; + this._sessionConfig = sessionConfig; + this._ownsClient = ownsClient; + this._id = id; + this._name = name ?? DefaultName; + this._description = description ?? DefaultDescription; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Copilot client to use for interacting with GitHub Copilot. + /// Whether the agent owns the client and should dispose it. Default is false. + /// The unique identifier for the agent. + /// The name of the agent. + /// The description of the agent. + /// The tools to make available to the agent. + /// Optional instructions to append as a system message. + public GithubCopilotAgent( + CopilotClient copilotClient, + bool ownsClient = false, + string? id = null, + string? name = null, + string? description = null, + IList? tools = null, + string? instructions = null) + : this( + copilotClient, + GetSessionConfig(tools, instructions), + ownsClient, + id, + name, + description) + { + } + + /// + public sealed override ValueTask GetNewSessionAsync(CancellationToken cancellationToken = default) + => new(new GithubCopilotAgentSession()); + + /// + /// Get a new instance using an existing session id, to continue that conversation. + /// + /// The session id to continue. + /// A new instance. + public ValueTask GetNewSessionAsync(string sessionId) + => new(new GithubCopilotAgentSession() { SessionId = sessionId }); + + /// + public override ValueTask DeserializeSessionAsync( + JsonElement serializedSession, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new GithubCopilotAgentSession(serializedSession, jsonSerializerOptions)); + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Ensure we have a valid session + session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false); + if (session is not GithubCopilotAgentSession typedSession) + { + throw new InvalidOperationException( + $"The provided session type {session.GetType()} is not compatible with the agent. Only GitHub Copilot agent created sessions are supported."); + } + + // Ensure the client is started + await this.EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false); + + // Create or resume a session with streaming enabled + SessionConfig sessionConfig = this._sessionConfig != null + ? new SessionConfig + { + Model = this._sessionConfig.Model, + Tools = this._sessionConfig.Tools, + SystemMessage = this._sessionConfig.SystemMessage, + AvailableTools = this._sessionConfig.AvailableTools, + ExcludedTools = this._sessionConfig.ExcludedTools, + Provider = this._sessionConfig.Provider, + OnPermissionRequest = this._sessionConfig.OnPermissionRequest, + McpServers = this._sessionConfig.McpServers, + CustomAgents = this._sessionConfig.CustomAgents, + SkillDirectories = this._sessionConfig.SkillDirectories, + DisabledSkills = this._sessionConfig.DisabledSkills, + Streaming = true + } + : new SessionConfig { Streaming = true }; + + CopilotSession copilotSession; + if (typedSession.SessionId is not null) + { + copilotSession = await this._copilotClient.ResumeSessionAsync( + typedSession.SessionId, + this.CreateResumeConfig(), + cancellationToken).ConfigureAwait(false); + } + else + { + copilotSession = await this._copilotClient.CreateSessionAsync(sessionConfig, cancellationToken).ConfigureAwait(false); + typedSession.SessionId = copilotSession.SessionId; + } + + try + { + Channel channel = Channel.CreateUnbounded(); + + // Subscribe to session events + using IDisposable subscription = copilotSession.On(evt => + { + switch (evt) + { + case AssistantMessageDeltaEvent deltaEvent: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(deltaEvent)); + break; + + case AssistantMessageEvent assistantMessage: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage)); + break; + + case AssistantUsageEvent usageEvent: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent)); + break; + + case SessionIdleEvent idleEvent: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(idleEvent)); + channel.Writer.TryComplete(); + break; + + case SessionErrorEvent errorEvent: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(errorEvent)); + channel.Writer.TryComplete(new InvalidOperationException( + $"Session error: {errorEvent.Data?.Message ?? "Unknown error"}")); + break; + + default: + // Handle all other event types by storing as RawRepresentation + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(evt)); + break; + } + }); + + List tempFiles = []; + try + { + // Build prompt from text content + string prompt = string.Join("\n", messages.Select(m => m.Text)); + + // Handle DataContent as attachments + List? attachments = await ProcessDataContentAttachmentsAsync( + messages, + tempFiles, + cancellationToken).ConfigureAwait(false); + + // Send the message with attachments + MessageOptions messageOptions = new() { Prompt = prompt }; + if (attachments is not null) + { + messageOptions.Attachments = [.. attachments]; + } + + await copilotSession.SendAsync(messageOptions, cancellationToken).ConfigureAwait(false); + // Yield updates as they arrive + await foreach (AgentResponseUpdate update in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + finally + { + CleanupTempFiles(tempFiles); + } + } + finally + { + await copilotSession.DisposeAsync().ConfigureAwait(false); + } + } + + /// + protected override string? IdCore => this._id; + + /// + public override string Name => this._name; + + /// + public override string Description => this._description; + + /// + /// Disposes the agent and releases resources. + /// + /// A value task representing the asynchronous dispose operation. + public async ValueTask DisposeAsync() + { + if (this._ownsClient) + { + await this._copilotClient.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task EnsureClientStartedAsync(CancellationToken cancellationToken) + { + if (this._copilotClient.State != ConnectionState.Connected) + { + await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false); + } + } + + private ResumeSessionConfig CreateResumeConfig() + { + return new ResumeSessionConfig + { + Tools = this._sessionConfig?.Tools, + Provider = this._sessionConfig?.Provider, + OnPermissionRequest = this._sessionConfig?.OnPermissionRequest, + McpServers = this._sessionConfig?.McpServers, + CustomAgents = this._sessionConfig?.CustomAgents, + SkillDirectories = this._sessionConfig?.SkillDirectories, + DisabledSkills = this._sessionConfig?.DisabledSkills, + Streaming = true + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent) + { + TextContent textContent = new(deltaEvent.Data?.DeltaContent ?? string.Empty) + { + RawRepresentation = deltaEvent + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [textContent]) + { + AgentId = this.Id, + MessageId = deltaEvent.Data?.MessageId, + CreatedAt = deltaEvent.Timestamp + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage) + { + TextContent textContent = new(assistantMessage.Data?.Content ?? string.Empty) + { + RawRepresentation = assistantMessage + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [textContent]) + { + AgentId = this.Id, + ResponseId = assistantMessage.Data?.MessageId, + MessageId = assistantMessage.Data?.MessageId, + CreatedAt = assistantMessage.Timestamp + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent) + { + UsageDetails usageDetails = new() + { + InputTokenCount = (int?)(usageEvent.Data?.InputTokens), + OutputTokenCount = (int?)(usageEvent.Data?.OutputTokens), + TotalTokenCount = (int?)((usageEvent.Data?.InputTokens ?? 0) + (usageEvent.Data?.OutputTokens ?? 0)), + CachedInputTokenCount = (int?)(usageEvent.Data?.CacheReadTokens), + AdditionalCounts = GetAdditionalCounts(usageEvent), + }; + + UsageContent usageContent = new(usageDetails) + { + RawRepresentation = usageEvent + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [usageContent]) + { + AgentId = this.Id, + CreatedAt = usageEvent.Timestamp + }; + } + + private static AdditionalPropertiesDictionary? GetAdditionalCounts(AssistantUsageEvent usageEvent) + { + if (usageEvent.Data is null) + { + return null; + } + + AdditionalPropertiesDictionary? additionalCounts = null; + + if (usageEvent.Data.CacheWriteTokens is double cacheWriteTokens) + { + additionalCounts ??= []; + additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = (long)cacheWriteTokens; + } + + if (usageEvent.Data.Cost is double cost) + { + additionalCounts ??= []; + additionalCounts[nameof(AssistantUsageData.Cost)] = (long)cost; + } + + if (usageEvent.Data.Duration is double duration) + { + additionalCounts ??= []; + additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration; + } + + return additionalCounts; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent) + { + // Handle arbitrary events by storing as RawRepresentation + AIContent content = new() + { + RawRepresentation = sessionEvent + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [content]) + { + AgentId = this.Id, + CreatedAt = sessionEvent.Timestamp + }; + } + + private static SessionConfig? GetSessionConfig(IList? tools, string? instructions) + { + List? mappedTools = tools is { Count: > 0 } ? tools.OfType().ToList() : null; + SystemMessageConfig? systemMessage = instructions is not null ? new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = instructions } : null; + + if (mappedTools is null && systemMessage is null) + { + return null; + } + + return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage }; + } + + private static readonly Dictionary s_mediaTypeExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/jpg"] = ".jpg", + ["image/gif"] = ".gif", + ["image/webp"] = ".webp", + ["image/svg+xml"] = ".svg", + ["text/plain"] = ".txt", + ["text/html"] = ".html", + ["text/markdown"] = ".md", + ["application/json"] = ".json", + ["application/xml"] = ".xml", + ["application/pdf"] = ".pdf" + }; + + private static string GetExtensionForMediaType(string? mediaType) + { + return mediaType is not null && s_mediaTypeExtensions.TryGetValue(mediaType, out string? extension) ? extension : ".dat"; + } + + private static async Task?> ProcessDataContentAttachmentsAsync( + IEnumerable messages, + List tempFiles, + CancellationToken cancellationToken) + { + List? attachments = null; + foreach (ChatMessage message in messages) + { + foreach (AIContent content in message.Contents) + { + if (content is DataContent dataContent) + { + // Write DataContent to a temp file + string tempFilePath = Path.Combine(Path.GetTempPath(), $"agentframework_copilot_data_{Guid.NewGuid()}{GetExtensionForMediaType(dataContent.MediaType)}"); + await File.WriteAllBytesAsync(tempFilePath, dataContent.Data.ToArray(), cancellationToken).ConfigureAwait(false); + tempFiles.Add(tempFilePath); + + // Create attachment + attachments ??= []; + attachments.Add(new UserMessageDataAttachmentsItem + { + Type = UserMessageDataAttachmentsItemType.File, + Path = tempFilePath, + DisplayName = Path.GetFileName(tempFilePath) + }); + } + } + } + + return attachments; + } + + private static void CleanupTempFiles(List tempFiles) + { + foreach (string tempFile in tempFiles) + { + try + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + catch + { + // Best effort cleanup + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotAgentSession.cs b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotAgentSession.cs new file mode 100644 index 0000000000..3c7d9a6598 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotAgentSession.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.GithubCopilot; + +/// +/// Represents a session for a GitHub Copilot agent conversation. +/// +public sealed class GithubCopilotAgentSession : AgentSession +{ + /// + /// Gets or sets the session ID for the GitHub Copilot conversation. + /// + public string? SessionId { get; internal set; } + + /// + /// Initializes a new instance of the class. + /// + internal GithubCopilotAgentSession() + { + } + + /// + /// Initializes a new instance of the class from serialized data. + /// + /// The serialized thread data. + /// Optional JSON serialization options. + internal GithubCopilotAgentSession(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + // The JSON serialization uses camelCase + if (serializedThread.TryGetProperty("sessionId", out JsonElement sessionIdElement)) + { + this.SessionId = sessionIdElement.GetString(); + } + } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + State state = new() + { + SessionId = this.SessionId + }; + + return JsonSerializer.SerializeToElement( + state, + GithubCopilotJsonUtilities.DefaultOptions.GetTypeInfo(typeof(State))); + } + + internal sealed class State + { + public string? SessionId { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotJsonUtilities.cs new file mode 100644 index 0000000000..c5a6e24d8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/GithubCopilotJsonUtilities.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.GithubCopilot; + +/// +/// Provides utility methods and configurations for JSON serialization operations within the GitHub Copilot agent implementation. +/// +internal static partial class GithubCopilotJsonUtilities +{ + /// + /// Gets the default instance used for JSON serialization operations. + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates and configures the default JSON serialization options. + /// + /// The configured options. + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + // Chain in the resolvers from both AgentAbstractionsJsonUtilities and our source generated context. + options.TypeInfoResolverChain.Clear(); + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.TypeInfoResolverChain.Add(JsonContext.Default.Options.TypeInfoResolver!); + + options.MakeReadOnly(); + return options; + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] + [JsonSerializable(typeof(GithubCopilotAgentSession.State))] + [ExcludeFromCodeCoverage] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Agents.AI.GithubCopilot/Microsoft.Agents.AI.GithubCopilot.csproj b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/Microsoft.Agents.AI.GithubCopilot.csproj new file mode 100644 index 0000000000..f4f79c27bd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GithubCopilot/Microsoft.Agents.AI.GithubCopilot.csproj @@ -0,0 +1,30 @@ + + + + preview + + $(TargetFrameworksCore) + + + + true + true + + + + + + + + + + + + + + + Microsoft Agent Framework GitHub Copilot + Provides Microsoft Agent Framework support for GitHub Copilot SDK. + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests/GithubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests/GithubCopilotAgentTests.cs new file mode 100644 index 0000000000..2ae499c479 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests/GithubCopilotAgentTests.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.GithubCopilot.IntegrationTests; + +public class GithubCopilotAgentTests +{ + private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only."; + + private static Task OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation) + => Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + await using GithubCopilotAgent agent = new(client, sessionConfig: null); + + // Act + AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number."); + + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.Contains("4", response.Text); + } + + [Fact(Skip = SkipReason)] + public async Task RunStreamingAsync_WithSimplePrompt_ReturnsUpdatesAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + await using GithubCopilotAgent agent = new(client, sessionConfig: null); + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.")) + { + updates.Add(update); + } + + // Assert + Assert.NotEmpty(updates); + string fullText = string.Join("", updates.Select(u => u.Text)); + Assert.Contains("4", fullText); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithFunctionTool_InvokesToolAsync() + { + // Arrange + bool toolInvoked = false; + + AIFunction weatherTool = AIFunctionFactory.Create((string location) => + { + toolInvoked = true; + return $"The weather in {location} is sunny with a high of 25C."; + }, "GetWeather", "Get the weather for a given location."); + + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + await using GithubCopilotAgent agent = new( + client, + tools: [weatherTool], + instructions: "You are a helpful weather agent. Use the GetWeather tool to answer weather questions."); + + // Act + AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?"); + + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.True(toolInvoked); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithSession_MaintainsContextAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + await using GithubCopilotAgent agent = new( + client, + instructions: "You are a helpful assistant. Keep your answers short."); + + AgentSession session = await agent.GetNewSessionAsync(); + + // Act - First turn + AgentResponse response1 = await agent.RunAsync("My name is Alice.", session); + Assert.NotNull(response1); + + // Act - Second turn using same session + AgentResponse response2 = await agent.RunAsync("What is my name?", session); + + // Assert + Assert.NotNull(response2); + Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithSessionResume_ContinuesConversationAsync() + { + // Arrange - First agent instance starts a conversation + string? sessionId; + + await using CopilotClient client1 = new(new CopilotClientOptions()); + await client1.StartAsync(); + + await using GithubCopilotAgent agent1 = new( + client1, + instructions: "You are a helpful assistant. Keep your answers short."); + + AgentSession session1 = await agent1.GetNewSessionAsync(); + await agent1.RunAsync("Remember this number: 42.", session1); + + sessionId = ((GithubCopilotAgentSession)session1).SessionId; + Assert.NotNull(sessionId); + + // Act - Second agent instance resumes the session + await using CopilotClient client2 = new(new CopilotClientOptions()); + await client2.StartAsync(); + + await using GithubCopilotAgent agent2 = new( + client2, + instructions: "You are a helpful assistant. Keep your answers short."); + + AgentSession session2 = await agent2.GetNewSessionAsync(sessionId); + AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2); + + // Assert + Assert.NotNull(response); + Assert.Contains("42", response.Text); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + SessionConfig sessionConfig = new() + { + OnPermissionRequest = OnPermissionRequestAsync, + }; + + await using GithubCopilotAgent agent = new(client, sessionConfig); + + // Act + AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'"); + + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithUrlPermissions_FetchesContentAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + SessionConfig sessionConfig = new() + { + OnPermissionRequest = OnPermissionRequestAsync, + }; + + await using GithubCopilotAgent agent = new(client, sessionConfig); + + // Act + AgentResponse response = await agent.RunAsync( + "Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence"); + + // Assert + Assert.NotNull(response); + Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + SessionConfig sessionConfig = new() + { + OnPermissionRequest = OnPermissionRequestAsync, + McpServers = new Dictionary + { + ["filesystem"] = new McpLocalServerConfig + { + Type = "stdio", + Command = "npx", + Args = ["-y", "@modelcontextprotocol/server-filesystem", "."], + Tools = ["*"], + }, + }, + }; + + await using GithubCopilotAgent agent = new(client, sessionConfig); + + // Act + AgentResponse response = await agent.RunAsync("List the files in the current directory"); + + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.NotEmpty(response.Text); + } + + [Fact(Skip = SkipReason)] + public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync() + { + // Arrange + await using CopilotClient client = new(new CopilotClientOptions()); + await client.StartAsync(); + + SessionConfig sessionConfig = new() + { + OnPermissionRequest = OnPermissionRequestAsync, + McpServers = new Dictionary + { + ["microsoft-learn"] = new McpRemoteServerConfig + { + Type = "http", + Url = "https://learn.microsoft.com/api/mcp", + Tools = ["*"], + }, + }, + }; + + await using GithubCopilotAgent agent = new(client, sessionConfig); + + // Act + AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result"); + + // Assert + Assert.NotNull(response); + Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests.csproj new file mode 100644 index 0000000000..b51b09f284 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests/Microsoft.Agents.AI.GithubCopilot.IntegrationTests.csproj @@ -0,0 +1,12 @@ + + + + + $(TargetFrameworksCore) + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/CopilotClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/CopilotClientExtensionsTests.cs new file mode 100644 index 0000000000..5c3483f4b2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/CopilotClientExtensionsTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.GithubCopilot.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class CopilotClientExtensionsTests +{ + [Fact] + public void AsAIAgent_WithAllParameters_ReturnsGithubCopilotAgentWithSpecifiedProperties() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + + const string TestId = "test-agent-id"; + const string TestName = "Test Agent"; + const string TestDescription = "This is a test agent description"; + + // Act + var agent = copilotClient.AsAIAgent(ownsClient: false, id: TestId, name: TestName, description: TestDescription, tools: null); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal(TestId, agent.Id); + Assert.Equal(TestName, agent.Name); + Assert.Equal(TestDescription, agent.Description); + } + + [Fact] + public void AsAIAgent_WithMinimalParameters_ReturnsGithubCopilotAgent() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + + // Act + var agent = copilotClient.AsAIAgent(ownsClient: false, tools: null); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + [Fact] + public void AsAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + CopilotClient? copilotClient = null; + + // Act & Assert + Assert.Throws(() => copilotClient!.AsAIAgent(sessionConfig: null)); + } + + [Fact] + public void AsAIAgent_WithOwnsClient_ReturnsAgentThatOwnsClient() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + + // Act + var agent = copilotClient.AsAIAgent(ownsClient: true, tools: null); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } + + [Fact] + public void AsAIAgent_WithTools_ReturnsAgentWithTools() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + List tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")]; + + // Act + var agent = copilotClient.AsAIAgent(tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/GithubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/GithubCopilotAgentTests.cs new file mode 100644 index 0000000000..90b84fb3bd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/GithubCopilotAgentTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.GithubCopilot.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class GithubCopilotAgentTests +{ + [Fact] + public void Constructor_WithCopilotClient_InitializesPropertiesCorrectly() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + const string TestId = "test-id"; + const string TestName = "test-name"; + const string TestDescription = "test-description"; + + // Act + var agent = new GithubCopilotAgent(copilotClient, ownsClient: false, id: TestId, name: TestName, description: TestDescription, tools: null); + + // Assert + Assert.Equal(TestId, agent.Id); + Assert.Equal(TestName, agent.Name); + Assert.Equal(TestDescription, agent.Description); + } + + [Fact] + public void Constructor_WithNullCopilotClient_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new GithubCopilotAgent(copilotClient: null!, sessionConfig: null)); + } + + [Fact] + public void Constructor_WithDefaultParameters_UsesBaseProperties() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + + // Act + var agent = new GithubCopilotAgent(copilotClient, ownsClient: false, tools: null); + + // Assert + Assert.NotNull(agent.Id); + Assert.NotEmpty(agent.Id); + Assert.Equal("GitHub Copilot Agent", agent.Name); + Assert.Equal("An AI agent powered by GitHub Copilot", agent.Description); + } + + [Fact] + public async Task GetNewSessionAsync_ReturnsGithubCopilotAgentSessionAsync() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GithubCopilotAgent(copilotClient, ownsClient: false, tools: null); + + // Act + var session = await agent.GetNewSessionAsync(); + + // Assert + Assert.NotNull(session); + Assert.IsType(session); + } + + [Fact] + public async Task GetNewSessionAsync_WithSessionId_ReturnsSessionWithSessionIdAsync() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GithubCopilotAgent(copilotClient, ownsClient: false, tools: null); + const string TestSessionId = "test-session-id"; + + // Act + var session = await agent.GetNewSessionAsync(TestSessionId); + + // Assert + Assert.NotNull(session); + var typedSession = Assert.IsType(session); + Assert.Equal(TestSessionId, typedSession.SessionId); + } + + [Fact] + public void Constructor_WithTools_InitializesCorrectly() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + List tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")]; + + // Act + var agent = new GithubCopilotAgent(copilotClient, tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(agent.Id); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/Microsoft.Agents.AI.GithubCopilot.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/Microsoft.Agents.AI.GithubCopilot.UnitTests.csproj new file mode 100644 index 0000000000..ef8527302e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GithubCopilot.UnitTests/Microsoft.Agents.AI.GithubCopilot.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + + $(TargetFrameworksCore) + + + + + + +