mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
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:
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Threading;
|
||||||
using GitHub.Copilot.SDK;
|
using GitHub.Copilot.SDK;
|
||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
@@ -10,6 +11,7 @@ namespace Aryx.AgentHost.Services;
|
|||||||
|
|
||||||
internal sealed class CopilotAgentBundle : IAsyncDisposable
|
internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
private const string HandoffToolPrefix = "handoff_to_";
|
||||||
private readonly List<IAsyncDisposable> _disposables = [];
|
private readonly List<IAsyncDisposable> _disposables = [];
|
||||||
|
|
||||||
private CopilotAgentBundle(IReadOnlyList<AIAgent> agents)
|
private CopilotAgentBundle(IReadOnlyList<AIAgent> agents)
|
||||||
@@ -64,7 +66,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
Streaming = true,
|
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(
|
GitHubCopilotAgent agent = new(
|
||||||
client,
|
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)
|
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||||
{
|
{
|
||||||
return pattern.Mode switch
|
return pattern.Mode switch
|
||||||
@@ -131,7 +163,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
definition => definition,
|
definition => definition,
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
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)
|
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||||
@@ -197,4 +236,32 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
|
|
||||||
return agentMap;
|
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 System.Reflection;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
using Aryx.AgentHost.Services;
|
using Aryx.AgentHost.Services;
|
||||||
using GitHub.Copilot.SDK;
|
using GitHub.Copilot.SDK;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
@@ -50,6 +51,59 @@ public sealed class CopilotAgentBundleTests
|
|||||||
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
|
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()
|
private static AIFunction CreateTool()
|
||||||
{
|
{
|
||||||
ToolTarget target = new();
|
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
|
private sealed class ToolTarget
|
||||||
{
|
{
|
||||||
public string Echo() => "ok";
|
public string Echo() => "ok";
|
||||||
|
|||||||
Reference in New Issue
Block a user