fix: enrich tool activity arguments on dedup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-08 12:14:03 +02:00
co-authored by Copilot
parent 0e2f9b8ae5
commit b9e73831e8
6 changed files with 178 additions and 35 deletions
@@ -319,7 +319,8 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
command,
requestInfo,
state.ActiveAgent,
state.ToolNamesByCallId);
state.ToolNamesByCallId,
state.ToolCallHasArgumentsById);
if (activity is null)
{
@@ -23,6 +23,8 @@ internal class TurnExecutionState
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, bool> ToolCallHasArgumentsById { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; }
public List<ChatMessageDto> 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<string, object?>? toolArguments)
{
ToolNamesByCallId[toolCallId] = toolName;
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 };
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
{
if (string.IsNullOrWhiteSpace(messageId))
@@ -20,7 +20,8 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId)
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> 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<string, string> toolNamesByCallId)
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> 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<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById,
string? toolCallId,
string toolName)
string toolName,
bool hasToolArguments)
{
if (toolCallId is not null)
{
toolNamesByCallId[toolCallId] = toolName;
toolCallHasArgumentsById[toolCallId] = hasToolArguments;
}
}
@@ -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<AgentActivityEventDto>());
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<AgentActivityEventDto>());
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]
@@ -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<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(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()
{
@@ -15,7 +15,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
[Fact]
public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -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<IReadOnlyList<object?>>(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<string, string> 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<string, string> 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<IReadOnlyList<object?>>(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<string, string> 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<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -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<string, string> 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<string, string> 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<string, object?>()));
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["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<string, object?>
{
["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<string, string> 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<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
{
return new RunTurnCommandDto