From b9e73831e8ec1a82a54a505caebd9fb087e7f844 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 8 Apr 2026 12:14:03 +0200 Subject: [PATCH] fix: enrich tool activity arguments on dedup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/AgentWorkflowTurnRunner.cs | 3 +- .../Services/TurnExecutionState.cs | 13 ++- .../WorkflowRequestInfoInterpreter.cs | 23 +++- .../CopilotTurnExecutionStateTests.cs | 10 ++ .../CopilotWorkflowRunnerTests.cs | 59 ++++++++++ .../WorkflowRequestInfoInterpreterTests.cs | 105 +++++++++++++----- 6 files changed, 178 insertions(+), 35 deletions(-) diff --git a/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs b/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs index ae18219..f1364e7 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs @@ -319,7 +319,8 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner command, requestInfo, state.ActiveAgent, - state.ToolNamesByCallId); + state.ToolNamesByCallId, + state.ToolCallHasArgumentsById); if (activity is null) { diff --git a/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs index 887ba86..00bdb6c 100644 --- a/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs @@ -23,6 +23,8 @@ internal class TurnExecutionState public ConcurrentDictionary ToolNamesByCallId { get; } = new(StringComparer.Ordinal); + public ConcurrentDictionary ToolCallHasArgumentsById { get; } = new(StringComparer.Ordinal); + public AgentIdentity? ActiveAgent { get; private set; } public List CompletedMessages { get; private set; } = []; @@ -93,7 +95,7 @@ internal class TurnExecutionState case ProviderToolExecutionStartEvent toolExecutionStart: string toolCallId = toolExecutionStart.ToolCallId; string toolName = toolExecutionStart.ToolName; - ToolNamesByCallId[toolCallId] = toolName; + TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments); ActiveAgent = agent; AgentActivityEventDto? toolActivity = CreateToolCallingActivity( agent, toolName, toolCallId, toolExecutionStart.ToolArguments); @@ -269,6 +271,15 @@ internal class TurnExecutionState _lastObservedMessageId = messageId; } + private void TrackToolCall( + string toolCallId, + string toolName, + IReadOnlyDictionary? toolArguments) + { + ToolNamesByCallId[toolCallId] = toolName; + ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 }; + } + private void QueueMessageReclassifiedIfNeeded(string? messageId) { if (string.IsNullOrWhiteSpace(messageId)) diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index ec97f9f..4bcaa06 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -20,7 +20,8 @@ internal static class WorkflowRequestInfoInterpreter RunTurnCommandDto command, RequestInfoEvent requestInfo, AgentIdentity? activeAgent, - ConcurrentDictionary toolNamesByCallId) + ConcurrentDictionary toolNamesByCallId, + ConcurrentDictionary toolCallHasArgumentsById) { RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo); return interpretation switch @@ -28,7 +29,7 @@ internal static class WorkflowRequestInfoInterpreter HandoffRequestInterpretation handoff => CreateHandoffActivity(command, handoff.TargetAgent, activeAgent), ToolRequestInterpretation tool when activeAgent.HasValue => - CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId), + CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId, toolCallHasArgumentsById), _ => null, }; } @@ -63,14 +64,21 @@ internal static class WorkflowRequestInfoInterpreter RunTurnCommandDto command, AgentIdentity activeAgent, ToolRequestInterpretation tool, - ConcurrentDictionary toolNamesByCallId) + ConcurrentDictionary toolNamesByCallId, + ConcurrentDictionary toolCallHasArgumentsById) { + bool hasToolArguments = tool.ToolArguments is { Count: > 0 }; if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId)) { - return null; + bool trackedHasArguments = toolCallHasArgumentsById.TryGetValue(tool.ToolCallId, out bool hasTrackedArguments) + && hasTrackedArguments; + if (trackedHasArguments || !hasToolArguments) + { + return null; + } } - TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName); + TrackToolCallId(toolNamesByCallId, toolCallHasArgumentsById, tool.ToolCallId, tool.ToolName, hasToolArguments); return new AgentActivityEventDto { @@ -88,12 +96,15 @@ internal static class WorkflowRequestInfoInterpreter private static void TrackToolCallId( ConcurrentDictionary toolNamesByCallId, + ConcurrentDictionary toolCallHasArgumentsById, string? toolCallId, - string toolName) + string toolName, + bool hasToolArguments) { if (toolCallId is not null) { toolNamesByCallId[toolCallId] = toolName; + toolCallHasArgumentsById[toolCallId] = hasToolArguments; } } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index b96ecc0..9a957f7 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -78,6 +78,8 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.Equal("view", toolName); + Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); + Assert.True(hasArguments); } [Fact] @@ -93,6 +95,8 @@ public sealed class CopilotTurnExecutionStateTests AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Null(toolActivity.ToolArguments); + Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); + Assert.False(hasArguments); } [Fact] @@ -109,6 +113,8 @@ public sealed class CopilotTurnExecutionStateTests Assert.Empty(state.DrainPendingEvents().OfType()); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.Equal("handoff_to_specialist", toolName); + Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); + Assert.False(hasArguments); } [Fact] @@ -231,6 +237,10 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("rg", firstToolName); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName)); Assert.Equal("view", secondToolName); + Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool firstHasArguments)); + Assert.False(firstHasArguments); + Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-2", out bool secondHasArguments)); + Assert.False(secondHasArguments); } [Fact] diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 3fbfe81..b4bbdb1 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -721,6 +721,65 @@ public sealed class CopilotWorkflowRunnerTests }); } + [Fact] + public async Task HandleWorkflowEventAsync_EmitsToolActivityEnrichmentWhenRequestInfoAddsMissingArguments() + { + RunTurnCommandDto command = CreateApprovalCommand(); + CopilotTurnExecutionState state = new(command); + state.ObserveSessionEvent( + CreateAgent("agent-1", "Primary"), + SessionEvent.FromJson( + """ + { + "type": "tool.execution_start", + "data": { + "toolCallId": "tool-call-1", + "toolName": "view" + }, + "id": "f61652d1-120e-4a9f-8f0e-1dbf04fb18da", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + _ = state.DrainPendingEvents(); + + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("tool-call-1", "view", new Dictionary + { + ["path"] = @"C:\workspace\README.md", + })); + List activities = []; + + MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( + "HandleWorkflowEventAsync", + BindingFlags.NonPublic | BindingFlags.Static)!; + Task handleTask = (Task)handleWorkflowEvent.Invoke( + null, + [ + command, + requestInfo, + Array.Empty(), + state, + (Func)(_ => Task.CompletedTask), + (Func)(sidecarEvent => + { + activities.Add(Assert.IsType(sidecarEvent)); + return Task.CompletedTask; + }), + ])!; + + bool shouldEndTurn = await handleTask; + + Assert.False(shouldEndTurn); + AgentActivityEventDto activity = Assert.Single(activities); + Assert.Equal("tool-calling", activity.ActivityType); + Assert.Equal("view", activity.ToolName); + Assert.Equal("tool-call-1", activity.ToolCallId); + Assert.NotNull(activity.ToolArguments); + Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]); + Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); + Assert.True(hasArguments); + } + [Fact] public void CreateExecutionEnvironment_UsesLockstepWhenRequested() { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs index c0c46c7..b1308fe 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -15,7 +15,7 @@ public sealed class WorkflowRequestInfoInterpreterTests [Fact] public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent( new FunctionCallContent("call-1", "view", new Dictionary { @@ -27,7 +27,8 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); @@ -37,13 +38,14 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity.ToolArguments); Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); Assert.Equal([10, 25], Assert.IsAssignableFrom>(activity.ToolArguments["viewRange"])); - Assert.Equal("view", toolNamesByCallId["call-1"]); + Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); + Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); } [Fact] public void TryCreateActivityFromRequest_MapsMcpToolCalls() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent( CreateMcpToolCall( "call-1", @@ -59,7 +61,8 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); @@ -67,13 +70,14 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity.ToolArguments); Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]); Assert.Equal(true, activity.ToolArguments["includeIgnored"]); - Assert.Equal("git.status", toolNamesByCallId["call-1"]); + Assert.Equal("git.status", tracking.ToolNamesByCallId["call-1"]); + Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); } [Fact] public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent( CreateCodeInterpreterToolCall("call-1", "print('hello')")); @@ -81,7 +85,8 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); @@ -90,32 +95,35 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.Equal( ["print('hello')"], Assert.IsAssignableFrom>(activity.ToolArguments["inputs"])); - Assert.Equal("code interpreter", toolNamesByCallId["call-1"]); + Assert.Equal("code interpreter", tracking.ToolNamesByCallId["call-1"]); + Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); } [Fact] public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall()); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("image generation", activity.ToolName); Assert.Null(activity.ToolArguments); - Assert.Empty(toolNamesByCallId); + Assert.Empty(tracking.ToolNamesByCallId); + Assert.Empty(tracking.ToolCallHasArgumentsById); } [Fact] public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent( new FunctionCallContent("call-1", "view", new Dictionary { @@ -127,16 +135,18 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.Null(activity.ToolArguments); + Assert.False(tracking.ToolCallHasArgumentsById["call-1"]); } [Fact] public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent( new FunctionCallContent( "call-1", @@ -150,37 +160,71 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.NotNull(activity.ToolArguments); Assert.Equal("[truncated]", activity.ToolArguments["command"]); + Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); } [Fact] - public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds() + public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal) - { - ["call-1"] = "view", - }; + var tracking = CreateToolTracking(); + tracking.ToolNamesByCallId["call-1"] = "view"; + tracking.ToolCallHasArgumentsById["call-1"] = true; RequestInfoEvent requestInfo = CreateRequestInfoEvent( - new FunctionCallContent("call-1", "view", new Dictionary())); + new FunctionCallContent("call-1", "view", new Dictionary + { + ["path"] = @"C:\workspace\file.txt", + })); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), requestInfo, new AgentIdentity("agent-1", "Primary"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.Null(activity); - Assert.Equal("view", toolNamesByCallId["call-1"]); + Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); + Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); + } + + [Fact] + public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments() + { + var tracking = CreateToolTracking(); + tracking.ToolNamesByCallId["call-1"] = "view"; + tracking.ToolCallHasArgumentsById["call-1"] = false; + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("call-1", "view", new Dictionary + { + ["path"] = @"C:\workspace\file.txt", + })); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateSingleAgentCommand(), + requestInfo, + new AgentIdentity("agent-1", "Primary"), + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); + + Assert.NotNull(activity); + Assert.Equal("tool-calling", activity.ActivityType); + Assert.Equal("call-1", activity.ToolCallId); + Assert.NotNull(activity.ToolArguments); + Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); + Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); + Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); } [Fact] public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets() { - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + var tracking = CreateToolTracking(); RequestInfoEvent requestInfo = CreateRequestInfoEvent( CreateHandoffTarget("agent-handoff-ux", "UX Specialist")); @@ -188,7 +232,8 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateHandoffCommand(), requestInfo, new AgentIdentity("agent-handoff-triage", "Triage"), - toolNamesByCallId); + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); Assert.NotNull(activity); Assert.Equal("handoff", activity.ActivityType); @@ -197,7 +242,8 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.Equal("agent-handoff-triage", activity.SourceAgentId); Assert.Equal("Triage", activity.SourceAgentName); Assert.Null(activity.ToolName); - Assert.Empty(toolNamesByCallId); + Assert.Empty(tracking.ToolNamesByCallId); + Assert.Empty(tracking.ToolCallHasArgumentsById); } [Fact] @@ -295,6 +341,11 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateAgent("agent-handoff-ux", "UX Specialist"), ]); + private static ( + ConcurrentDictionary ToolNamesByCallId, + ConcurrentDictionary ToolCallHasArgumentsById) CreateToolTracking() + => (new(StringComparer.Ordinal), new(StringComparer.Ordinal)); + private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList agents) { return new RunTurnCommandDto