mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-05 11:28:40 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user