From d7004ec2a9ef4799553e5415c35ec8f0189d85a4 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 1 Apr 2026 19:04:23 +0200 Subject: [PATCH] fix: make handoff filtering explicit Explicitly configure handoff workflows to use HandoffToolCallFilteringBehavior.HandoffOnly instead of relying on Agent Framework's current default. This keeps normal tool-call history visible across handoffs while still stripping handoff plumbing, and adds a regression test to pin that behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotAgentBundle.cs | 13 +++- .../CopilotAgentBundleTests.cs | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index 057e271..f0620b8 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -222,8 +222,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable : pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId; AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0]; - HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent) - .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); + HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(entryAgent); foreach (PatternHandoffRoute route in topology.Routes) { @@ -250,6 +249,16 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable return builder.Build(); } + internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent) + { + return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent) + // Preserve normal tool-call history across handoffs while still hiding the + // workflow's handoff plumbing. Make this explicit so AF default changes + // cannot silently alter Aryx handoff behavior. + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly) + .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); + } + private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern) { int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations; diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index bc909a9..a19ae80 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -4,6 +4,7 @@ using Aryx.AgentHost.Contracts; using GitHub.Copilot.SDK; using Aryx.AgentHost.Services; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; namespace Aryx.AgentHost.Tests; @@ -111,6 +112,24 @@ public sealed class CopilotAgentBundleTests Assert.Throws(() => AryxCopilotAgent.CreateConfiguredSessionConfig(new SessionConfig(), options)); } + [Fact] + public void CreateHandoffWorkflowBuilder_ExplicitlyUsesHandoffOnlyFiltering() + { + ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary"); + + HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent); + + FieldInfo field = typeof(HandoffsWorkflowBuilder).GetField( + "_toolCallFilteringBehavior", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field."); + + HandoffToolCallFilteringBehavior behavior = Assert.IsType(field.GetValue(builder)); + + Assert.Equal(HandoffToolCallFilteringBehavior.HandoffOnly, behavior); + Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions); + } + [Fact] public void ConvertToolRequestsToFunctionCalls_MapsCallIdsNamesAndArguments() { @@ -369,8 +388,48 @@ public sealed class CopilotAgentBundleTests CreateTool().JsonSchema); } + private static ChatClientAgent CreateChatClientAgent(string id, string name) + { + return new ChatClientAgent( + new StubChatClient(), + id, + name, + "Stub agent for handoff builder tests.", + [], + null!, + null!); + } + private sealed class ToolTarget { public string Echo() => "ok"; } + + private sealed class StubChatClient : IChatClient + { + public void Dispose() + { + } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } }