From 7247a68f24fe2b7e509e2f3ae4fa7b0b4f23e11d Mon Sep 17 00:00:00 2001 From: David Kaya Date: Fri, 27 Mar 2026 20:58:31 +0100 Subject: [PATCH] fix: enforce real entry handoffs Constrain the initial handoff triage agent so it cannot use regular session tooling and must use a handoff tool on its first invocation. This prevents the model from narrating delegation in plain text while never actually transferring control. Also add regression tests for the new entry-agent constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotAgentBundle.cs | 71 ++++++++++++++++++- .../CopilotAgentBundleTests.cs | 65 +++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index f4f0f94..a86f5d4 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -1,3 +1,4 @@ +using System.Threading; using GitHub.Copilot.SDK; using Aryx.AgentHost.Contracts; using Microsoft.Agents.AI; @@ -10,6 +11,7 @@ namespace Aryx.AgentHost.Services; internal sealed class CopilotAgentBundle : IAsyncDisposable { + private const string HandoffToolPrefix = "handoff_to_"; private readonly List _disposables = []; private CopilotAgentBundle(IReadOnlyList agents) @@ -64,7 +66,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable Streaming = true, }; - ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools); + if (IsInitialHandoffAgent(command.Pattern, agentIndex)) + { + ApplyInitialHandoffEntryConstraints(sessionConfig); + } + else + { + ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools); + } GitHubCopilotAgent agent = new( client, @@ -99,6 +108,29 @@ 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 @@ -131,7 +163,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable definition => definition, StringComparer.Ordinal); PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern); - AIAgent entryAgent = agentMap.GetValueOrDefault(topology.EntryAgentId) ?? Agents[0]; + 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; + } HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent) .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); @@ -197,4 +236,32 @@ 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 f514cfa..465de78 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Microsoft.Agents.AI; using Aryx.AgentHost.Services; using GitHub.Copilot.SDK; using Microsoft.Extensions.AI; @@ -50,6 +51,59 @@ public sealed class CopilotAgentBundleTests Assert.Equal(["glob", "view"], sessionConfig.AvailableTools); } + [Fact] + public void ApplyInitialHandoffEntryConstraints_DisablesCopilotToolsAndClearsSessionTooling() + { + SessionConfig sessionConfig = new() + { + AvailableTools = ["glob", "view"], + ExcludedTools = ["edit"], + McpServers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Git MCP"] = new McpLocalServerConfig + { + Type = "local", + Command = "node", + }, + }, + Tools = [CreateTool()], + }; + + CopilotAgentBundle.ApplyInitialHandoffEntryConstraints(sessionConfig); + + Assert.Empty(sessionConfig.AvailableTools); + Assert.Null(sessionConfig.ExcludedTools); + Assert.Null(sessionConfig.McpServers); + Assert.Null(sessionConfig.Tools); + } + + [Fact] + public void RequireInitialHandoffToolMode_RequiresAToolWhenHandoffToolsArePresent() + { + ChatClientAgentRunOptions options = new(new ChatOptions + { + Tools = [CreateHandoffTool(), CreateTool()], + }); + + ChatClientAgentRunOptions constrained = Assert.IsType( + CopilotAgentBundle.RequireInitialHandoffToolMode(options)); + + Assert.NotSame(options, constrained); + Assert.Null(options.ChatOptions?.ToolMode); + Assert.Equal(ChatToolMode.RequireAny, constrained.ChatOptions?.ToolMode); + } + + [Fact] + public void RequireInitialHandoffToolMode_LeavesNonHandoffOptionsUnchanged() + { + ChatClientAgentRunOptions options = new(new ChatOptions + { + Tools = [CreateTool()], + }); + + Assert.Same(options, CopilotAgentBundle.RequireInitialHandoffToolMode(options)); + } + private static AIFunction CreateTool() { ToolTarget target = new(); @@ -66,6 +120,17 @@ public sealed class CopilotAgentBundleTests }); } + private static AIFunction CreateHandoffTool() + { + return AIFunctionFactory.Create( + (string reasonForHandoff) => $"Handed off because {reasonForHandoff}.", + new AIFunctionFactoryOptions + { + Name = "handoff_to_1", + Description = "Transfer ownership to a specialist", + }); + } + private sealed class ToolTarget { public string Echo() => "ok";