fix: only project handoff tool calls as FunctionCallContent

AryxCopilotAgent was converting ALL Copilot tool requests (ask_user,
MCP tools, etc.) into FunctionCallContent. The Agent Framework's
AIAgentHostExecutor tracks every FunctionCallContent as an outstanding
request.  Since SDK-handled tools like ask_user never produce a
matching FunctionResultContent, HasOutstandingRequests stayed true and
TurnToken was never emitted — stalling group-chat advancement after
an ask_user interaction.

Now only handoff_to_* tool requests are projected as FunctionCallContent.
All other tool calls remain invisible to the Agent Framework executor,
matching the upstream GitHubCopilotAgent behaviour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 21:12:42 +01:00
co-authored by Copilot
parent 9d65e3d209
commit 154787c336
2 changed files with 35 additions and 2 deletions
@@ -225,6 +225,16 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
continue; continue;
} }
// Only project handoff tool calls as FunctionCallContent for the Agent Framework.
// Other tool calls (ask_user, MCP tools, etc.) are resolved by the Copilot SDK
// internally and must not be surfaced, because AIAgentHostExecutor tracks every
// FunctionCallContent as an outstanding request. An unmatched request prevents
// the executor from emitting a TurnToken, which stalls group-chat advancement.
if (!IsHandoffToolName(toolRequest.Name))
{
continue;
}
contents.Add(new FunctionCallContent( contents.Add(new FunctionCallContent(
toolRequest.ToolCallId, toolRequest.ToolCallId,
toolRequest.Name, toolRequest.Name,
@@ -234,6 +244,12 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return contents; return contents;
} }
private static bool IsHandoffToolName(string? name)
{
return !string.IsNullOrWhiteSpace(name)
&& name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal);
}
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken) private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
{ {
if (_copilotClient.State != ConnectionState.Connected) if (_copilotClient.State != ConnectionState.Connected)
@@ -344,8 +360,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
private static bool IsHandoffDeclaration(AIFunctionDeclaration declaration) private static bool IsHandoffDeclaration(AIFunctionDeclaration declaration)
{ {
return !string.IsNullOrWhiteSpace(declaration.Name) return IsHandoffToolName(declaration.Name);
&& declaration.Name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal);
} }
private static AIFunction CreateInvokableHandoffFunction(AIFunctionDeclaration declaration) private static AIFunction CreateInvokableHandoffFunction(AIFunctionDeclaration declaration)
@@ -125,6 +125,24 @@ public sealed class CopilotAgentBundleTests
Assert.Equal("frontend specialist", functionCall.Arguments["reasonForHandoff"]?.ToString()); Assert.Equal("frontend specialist", functionCall.Arguments["reasonForHandoff"]?.ToString());
} }
[Fact]
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
{
AssistantMessageDataToolRequestsItem[] toolRequests =
{
new() { ToolCallId = "call-001", Name = "ask_user" },
new() { ToolCallId = "call-002", Name = "web_fetch" },
new() { ToolCallId = "call-003", Name = "handoff_to_reviewer" },
new() { ToolCallId = "call-004", Name = "grep" },
};
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
FunctionCallContent single = Assert.Single(result);
Assert.Equal("call-003", single.CallId);
Assert.Equal("handoff_to_reviewer", single.Name);
}
private static AIFunction CreateTool() private static AIFunction CreateTool()
{ {
ToolTarget target = new(); ToolTarget target = new();