fix: restore real Copilot handoffs

Replace the broken forced-tool workaround with a repo-local Copilot
agent adapter that merges runtime handoff instructions and tool
declarations into Copilot sessions and projects Copilot tool requests
back into Agent Framework function-call updates.

Also add regression coverage for the adapter and document the runtime
integration detail in the architecture guide.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 21:07:58 +01:00
co-authored by Copilot
parent 7247a68f24
commit 9d65e3d209
4 changed files with 610 additions and 104 deletions
@@ -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<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new AryxCopilotAgentSession());
protected override ValueTask<JsonElement> 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<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default)
=> new(AryxCopilotAgentSession.Deserialize(serializedState, jsonSerializerOptions));
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> 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<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
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<UserMessageDataAttachmentsItem>? 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<FunctionCallContent> ConvertToolRequestsToFunctionCalls(
AssistantMessageDataToolRequestsItem[]? toolRequests)
{
if (toolRequests is not { Length: > 0 })
{
return [];
}
List<FunctionCallContent> 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<string, object>(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<string, SectionOverride>(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<AIFunction>? MergeTools(
ICollection<AIFunction>? sessionTools,
IList<AITool>? runtimeTools)
{
if (runtimeTools is not { Count: > 0 })
{
return sessionTools;
}
List<AIFunction> 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<string, object?>
{
["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<AIContent> 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<string, object?>? ParseToolArguments(object? arguments)
{
if (arguments is null)
{
return null;
}
if (arguments is Dictionary<string, object?> dictionary)
{
return new Dictionary<string, object?>(dictionary, StringComparer.Ordinal);
}
if (arguments is JsonElement jsonElement)
{
if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return null;
}
return JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonElement.GetRawText());
}
string json = JsonSerializer.Serialize(arguments, arguments.GetType());
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
}
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<UserMessageDataAttachmentsItem>? 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<AryxCopilotAgentSession>(options)
?? new AryxCopilotAgentSession();
}
}
@@ -11,7 +11,6 @@ namespace Aryx.AgentHost.Services;
internal sealed class CopilotAgentBundle : IAsyncDisposable
{
private const string HandoffToolPrefix = "handoff_to_";
private readonly List<IAsyncDisposable> _disposables = [];
private CopilotAgentBundle(IReadOnlyList<AIAgent> 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();
}
}
@@ -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<string, object>(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<IEnumerable<AIFunction>>(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<ChatClientAgentRunOptions>(
CopilotAgentBundle.RequireInitialHandoffToolMode(options));
Assert.NotSame(options, constrained);
Assert.Null(options.ChatOptions?.ToolMode);
Assert.Equal(ChatToolMode.RequireAny, constrained.ChatOptions?.ToolMode);
Assert.Throws<NotSupportedException>(() => 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<string, object?>
{
["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