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>
This commit is contained in:
David Kaya
2026-03-27 20:58:31 +01:00
co-authored by Copilot
parent 85a9327f65
commit 7247a68f24
2 changed files with 134 additions and 2 deletions
@@ -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<IAsyncDisposable> _disposables = [];
private CopilotAgentBundle(IReadOnlyList<AIAgent> 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();
}
}
@@ -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<string, object>(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<ChatClientAgentRunOptions>(
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";