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>
This commit is contained in:
David Kaya
2026-04-01 19:04:23 +02:00
co-authored by Copilot
parent 0aed6240b9
commit d7004ec2a9
2 changed files with 70 additions and 2 deletions
@@ -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;
@@ -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<NotSupportedException>(() => 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<HandoffToolCallFilteringBehavior>(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<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
return null;
}
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
}
}