diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfc0fa1..4ee7e6d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -136,6 +136,8 @@ Patterns describe how agents collaborate. The architecture supports: Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input. +For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so handoff routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime. + Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution. Patterns now persist an explicit graph-backed topology alongside the flat agent list. Agent nodes carry stable agent ids, ordering, and layout metadata, while system nodes such as user input/output, distributor, collector, and orchestrator make mode-specific flow visible in the saved contract. diff --git a/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs b/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs new file mode 100644 index 0000000..6ea74e1 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs @@ -0,0 +1,549 @@ +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Channels; +using GitHub.Copilot.SDK; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Aryx.AgentHost.Services; + +internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable +{ + private const string DefaultName = "GitHub Copilot Agent"; + private const string DefaultDescription = "An AI agent powered by GitHub Copilot"; + private const string HandoffToolPrefix = "handoff_to_"; + private readonly CopilotClient _copilotClient; + private readonly string? _id; + private readonly string _name; + private readonly string _description; + private readonly SessionConfig? _sessionConfig; + private readonly bool _ownsClient; + + public AryxCopilotAgent( + CopilotClient copilotClient, + SessionConfig? sessionConfig = null, + bool ownsClient = false, + string? id = null, + string? name = null, + string? description = null) + { + _copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient)); + _sessionConfig = sessionConfig; + _ownsClient = ownsClient; + _id = id; + _name = name ?? DefaultName; + _description = description ?? DefaultDescription; + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new AryxCopilotAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + if (session is not AryxCopilotAgentSession typedSession) + { + throw new InvalidOperationException( + $"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(AryxCopilotAgentSession)}' can be serialized by this agent."); + } + + return new(typedSession.Serialize(jsonSerializerOptions)); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(AryxCopilotAgentSession.Deserialize(serializedState, jsonSerializerOptions)); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + + session ??= await CreateSessionAsync(cancellationToken).ConfigureAwait(false); + if (session is not AryxCopilotAgentSession typedSession) + { + throw new InvalidOperationException( + $"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(AryxCopilotAgentSession)}' can be used by this agent."); + } + + await EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false); + + SessionConfig sessionConfig = CreateConfiguredSessionConfig(_sessionConfig, options); + CopilotSession copilotSession; + if (typedSession.SessionId is not null) + { + copilotSession = await _copilotClient.ResumeSessionAsync( + typedSession.SessionId, + CreateResumeConfig(sessionConfig), + cancellationToken).ConfigureAwait(false); + } + else + { + copilotSession = await _copilotClient.CreateSessionAsync(sessionConfig, cancellationToken).ConfigureAwait(false); + typedSession.SessionId = copilotSession.SessionId; + } + + try + { + Channel channel = Channel.CreateUnbounded(); + + using IDisposable subscription = copilotSession.On(evt => + { + switch (evt) + { + case AssistantMessageDeltaEvent deltaEvent: + channel.Writer.TryWrite(ConvertToAgentResponseUpdate(deltaEvent)); + break; + + case AssistantMessageEvent assistantMessage: + channel.Writer.TryWrite(ConvertToAgentResponseUpdate(assistantMessage)); + break; + + case AssistantUsageEvent usageEvent: + channel.Writer.TryWrite(ConvertToAgentResponseUpdate(usageEvent)); + break; + + case SessionIdleEvent idleEvent: + channel.Writer.TryWrite(ConvertToAgentResponseUpdate(idleEvent)); + channel.Writer.TryComplete(); + break; + + case SessionErrorEvent errorEvent: + channel.Writer.TryWrite(ConvertToAgentResponseUpdate(errorEvent)); + channel.Writer.TryComplete(new InvalidOperationException( + $"Session error: {errorEvent.Data?.Message ?? "Unknown error"}")); + break; + + default: + channel.Writer.TryWrite(ConvertToAgentResponseUpdate(evt)); + break; + } + }); + + string? tempDir = null; + try + { + string prompt = string.Join("\n", messages.Select(message => message.Text)); + (List? attachments, tempDir) = await ProcessDataContentAttachmentsAsync( + messages, + cancellationToken).ConfigureAwait(false); + + MessageOptions messageOptions = new() { Prompt = prompt }; + if (attachments is not null) + { + messageOptions.Attachments = [.. attachments]; + } + + await copilotSession.SendAsync(messageOptions, cancellationToken).ConfigureAwait(false); + await foreach (AgentResponseUpdate update in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + finally + { + CleanupTempDir(tempDir); + } + } + finally + { + await copilotSession.DisposeAsync().ConfigureAwait(false); + } + } + + protected override string? IdCore => _id; + + public override string Name => _name; + + public override string Description => _description; + + public async ValueTask DisposeAsync() + { + if (_ownsClient) + { + await _copilotClient.DisposeAsync().ConfigureAwait(false); + } + } + + internal static SessionConfig CreateConfiguredSessionConfig(SessionConfig? source, AgentRunOptions? options) + { + SessionConfig sessionConfig = source?.Clone() ?? new SessionConfig(); + sessionConfig.Streaming = true; + if (sessionConfig.SystemMessage is not null) + { + sessionConfig.SystemMessage = CloneSystemMessage(sessionConfig.SystemMessage); + } + + if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions }) + { + return sessionConfig; + } + + if (!string.IsNullOrWhiteSpace(chatOptions.ModelId)) + { + sessionConfig.Model = chatOptions.ModelId; + } + + if (!string.IsNullOrWhiteSpace(chatOptions.Instructions)) + { + AppendInstructions(sessionConfig, chatOptions.Instructions); + } + + sessionConfig.Tools = MergeTools(sessionConfig.Tools, chatOptions.Tools); + return sessionConfig; + } + + internal static IReadOnlyList ConvertToolRequestsToFunctionCalls( + AssistantMessageDataToolRequestsItem[]? toolRequests) + { + if (toolRequests is not { Length: > 0 }) + { + return []; + } + + List contents = []; + foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests) + { + if (string.IsNullOrWhiteSpace(toolRequest.ToolCallId) || string.IsNullOrWhiteSpace(toolRequest.Name)) + { + continue; + } + + contents.Add(new FunctionCallContent( + toolRequest.ToolCallId, + toolRequest.Name, + ParseToolArguments(toolRequest.Arguments))); + } + + return contents; + } + + private async Task EnsureClientStartedAsync(CancellationToken cancellationToken) + { + if (_copilotClient.State != ConnectionState.Connected) + { + await _copilotClient.StartAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static ResumeSessionConfig CreateResumeConfig(SessionConfig source) + { + return new ResumeSessionConfig + { + ClientName = source.ClientName, + Model = source.Model, + Tools = source.Tools is not null ? [.. source.Tools] : null, + SystemMessage = CloneSystemMessage(source.SystemMessage), + AvailableTools = source.AvailableTools is not null ? [.. source.AvailableTools] : null, + ExcludedTools = source.ExcludedTools is not null ? [.. source.ExcludedTools] : null, + Provider = source.Provider, + OnPermissionRequest = source.OnPermissionRequest, + OnUserInputRequest = source.OnUserInputRequest, + Hooks = source.Hooks, + WorkingDirectory = source.WorkingDirectory, + ConfigDir = source.ConfigDir, + Streaming = true, + McpServers = source.McpServers is not null + ? new Dictionary(source.McpServers, source.McpServers.Comparer) + : null, + CustomAgents = source.CustomAgents is not null ? [.. source.CustomAgents] : null, + Agent = source.Agent, + SkillDirectories = source.SkillDirectories is not null ? [.. source.SkillDirectories] : null, + DisabledSkills = source.DisabledSkills is not null ? [.. source.DisabledSkills] : null, + InfiniteSessions = source.InfiniteSessions, + OnEvent = source.OnEvent, + ReasoningEffort = source.ReasoningEffort, + }; + } + + private static SystemMessageConfig? CloneSystemMessage(SystemMessageConfig? source) + { + if (source is null) + { + return null; + } + + return new SystemMessageConfig + { + Mode = source.Mode, + Content = source.Content, + Sections = source.Sections is not null ? new Dictionary(source.Sections) : null, + }; + } + + private static void AppendInstructions(SessionConfig sessionConfig, string instructions) + { + string trimmedInstructions = instructions.Trim(); + if (trimmedInstructions.Length == 0) + { + return; + } + + if (sessionConfig.SystemMessage is null) + { + sessionConfig.SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = trimmedInstructions, + }; + return; + } + + string? existingContent = sessionConfig.SystemMessage.Content; + sessionConfig.SystemMessage.Content = string.IsNullOrWhiteSpace(existingContent) + ? trimmedInstructions + : $"{existingContent.Trim()}\n\n{trimmedInstructions}"; + } + + private static ICollection? MergeTools( + ICollection? sessionTools, + IList? runtimeTools) + { + if (runtimeTools is not { Count: > 0 }) + { + return sessionTools; + } + + List mergedTools = sessionTools is not null ? [.. sessionTools] : []; + foreach (AITool runtimeTool in runtimeTools) + { + mergedTools.Add(MapRuntimeTool(runtimeTool)); + } + + return mergedTools; + } + + private static AIFunction MapRuntimeTool(AITool tool) + { + return tool switch + { + AIFunction function => function, + AIFunctionDeclaration declaration when IsHandoffDeclaration(declaration) => CreateInvokableHandoffFunction(declaration), + AIFunctionDeclaration declaration => throw new NotSupportedException( + $"GitHub Copilot session tools must be invokable AIFunctions. Runtime tool '{declaration.Name}' is declaration-only."), + _ => throw new NotSupportedException( + $"GitHub Copilot session tools must be invokable AIFunctions. Runtime tool '{tool.Name}' is not supported."), + }; + } + + private static bool IsHandoffDeclaration(AIFunctionDeclaration declaration) + { + return !string.IsNullOrWhiteSpace(declaration.Name) + && declaration.Name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal); + } + + private static AIFunction CreateInvokableHandoffFunction(AIFunctionDeclaration declaration) + { + AIFunction function = AIFunctionFactory.Create( + (string? reasonForHandoff) => "Transferred.", + new AIFunctionFactoryOptions + { + Name = declaration.Name, + Description = declaration.Description, + AdditionalProperties = new Dictionary + { + ["skip_permission"] = true, + }, + }); + return function; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent) + { + TextContent textContent = new(deltaEvent.Data?.DeltaContent ?? string.Empty) + { + RawRepresentation = deltaEvent, + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [textContent]) + { + AgentId = Id, + MessageId = deltaEvent.Data?.MessageId, + CreatedAt = deltaEvent.Timestamp, + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage) + { + List contents = []; + contents.AddRange(ConvertToolRequestsToFunctionCalls(assistantMessage.Data?.ToolRequests)); + contents.Add(new AIContent + { + RawRepresentation = assistantMessage, + }); + + return new AgentResponseUpdate(ChatRole.Assistant, contents) + { + AgentId = 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, + }; + + UsageContent usageContent = new(usageDetails) + { + RawRepresentation = usageEvent, + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [usageContent]) + { + AgentId = Id, + CreatedAt = usageEvent.Timestamp, + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent) + { + AIContent content = new() + { + RawRepresentation = sessionEvent, + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [content]) + { + AgentId = Id, + CreatedAt = sessionEvent.Timestamp, + }; + } + + private static Dictionary? ParseToolArguments(object? arguments) + { + if (arguments is null) + { + return null; + } + + if (arguments is Dictionary dictionary) + { + return new Dictionary(dictionary, StringComparer.Ordinal); + } + + if (arguments is JsonElement jsonElement) + { + if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + return JsonSerializer.Deserialize>(jsonElement.GetRawText()); + } + + string json = JsonSerializer.Serialize(arguments, arguments.GetType()); + return JsonSerializer.Deserialize>(json); + } + + private static async Task<(List? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( + IEnumerable messages, + CancellationToken cancellationToken) + { + List? attachments = null; + string? tempDir = null; + foreach (ChatMessage message in messages) + { + foreach (AIContent content in message.Contents) + { + if (content is not DataContent dataContent) + { + continue; + } + + tempDir ??= Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName; + + string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false); + + attachments ??= []; + attachments.Add(new UserMessageDataAttachmentsItemFile + { + Path = tempFilePath, + DisplayName = Path.GetFileName(tempFilePath), + }); + } + } + + return (attachments, tempDir); + } + + private static void CleanupTempDir(string? tempDir) + { + if (tempDir is null) + { + return; + } + + try + { + Directory.Delete(tempDir, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} + +internal sealed class AryxCopilotAgentSession : AgentSession +{ + public AryxCopilotAgentSession() + { + } + + [JsonConstructor] + public AryxCopilotAgentSession(string? sessionId, AgentSessionStateBag? stateBag = null) + : base(stateBag ?? new AgentSessionStateBag()) + { + SessionId = sessionId; + } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; set; } + + internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + JsonSerializerOptions options = jsonSerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); + return JsonSerializer.SerializeToElement(this, options); + } + + internal static AryxCopilotAgentSession Deserialize( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null) + { + if (serializedState.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState)); + } + + JsonSerializerOptions options = jsonSerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); + return serializedState.Deserialize(options) + ?? new AryxCopilotAgentSession(); + } +} diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index a86f5d4..bdf1752 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -11,7 +11,6 @@ namespace Aryx.AgentHost.Services; internal sealed class CopilotAgentBundle : IAsyncDisposable { - private const string HandoffToolPrefix = "handoff_to_"; private readonly List _disposables = []; private CopilotAgentBundle(IReadOnlyList agents) @@ -66,16 +65,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable Streaming = true, }; - if (IsInitialHandoffAgent(command.Pattern, agentIndex)) - { - ApplyInitialHandoffEntryConstraints(sessionConfig); - } - else - { - ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools); - } + ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools); - GitHubCopilotAgent agent = new( + AryxCopilotAgent agent = new( client, sessionConfig, ownsClient: true, @@ -108,29 +100,6 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable } } - internal static void ApplyInitialHandoffEntryConstraints(SessionConfig sessionConfig) - { - sessionConfig.AvailableTools = []; - sessionConfig.ExcludedTools = null; - sessionConfig.McpServers = null; - sessionConfig.Tools = null; - } - - internal static AgentRunOptions? RequireInitialHandoffToolMode(AgentRunOptions? options) - { - if (options is not ChatClientAgentRunOptions chatRunOptions - || chatRunOptions.ChatOptions?.Tools is not { Count: > 0 } tools - || !tools.Any(IsHandoffTool)) - { - return options; - } - - ChatClientAgentRunOptions constrainedOptions = (ChatClientAgentRunOptions)chatRunOptions.Clone(); - constrainedOptions.ChatOptions ??= new ChatOptions(); - constrainedOptions.ChatOptions.ToolMode ??= ChatToolMode.RequireAny; - return constrainedOptions; - } - public Workflow BuildWorkflow(PatternDefinitionDto pattern) { return pattern.Mode switch @@ -166,11 +135,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable string entryAgentId = agentMap.ContainsKey(topology.EntryAgentId) ? topology.EntryAgentId : pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId; - AIAgent entryAgent = WrapInitialHandoffEntryAgent(agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0]); - if (!string.IsNullOrWhiteSpace(entryAgentId)) - { - agentMap[entryAgentId] = entryAgent; - } + AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0]; HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent) .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); @@ -236,32 +201,4 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable return agentMap; } - - private static bool IsInitialHandoffAgent(PatternDefinitionDto pattern, int agentIndex) - { - return agentIndex == 0 - && string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsHandoffTool(AITool tool) - { - return !string.IsNullOrWhiteSpace(tool.Name) - && tool.Name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal); - } - - private static AIAgent WrapInitialHandoffEntryAgent(AIAgent agent) - { - int streamingInvocationCount = 0; - return agent.AsBuilder() - .Use( - runFunc: null, - runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) => - { - AgentRunOptions? effectiveOptions = Interlocked.Increment(ref streamingInvocationCount) == 1 - ? RequireInitialHandoffToolMode(options) - : options; - return innerAgent.RunStreamingAsync(messages, session, effectiveOptions, cancellationToken); - }) - .Build(); - } } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index 465de78..2eafa6b 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -1,7 +1,8 @@ using System.Reflection; -using Microsoft.Agents.AI; -using Aryx.AgentHost.Services; +using System.Text.Json; using GitHub.Copilot.SDK; +using Aryx.AgentHost.Services; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Aryx.AgentHost.Tests; @@ -52,56 +53,76 @@ public sealed class CopilotAgentBundleTests } [Fact] - public void ApplyInitialHandoffEntryConstraints_DisablesCopilotToolsAndClearsSessionTooling() + public async Task CreateConfiguredSessionConfig_MergesInstructionsAndConvertsHandoffDeclarations() { - SessionConfig sessionConfig = new() + SessionConfig baseConfig = new() { - AvailableTools = ["glob", "view"], - ExcludedTools = ["edit"], - McpServers = new Dictionary(StringComparer.OrdinalIgnoreCase) + Model = "gpt-5.4", + SystemMessage = new SystemMessageConfig { - ["Git MCP"] = new McpLocalServerConfig - { - Type = "local", - Command = "node", - }, + Content = "Base instructions", }, Tools = [CreateTool()], }; + ChatClientAgentRunOptions options = new(new ChatOptions + { + Instructions = "Workflow handoff instructions", + Tools = [CreateHandoffDeclaration()], + }); - CopilotAgentBundle.ApplyInitialHandoffEntryConstraints(sessionConfig); + SessionConfig effective = AryxCopilotAgent.CreateConfiguredSessionConfig(baseConfig, options); - Assert.Empty(sessionConfig.AvailableTools); - Assert.Null(sessionConfig.ExcludedTools); - Assert.Null(sessionConfig.McpServers); - Assert.Null(sessionConfig.Tools); + Assert.Equal("gpt-5.4", effective.Model); + Assert.Equal("Base instructions\n\nWorkflow handoff instructions", effective.SystemMessage?.Content); + Assert.Equal("Base instructions", baseConfig.SystemMessage?.Content); + + AIFunction[] tools = Assert.IsAssignableFrom>(effective.Tools).ToArray(); + Assert.Equal(2, tools.Length); + AIFunction handoffTool = Assert.Single(tools, tool => tool.Name == "handoff_to_1"); + Assert.True(handoffTool.AdditionalProperties.TryGetValue("skip_permission", out object? skipPermission)); + Assert.Equal(true, skipPermission); + + object? result = await handoffTool.InvokeAsync(new AIFunctionArguments + { + ["reasonForHandoff"] = "UI specialist", + }); + + Assert.Equal("Transferred.", result?.ToString()); } [Fact] - public void RequireInitialHandoffToolMode_RequiresAToolWhenHandoffToolsArePresent() + public void CreateConfiguredSessionConfig_RejectsUnsupportedRuntimeDeclarations() { ChatClientAgentRunOptions options = new(new ChatOptions { - Tools = [CreateHandoffTool(), CreateTool()], + Tools = [AIFunctionFactory.CreateDeclaration("route_elsewhere", "Unsupported declaration", CreateTool().JsonSchema)], }); - ChatClientAgentRunOptions constrained = Assert.IsType( - CopilotAgentBundle.RequireInitialHandoffToolMode(options)); - - Assert.NotSame(options, constrained); - Assert.Null(options.ChatOptions?.ToolMode); - Assert.Equal(ChatToolMode.RequireAny, constrained.ChatOptions?.ToolMode); + Assert.Throws(() => AryxCopilotAgent.CreateConfiguredSessionConfig(new SessionConfig(), options)); } [Fact] - public void RequireInitialHandoffToolMode_LeavesNonHandoffOptionsUnchanged() + public void ConvertToolRequestsToFunctionCalls_MapsCallIdsNamesAndArguments() { - ChatClientAgentRunOptions options = new(new ChatOptions + AssistantMessageDataToolRequestsItem[] toolRequests = { - Tools = [CreateTool()], - }); + new() + { + ToolCallId = "call-123", + Name = "handoff_to_1", + Arguments = JsonSerializer.SerializeToElement(new Dictionary + { + ["reasonForHandoff"] = "frontend specialist", + }), + }, + }; - Assert.Same(options, CopilotAgentBundle.RequireInitialHandoffToolMode(options)); + FunctionCallContent functionCall = Assert.Single(AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests)); + + Assert.Equal("call-123", functionCall.CallId); + Assert.Equal("handoff_to_1", functionCall.Name); + Assert.NotNull(functionCall.Arguments); + Assert.Equal("frontend specialist", functionCall.Arguments["reasonForHandoff"]?.ToString()); } private static AIFunction CreateTool() @@ -120,15 +141,12 @@ public sealed class CopilotAgentBundleTests }); } - private static AIFunction CreateHandoffTool() + private static AIFunctionDeclaration CreateHandoffDeclaration() { - return AIFunctionFactory.Create( - (string reasonForHandoff) => $"Handed off because {reasonForHandoff}.", - new AIFunctionFactoryOptions - { - Name = "handoff_to_1", - Description = "Transfer ownership to a specialist", - }); + return AIFunctionFactory.CreateDeclaration( + "handoff_to_1", + "Transfer ownership to a specialist", + CreateTool().JsonSchema); } private sealed class ToolTarget