diff --git a/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs b/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs index 3ac0c9c..b6559b8 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.IO; using System.Runtime.CompilerServices; using System.Text.Json; @@ -104,6 +105,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable try { Channel channel = Channel.CreateUnbounded(); + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); using IDisposable subscription = copilotSession.On(evt => { @@ -114,9 +116,19 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable break; case AssistantMessageEvent assistantMessage: + TrackToolRequestNames(toolNamesByCallId, assistantMessage.Data?.ToolRequests); channel.Writer.TryWrite(ConvertToAgentResponseUpdate(assistantMessage)); break; + case ToolExecutionCompleteEvent toolExecutionComplete: + AgentResponseUpdate? toolResultUpdate = ConvertToAgentResponseUpdate(toolExecutionComplete, toolNamesByCallId); + if (toolResultUpdate is not null) + { + channel.Writer.TryWrite(toolResultUpdate); + } + + break; + case AssistantUsageEvent usageEvent: channel.Writer.TryWrite(ConvertToAgentResponseUpdate(usageEvent)); break; @@ -232,16 +244,6 @@ 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, @@ -251,6 +253,26 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable return contents; } + internal static FunctionResultContent? TryCreateToolResultContent( + ToolExecutionCompleteEvent toolExecutionComplete, + string? toolName = null) + { + // Regular Copilot tools need their result projected back into AF so the function call + // remains part of workflow-visible history. Handoff tools are finalized separately by + // HandoffAgentExecutor, which already injects its own "Transferred." result. + string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim(); + if (string.IsNullOrWhiteSpace(toolCallId) || IsHandoffToolName(toolName)) + { + return null; + } + + string result = ResolveToolResultText(toolExecutionComplete.Data); + return new FunctionResultContent(toolCallId, result) + { + RawRepresentation = toolExecutionComplete, + }; + } + private static bool IsHandoffToolName(string? name) { return !string.IsNullOrWhiteSpace(name) @@ -441,6 +463,36 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable }; } + private AgentResponseUpdate? ConvertToAgentResponseUpdate( + ToolExecutionCompleteEvent toolExecutionComplete, + ConcurrentDictionary toolNamesByCallId) + { + string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim(); + if (string.IsNullOrWhiteSpace(toolCallId)) + { + return null; + } + + string? toolName = null; + if (toolNamesByCallId.TryRemove(toolCallId, out string? trackedToolName)) + { + toolName = trackedToolName; + } + + FunctionResultContent? toolResult = TryCreateToolResultContent(toolExecutionComplete, toolName); + if (toolResult is null) + { + return null; + } + + return new AgentResponseUpdate(ChatRole.Tool, [toolResult]) + { + AgentId = Id, + MessageId = toolCallId, + CreatedAt = toolExecutionComplete.Timestamp, + }; + } + private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent) { AIContent content = new() @@ -455,6 +507,45 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable }; } + private static void TrackToolRequestNames( + ConcurrentDictionary toolNamesByCallId, + AssistantMessageDataToolRequestsItem[]? toolRequests) + { + if (toolRequests is not { Length: > 0 }) + { + return; + } + + foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests) + { + string? toolCallId = toolRequest.ToolCallId?.Trim(); + string? toolName = toolRequest.Name?.Trim(); + if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName)) + { + continue; + } + + toolNamesByCallId[toolCallId] = toolName; + } + } + + private static string ResolveToolResultText(ToolExecutionCompleteData? toolExecutionCompleteData) + { + if (toolExecutionCompleteData is null) + { + return string.Empty; + } + + if (toolExecutionCompleteData.Success) + { + return toolExecutionCompleteData.Result?.Content + ?? toolExecutionCompleteData.Result?.DetailedContent + ?? string.Empty; + } + + return toolExecutionCompleteData.Error?.Message ?? string.Empty; + } + private static Dictionary? ParseToolArguments(object? arguments) { if (arguments is null) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs index 16438df..bfefc99 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -89,7 +89,16 @@ internal sealed class CopilotTurnExecutionState case ToolExecutionStartEvent toolExecutionStart when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId) && !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName): - ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim(); + string toolCallId = toolExecutionStart.Data.ToolCallId.Trim(); + string toolName = toolExecutionStart.Data.ToolName.Trim(); + ToolNamesByCallId[toolCallId] = toolName; + ActiveAgent = agent; + AgentActivityEventDto? toolActivity = CreateToolCallingActivity(agent, toolName, toolCallId); + if (toolActivity is not null) + { + _pendingEvents.Enqueue(toolActivity); + } + QueueMessageReclassifiedIfNeeded(_lastObservedMessageId); break; case AssistantIntentEvent intentEvent: @@ -278,6 +287,29 @@ internal sealed class CopilotTurnExecutionState }; } + private AgentActivityEventDto? CreateToolCallingActivity( + AgentIdentity agent, + string toolName, + string toolCallId) + { + if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal)) + { + return null; + } + + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + ActivityType = "tool-calling", + AgentId = agent.AgentId, + AgentName = agent.AgentName, + ToolName = toolName, + ToolCallId = toolCallId, + }; + } + private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId) { return new MessageReclassifiedEventDto diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index 5c55ab3..dc79710 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -57,12 +57,17 @@ internal static class WorkflowRequestInfoInterpreter }; } - private static AgentActivityEventDto CreateToolCallingActivity( + private static AgentActivityEventDto? CreateToolCallingActivity( RunTurnCommandDto command, AgentIdentity activeAgent, ToolRequestInterpretation tool, ConcurrentDictionary toolNamesByCallId) { + if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId)) + { + return null; + } + TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName); return new AgentActivityEventDto diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index 41a61b7..fde4f3f 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -185,7 +185,7 @@ public sealed class CopilotAgentBundleTests } [Fact] - public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls() + public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls() { AssistantMessageDataToolRequestsItem[] toolRequests = { @@ -197,9 +197,99 @@ public sealed class CopilotAgentBundleTests IReadOnlyList result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests); - FunctionCallContent single = Assert.Single(result); - Assert.Equal("call-003", single.CallId); - Assert.Equal("handoff_to_reviewer", single.Name); + Assert.Collection( + result, + functionCall => + { + Assert.Equal("call-001", functionCall.CallId); + Assert.Equal("ask_user", functionCall.Name); + }, + functionCall => + { + Assert.Equal("call-002", functionCall.CallId); + Assert.Equal("web_fetch", functionCall.Name); + }, + functionCall => + { + Assert.Equal("call-003", functionCall.CallId); + Assert.Equal("handoff_to_reviewer", functionCall.Name); + }, + functionCall => + { + Assert.Equal("call-004", functionCall.CallId); + Assert.Equal("grep", functionCall.Name); + }); + } + + [Fact] + public void TryCreateToolResultContent_UsesSdkResultContentForNonHandoffTools() + { + ToolExecutionCompleteEvent toolExecutionComplete = new() + { + Data = new ToolExecutionCompleteData + { + ToolCallId = "call-123", + Success = true, + Result = new ToolExecutionCompleteDataResult + { + Content = "Search complete.", + DetailedContent = "Search complete with extra context.", + }, + }, + }; + + FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "rg"); + + Assert.NotNull(toolResult); + Assert.Equal("call-123", toolResult.CallId); + Assert.Equal("Search complete.", Assert.IsType(toolResult.Result)); + Assert.Same(toolExecutionComplete, toolResult.RawRepresentation); + } + + [Fact] + public void TryCreateToolResultContent_UsesSdkErrorMessageForFailedTools() + { + ToolExecutionCompleteEvent toolExecutionComplete = new() + { + Data = new ToolExecutionCompleteData + { + ToolCallId = "call-456", + Success = false, + Error = new ToolExecutionCompleteDataError + { + Message = "Permission denied.", + }, + }, + }; + + FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "view"); + + Assert.NotNull(toolResult); + Assert.Equal("call-456", toolResult.CallId); + Assert.Equal("Permission denied.", Assert.IsType(toolResult.Result)); + } + + [Fact] + public void TryCreateToolResultContent_SkipsHandoffTools() + { + ToolExecutionCompleteEvent toolExecutionComplete = new() + { + Data = new ToolExecutionCompleteData + { + ToolCallId = "call-789", + Success = true, + Result = new ToolExecutionCompleteDataResult + { + Content = "Transferred.", + }, + }, + }; + + FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent( + toolExecutionComplete, + "handoff_to_reviewer"); + + Assert.Null(toolResult); } [Fact] diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index d8b378d..9f4b51f 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -60,7 +60,7 @@ public sealed class CopilotTurnExecutionStateTests } [Fact] - public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId() + public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); @@ -68,22 +68,32 @@ public sealed class CopilotTurnExecutionStateTests state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( - """ - { - "type": "tool.execution_start", - "data": { - "toolCallId": "tool-call-1", - "toolName": "view" - }, - "id": "33333333-3333-3333-3333-333333333333", - "timestamp": "2026-03-27T00:00:00Z" - } - """)); + """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}""")); + AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType()); + Assert.Equal("tool-calling", toolActivity.ActivityType); + Assert.Equal("view", toolActivity.ToolName); + Assert.Equal("tool-call-1", toolActivity.ToolCallId); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.Equal("view", toolName); } + [Fact] + public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}""")); + + Assert.Empty(state.DrainPendingEvents().OfType()); + Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); + Assert.Equal("handoff_to_specialist", toolName); + } + [Fact] public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent() { @@ -178,6 +188,10 @@ public sealed class CopilotTurnExecutionStateTests IReadOnlyList pending = state.DrainPendingEvents(); + AgentActivityEventDto[] toolActivities = [.. pending.OfType().Where(activity => activity.ActivityType == "tool-calling")]; + Assert.Equal(2, toolActivities.Length); + Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg"); + Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view"); MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType()); Assert.Equal("msg-3", reclassified.MessageId); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName)); diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs index 48914ea..7a7202a 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -86,6 +86,26 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.Empty(toolNamesByCallId); } + [Fact] + public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal) + { + ["call-1"] = "view", + }; + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("call-1", "view", new Dictionary())); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateSingleAgentCommand(), + requestInfo, + new AgentIdentity("agent-1", "Primary"), + toolNamesByCallId); + + Assert.Null(activity); + Assert.Equal("view", toolNamesByCallId["call-1"]); + } + [Fact] public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets() {