feat: capture tool call arguments in sidecar activity events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-07 14:50:22 +02:00
co-authored by Copilot
parent 8797186a73
commit fd9cc03c5c
3 changed files with 308 additions and 14 deletions
@@ -456,6 +456,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string? SourceAgentName { get; init; } public string? SourceAgentName { get; init; }
public string? ToolName { get; init; } public string? ToolName { get; init; }
public string? ToolCallId { get; init; } public string? ToolCallId { get; init; }
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
public IReadOnlyList<ToolCallFileChangeDto>? FileChanges { get; init; } public IReadOnlyList<ToolCallFileChangeDto>? FileChanges { get; init; }
} }
@@ -12,6 +12,8 @@ internal static class WorkflowRequestInfoInterpreter
private const string ToolCallingActivityType = "tool-calling"; private const string ToolCallingActivityType = "tool-calling";
private const string CodeInterpreterToolName = "code interpreter"; private const string CodeInterpreterToolName = "code interpreter";
private const string ImageGenerationToolName = "image generation"; private const string ImageGenerationToolName = "image generation";
private const int MaxToolArgumentValueLength = 4000;
private const string TruncatedToolArgumentValue = "[truncated]";
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions(); private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
public static AgentActivityEventDto? TryCreateActivityFromRequest( public static AgentActivityEventDto? TryCreateActivityFromRequest(
@@ -80,6 +82,7 @@ internal static class WorkflowRequestInfoInterpreter
AgentName = activeAgent.AgentName, AgentName = activeAgent.AgentName,
ToolName = tool.ToolName, ToolName = tool.ToolName,
ToolCallId = tool.ToolCallId, ToolCallId = tool.ToolCallId,
ToolArguments = tool.ToolArguments,
}; };
} }
@@ -103,8 +106,8 @@ internal static class WorkflowRequestInfoInterpreter
return new HandoffRequestInterpretation(handoffAgent); return new HandoffRequestInterpretation(handoffAgent);
} }
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId) return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId, out IReadOnlyDictionary<string, object?>? toolArguments)
? new ToolRequestInterpretation(toolName, toolCallId) ? new ToolRequestInterpretation(toolName, toolCallId, toolArguments)
: new UnknownRequestInterpretation(); : new UnknownRequestInterpretation();
} }
@@ -137,33 +140,38 @@ internal static class WorkflowRequestInfoInterpreter
private static bool TryGetToolRequestInfo( private static bool TryGetToolRequestInfo(
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
out string toolName, out string toolName,
out string? toolCallId) out string? toolCallId,
out IReadOnlyDictionary<string, object?>? toolArguments)
{ {
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId) return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments)
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId); || TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments);
} }
private static bool TryGetStableToolRequestInfo( private static bool TryGetStableToolRequestInfo(
PortableValue requestData, PortableValue requestData,
out string toolName, out string toolName,
out string? toolCallId) out string? toolCallId,
out IReadOnlyDictionary<string, object?>? toolArguments)
{ {
if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall)) if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall))
{ {
toolName = NormalizeOptionalString(functionCall.Name) ?? "function"; toolName = NormalizeOptionalString(functionCall.Name) ?? "function";
toolCallId = NormalizeOptionalString(functionCall.CallId); toolCallId = NormalizeOptionalString(functionCall.CallId);
toolArguments = NormalizeToolArguments(functionCall.Arguments);
return true; return true;
} }
toolName = string.Empty; toolName = string.Empty;
toolCallId = null; toolCallId = null;
toolArguments = null;
return false; return false;
} }
private static bool TryGetEvaluationToolRequestInfo( private static bool TryGetEvaluationToolRequestInfo(
PortableValue requestData, PortableValue requestData,
out string toolName, out string toolName,
out string? toolCallId) out string? toolCallId,
out IReadOnlyDictionary<string, object?>? toolArguments)
{ {
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall)) if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
{ {
@@ -171,6 +179,7 @@ internal static class WorkflowRequestInfoInterpreter
?? NormalizeOptionalString(mcpToolCall.ServerName) ?? NormalizeOptionalString(mcpToolCall.ServerName)
?? string.Empty; ?? string.Empty;
toolCallId = NormalizeOptionalString(mcpToolCall.CallId); toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
toolArguments = NormalizeToolArguments(mcpToolCall.Arguments);
return toolName.Length > 0; return toolName.Length > 0;
} }
@@ -178,6 +187,7 @@ internal static class WorkflowRequestInfoInterpreter
{ {
toolName = CodeInterpreterToolName; toolName = CodeInterpreterToolName;
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId); toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
return true; return true;
} }
@@ -185,14 +195,196 @@ internal static class WorkflowRequestInfoInterpreter
{ {
toolName = ImageGenerationToolName; toolName = ImageGenerationToolName;
toolCallId = null; toolCallId = null;
toolArguments = null;
return true; return true;
} }
toolName = string.Empty; toolName = string.Empty;
toolCallId = null; toolCallId = null;
toolArguments = null;
return false; return false;
} }
private static IReadOnlyDictionary<string, object?>? NormalizeToolArguments(
IEnumerable<KeyValuePair<string, object?>>? arguments)
{
if (arguments is null)
{
return null;
}
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, object?> argument in arguments)
{
string? key = NormalizeOptionalString(argument.Key);
if (key is null)
{
continue;
}
object? value = NormalizeToolArgumentValue(argument.Value);
if (value is null)
{
continue;
}
normalized[key] = value;
}
return normalized.Count > 0 ? normalized : null;
}
private static IReadOnlyDictionary<string, object?>? NormalizeCodeInterpreterToolArguments(
CodeInterpreterToolCallContent codeInterpreterToolCall)
{
IList<AIContent>? rawInputs = codeInterpreterToolCall.Inputs;
if (rawInputs is not { Count: > 0 })
{
return null;
}
List<object?> inputs = [];
foreach (AIContent input in rawInputs)
{
object? normalized = input switch
{
TextContent text => NormalizeToolArgumentValue(text.Text),
_ => BuildAiContentFallbackValue(input),
};
if (normalized is not null)
{
inputs.Add(normalized);
}
}
return inputs.Count > 0
? new Dictionary<string, object?>(StringComparer.Ordinal)
{
["inputs"] = inputs,
}
: null;
}
private static object? NormalizeToolArgumentValue(object? value)
{
return value switch
{
null => null,
string text => NormalizeToolArgumentText(text),
JsonElement element => NormalizeToolArgumentElement(element),
bool boolean => boolean,
byte number => number,
sbyte number => number,
short number => number,
ushort number => number,
int number => number,
uint number => number,
long number => number,
ulong number => number,
float number => number,
double number => number,
decimal number => number,
AIContent content => BuildAiContentFallbackValue(content),
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
IEnumerable<object?> sequence => NormalizeToolArgumentSequence(sequence),
_ => NormalizeUnknownToolArgumentValue(value),
};
}
private static object? NormalizeToolArgumentElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => NormalizeToolArgumentText(element.GetString()),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.Deserialize<object?>(JsonOptions),
JsonValueKind.Object => NormalizeToolArgumentObject(element),
JsonValueKind.Array => NormalizeToolArgumentArray(element),
_ => NormalizeToolArgumentText(element.GetRawText()),
};
}
private static IReadOnlyDictionary<string, object?>? NormalizeToolArgumentObject(JsonElement element)
{
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
foreach (JsonProperty property in element.EnumerateObject())
{
string? key = NormalizeOptionalString(property.Name);
if (key is null)
{
continue;
}
object? value = NormalizeToolArgumentElement(property.Value);
if (value is not null)
{
normalized[key] = value;
}
}
return normalized.Count > 0 ? normalized : null;
}
private static IReadOnlyList<object?>? NormalizeToolArgumentArray(JsonElement element)
{
List<object?> normalized = [];
foreach (JsonElement item in element.EnumerateArray())
{
object? value = NormalizeToolArgumentElement(item);
if (value is not null)
{
normalized.Add(value);
}
}
return normalized.Count > 0 ? normalized : null;
}
private static IReadOnlyList<object?>? NormalizeToolArgumentSequence(IEnumerable<object?> sequence)
{
List<object?> normalized = [];
foreach (object? item in sequence)
{
object? value = NormalizeToolArgumentValue(item);
if (value is not null)
{
normalized.Add(value);
}
}
return normalized.Count > 0 ? normalized : null;
}
private static object? NormalizeUnknownToolArgumentValue(object value)
{
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
using JsonDocument document = JsonDocument.Parse(json);
return NormalizeToolArgumentElement(document.RootElement);
}
private static IReadOnlyDictionary<string, object?> BuildAiContentFallbackValue(AIContent content)
{
return new Dictionary<string, object?>(StringComparer.Ordinal)
{
["type"] = content.GetType().Name,
};
}
private static string? NormalizeToolArgumentText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Length > MaxToolArgumentValueLength
? TruncatedToolArgumentValue
: value;
}
private static string? NormalizeOptionalString(string? value) private static string? NormalizeOptionalString(string? value)
{ {
return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -208,7 +400,10 @@ internal static class WorkflowRequestInfoInterpreter
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation; private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation; private sealed record ToolRequestInterpretation(
string ToolName,
string? ToolCallId,
IReadOnlyDictionary<string, object?>? ToolArguments) : RequestInterpretation;
private sealed record UnknownRequestInterpretation : RequestInterpretation; private sealed record UnknownRequestInterpretation : RequestInterpretation;
} }
@@ -1,3 +1,4 @@
using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
@@ -15,7 +16,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
{ {
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal); ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent( 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",
["viewRange"] = new object[] { 10, 25 },
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
@@ -28,6 +33,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-1", activity.AgentId); Assert.Equal("agent-1", activity.AgentId);
Assert.Equal("Primary", activity.AgentName); Assert.Equal("Primary", activity.AgentName);
Assert.Equal("view", activity.ToolName); Assert.Equal("view", activity.ToolName);
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", toolNamesByCallId["call-1"]);
} }
@@ -36,7 +44,15 @@ public sealed class WorkflowRequestInfoInterpreterTests
{ {
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal); ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent( RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateMcpToolCall("call-1", "git.status", "Git MCP")); CreateMcpToolCall(
"call-1",
"git.status",
"Git MCP",
new Dictionary<string, object?>
{
["path"] = @"C:\workspace",
["includeIgnored"] = true,
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
@@ -47,6 +63,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("git.status", activity.ToolName); Assert.Equal("git.status", activity.ToolName);
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", toolNamesByCallId["call-1"]);
} }
@@ -54,7 +73,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName() public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
{ {
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal); ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1")); RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
@@ -65,6 +85,10 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("code interpreter", activity.ToolName); Assert.Equal("code interpreter", activity.ToolName);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(
["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]); Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
} }
@@ -83,9 +107,55 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName); Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments);
Assert.Empty(toolNamesByCallId); Assert.Empty(toolNamesByCallId);
} }
[Fact]
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["empty"] = " ",
["missing"] = null,
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.Null(activity.ToolArguments);
}
[Fact]
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent(
"call-1",
"powershell",
new Dictionary<string, object?>
{
["command"] = new string('x', 4001),
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
}
[Fact] [Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds() public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
{ {
@@ -242,22 +312,50 @@ public sealed class WorkflowRequestInfoInterpreterTests
return new RequestInfoEvent(request); return new RequestInfoEvent(request);
} }
private static object CreateCodeInterpreterToolCall(string callId) private static object CreateCodeInterpreterToolCall(string callId, params string[] inputs)
{ {
Type type = Type.GetType( Type type = Type.GetType(
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions", "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!; throwOnError: true)!;
object instance = Activator.CreateInstance(type)!; object instance = Activator.CreateInstance(type)!;
type.GetProperty("CallId")!.SetValue(instance, callId); type.GetProperty("CallId")!.SetValue(instance, callId);
if (inputs.Length > 0)
{
Type aiContentType = Type.GetType(
"Microsoft.Extensions.AI.AIContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
Type textContentType = Type.GetType(
"Microsoft.Extensions.AI.TextContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
IList values = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(aiContentType))!;
foreach (string input in inputs)
{
object textContent = Activator.CreateInstance(textContentType, input)!;
values.Add(textContent);
}
type.GetProperty("Inputs")!.SetValue(instance, values);
}
return instance; return instance;
} }
private static object CreateMcpToolCall(string callId, string toolName, string serverName) private static object CreateMcpToolCall(
string callId,
string toolName,
string serverName,
IReadOnlyDictionary<string, object?>? arguments = null)
{ {
Type type = Type.GetType( Type type = Type.GetType(
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions", "Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!; throwOnError: true)!;
return Activator.CreateInstance(type, callId, toolName, serverName)!; object instance = Activator.CreateInstance(type, callId, toolName, serverName)!;
if (arguments is not null)
{
type.GetProperty("Arguments")!.SetValue(instance, arguments);
}
return instance;
} }
private static object CreateImageGenerationToolCall() private static object CreateImageGenerationToolCall()