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;
}
// 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(
toolRequest.ToolCallId,
toolRequest.Name,
@@ -234,6 +244,12 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return contents;
}
private static bool IsHandoffToolName(string? name)
{
return !string.IsNullOrWhiteSpace(name)
&& name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal);
}
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
{
if (_copilotClient.State != ConnectionState.Connected)
@@ -344,8 +360,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
private static bool IsHandoffDeclaration(AIFunctionDeclaration declaration)
{
return !string.IsNullOrWhiteSpace(declaration.Name)
&& declaration.Name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal);
return IsHandoffToolName(declaration.Name);
}
private static AIFunction CreateInvokableHandoffFunction(AIFunctionDeclaration declaration)
@@ -125,6 +125,24 @@ public sealed class CopilotAgentBundleTests
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()
{
ToolTarget target = new();