diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 8bf620b..90c6549 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -456,6 +456,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto public string? SourceAgentName { get; init; } public string? ToolName { get; init; } public string? ToolCallId { get; init; } + public IReadOnlyDictionary? ToolArguments { get; init; } public IReadOnlyList? FileChanges { get; init; } } diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index f6bb163..7f0f502 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -12,6 +12,8 @@ internal static class WorkflowRequestInfoInterpreter private const string ToolCallingActivityType = "tool-calling"; private const string CodeInterpreterToolName = "code interpreter"; private const string ImageGenerationToolName = "image generation"; + private const int MaxToolArgumentValueLength = 4000; + private const string TruncatedToolArgumentValue = "[truncated]"; private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions(); public static AgentActivityEventDto? TryCreateActivityFromRequest( @@ -80,6 +82,7 @@ internal static class WorkflowRequestInfoInterpreter AgentName = activeAgent.AgentName, ToolName = tool.ToolName, ToolCallId = tool.ToolCallId, + ToolArguments = tool.ToolArguments, }; } @@ -103,8 +106,8 @@ internal static class WorkflowRequestInfoInterpreter return new HandoffRequestInterpretation(handoffAgent); } - return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId) - ? new ToolRequestInterpretation(toolName, toolCallId) + return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId, out IReadOnlyDictionary? toolArguments) + ? new ToolRequestInterpretation(toolName, toolCallId, toolArguments) : new UnknownRequestInterpretation(); } @@ -137,33 +140,38 @@ internal static class WorkflowRequestInfoInterpreter private static bool TryGetToolRequestInfo( RequestInfoEvent requestInfo, out string toolName, - out string? toolCallId) + out string? toolCallId, + out IReadOnlyDictionary? toolArguments) { - return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId) - || TryGetEvaluationToolRequestInfo(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, out toolArguments); } private static bool TryGetStableToolRequestInfo( PortableValue requestData, out string toolName, - out string? toolCallId) + out string? toolCallId, + out IReadOnlyDictionary? toolArguments) { if (requestData.Is(out FunctionCallContent? functionCall)) { toolName = NormalizeOptionalString(functionCall.Name) ?? "function"; toolCallId = NormalizeOptionalString(functionCall.CallId); + toolArguments = NormalizeToolArguments(functionCall.Arguments); return true; } toolName = string.Empty; toolCallId = null; + toolArguments = null; return false; } private static bool TryGetEvaluationToolRequestInfo( PortableValue requestData, out string toolName, - out string? toolCallId) + out string? toolCallId, + out IReadOnlyDictionary? toolArguments) { if (requestData.Is(out McpServerToolCallContent? mcpToolCall)) { @@ -171,6 +179,7 @@ internal static class WorkflowRequestInfoInterpreter ?? NormalizeOptionalString(mcpToolCall.ServerName) ?? string.Empty; toolCallId = NormalizeOptionalString(mcpToolCall.CallId); + toolArguments = NormalizeToolArguments(mcpToolCall.Arguments); return toolName.Length > 0; } @@ -178,6 +187,7 @@ internal static class WorkflowRequestInfoInterpreter { toolName = CodeInterpreterToolName; toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId); + toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall); return true; } @@ -185,14 +195,196 @@ internal static class WorkflowRequestInfoInterpreter { toolName = ImageGenerationToolName; toolCallId = null; + toolArguments = null; return true; } toolName = string.Empty; toolCallId = null; + toolArguments = null; return false; } + private static IReadOnlyDictionary? NormalizeToolArguments( + IEnumerable>? arguments) + { + if (arguments is null) + { + return null; + } + + Dictionary normalized = new(StringComparer.Ordinal); + foreach (KeyValuePair 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? NormalizeCodeInterpreterToolArguments( + CodeInterpreterToolCallContent codeInterpreterToolCall) + { + IList? rawInputs = codeInterpreterToolCall.Inputs; + if (rawInputs is not { Count: > 0 }) + { + return null; + } + + List 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(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> dictionary => NormalizeToolArguments(dictionary), + IEnumerable 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(JsonOptions), + JsonValueKind.Object => NormalizeToolArgumentObject(element), + JsonValueKind.Array => NormalizeToolArgumentArray(element), + _ => NormalizeToolArgumentText(element.GetRawText()), + }; + } + + private static IReadOnlyDictionary? NormalizeToolArgumentObject(JsonElement element) + { + Dictionary 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? NormalizeToolArgumentArray(JsonElement element) + { + List 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? NormalizeToolArgumentSequence(IEnumerable sequence) + { + List 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 BuildAiContentFallbackValue(AIContent content) + { + return new Dictionary(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) { 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 ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation; + private sealed record ToolRequestInterpretation( + string ToolName, + string? ToolCallId, + IReadOnlyDictionary? ToolArguments) : RequestInterpretation; private sealed record UnknownRequestInterpretation : RequestInterpretation; } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs index 57c4ed6..0e2ce15 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Concurrent; using System.Runtime.CompilerServices; using Aryx.AgentHost.Contracts; @@ -15,7 +16,11 @@ public sealed class WorkflowRequestInfoInterpreterTests { ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); RequestInfoEvent requestInfo = CreateRequestInfoEvent( - new FunctionCallContent("call-1", "view", new Dictionary())); + new FunctionCallContent("call-1", "view", new Dictionary + { + ["path"] = @"C:\workspace\file.txt", + ["viewRange"] = new object[] { 10, 25 }, + })); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), @@ -28,6 +33,9 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.Equal("agent-1", activity.AgentId); Assert.Equal("Primary", activity.AgentName); Assert.Equal("view", activity.ToolName); + 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"]); } @@ -36,7 +44,15 @@ public sealed class WorkflowRequestInfoInterpreterTests { ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); RequestInfoEvent requestInfo = CreateRequestInfoEvent( - CreateMcpToolCall("call-1", "git.status", "Git MCP")); + CreateMcpToolCall( + "call-1", + "git.status", + "Git MCP", + new Dictionary + { + ["path"] = @"C:\workspace", + ["includeIgnored"] = true, + })); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), @@ -47,6 +63,9 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); 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"]); } @@ -54,7 +73,8 @@ public sealed class WorkflowRequestInfoInterpreterTests public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName() { ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); - RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1")); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + CreateCodeInterpreterToolCall("call-1", "print('hello')")); AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( CreateSingleAgentCommand(), @@ -65,6 +85,10 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("code interpreter", activity.ToolName); + Assert.NotNull(activity.ToolArguments); + Assert.Equal( + ["print('hello')"], + Assert.IsAssignableFrom>(activity.ToolArguments["inputs"])); Assert.Equal("code interpreter", toolNamesByCallId["call-1"]); } @@ -83,9 +107,55 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.NotNull(activity); Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("image generation", activity.ToolName); + Assert.Null(activity.ToolArguments); Assert.Empty(toolNamesByCallId); } + [Fact] + public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("call-1", "view", new Dictionary + { + ["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 toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent( + "call-1", + "powershell", + new Dictionary + { + ["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] public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds() { @@ -242,22 +312,50 @@ public sealed class WorkflowRequestInfoInterpreterTests return new RequestInfoEvent(request); } - private static object CreateCodeInterpreterToolCall(string callId) + private static object CreateCodeInterpreterToolCall(string callId, params string[] inputs) { Type type = Type.GetType( "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions", throwOnError: true)!; object instance = Activator.CreateInstance(type)!; 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; } - private static object CreateMcpToolCall(string callId, string toolName, string serverName) + private static object CreateMcpToolCall( + string callId, + string toolName, + string serverName, + IReadOnlyDictionary? arguments = null) { Type type = Type.GetType( "Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions", 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()