diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs index 5046929..195add8 100644 --- a/src/AnthropicClient/AnthropicApiClient.cs +++ b/src/AnthropicClient/AnthropicApiClient.cs @@ -82,6 +82,12 @@ public class AnthropicApiClient : IAnthropicApiClient } var chatResponse = Deserialize(responseContent) ?? new ChatResponse(); + + if (request.Tools is not null && request.Tools.Count > 0) + { + chatResponse.ToolCall = GetToolCall(chatResponse, request.Tools); + } + return AnthropicResult.Success(chatResponse, anthropicHeaders); } @@ -190,6 +196,11 @@ public class AnthropicApiClient : IAnthropicApiClient Usage = newUsage, Content = chatResponse.Content, }; + + if (request.Tools is not null && request.Tools.Count > 0) + { + chatResponse.ToolCall = GetToolCall(chatResponse, request.Tools); + } } // yield chat response on message stop @@ -228,6 +239,25 @@ public class AnthropicApiClient : IAnthropicApiClient } while (true); } + private ToolCall? GetToolCall(ChatResponse response, List tools) + { + var toolUse = response.Content.OfType().FirstOrDefault(); + + if (toolUse is null) + { + return null; + } + + var tool = tools.FirstOrDefault(t => t.Name == toolUse.Name); + + if (tool is null) + { + return null; + } + + return new ToolCall(tool, toolUse); + } + private async Task SendRequestAsync(MessageRequest request) { var requestContent = new StringContent(Serialize(request), Encoding.UTF8, JsonContentType); diff --git a/src/AnthropicClient/Models/ChatResponse.cs b/src/AnthropicClient/Models/ChatResponse.cs index aa33a1b..087066c 100644 --- a/src/AnthropicClient/Models/ChatResponse.cs +++ b/src/AnthropicClient/Models/ChatResponse.cs @@ -48,4 +48,10 @@ public class ChatResponse /// Gets the contents of the chat response. /// public List Content { get; init; } = []; + + /// + /// Gets the tool call of the chat response. If the chat response does not contain a tool call, this property is null. + /// + [JsonIgnore] + public ToolCall? ToolCall { get; set; } = null; } \ No newline at end of file diff --git a/src/AnthropicClient/Models/ToolCall.cs b/src/AnthropicClient/Models/ToolCall.cs index a1a9ed0..fd9e409 100644 --- a/src/AnthropicClient/Models/ToolCall.cs +++ b/src/AnthropicClient/Models/ToolCall.cs @@ -1,3 +1,8 @@ +using System.Reflection; +using System.Text.Json; + +using AnthropicClient.Json; + namespace AnthropicClient.Models; /// @@ -5,13 +10,112 @@ namespace AnthropicClient.Models; /// public class ToolCall { - // TODO: Implementation - // - each tool call needs parameters - // - each tool call needs a tool name - // - each tool needs function to call - // - tool may or may not need an instance - // - tool call will be given input from tool use - // - tool call should have list of known functions - // - tool call should be able to find function by name - // - tool call should be able to translate input to function parameters + /// + /// Gets the tool of the tool call. + /// + public Tool Tool { get; } + + /// + /// Gets the tool use of the tool call. + /// + public ToolUseContent ToolUse { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The tool of the tool call. + /// The tool use of the tool call. + /// A new instance of the class. + public ToolCall(Tool tool, ToolUseContent toolUse) + { + Tool = tool; + ToolUse = toolUse; + } + + /// + /// Invokes the tool call. + /// + /// The result of the tool call as an instance of . + public async Task> InvokeAsync() + { + return await InvokeAsync(); + } + + /// + /// Invokes the tool call. + /// + /// The type of the result value. + /// The result of the tool call as an instance of . + /// Thrown when a parameter name is not found. + /// Thrown when an argument is missing. + public async Task> InvokeAsync() + { + try + { + T? result = default; + + var arguments = GetArguments(); + var isAwaitable = Tool.Function.Method.ReturnType.GetMethod(nameof(Task.GetAwaiter)) is not null; + + if (isAwaitable) + { + var task = (Task)Tool.Function.Method.Invoke(Tool.Function.Instance, arguments); + + await task; + + const string resultPropertyName = "Result"; + var resultProperty = task.GetType().GetProperty(resultPropertyName); + result = resultProperty is not null ? (T)resultProperty.GetValue(task) : default; + } + else + { + result = (T)Tool.Function.Method.Invoke(Tool.Function.Instance, arguments); + } + + return ToolCallResult.Success(result); + } + catch (Exception e) + { + return ToolCallResult.Failure(e); + } + } + + private object?[] GetArguments() + { + var parameters = Tool.Function.Method.GetParameters(); + var arguments = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var attribute = parameter.GetCustomAttribute(); + var parameterName = attribute is not null + ? string.IsNullOrWhiteSpace(attribute.Name) + ? parameter.Name + : attribute.Name + : parameter.Name; + + if (parameterName == null) + { + throw new ArgumentException($"Failed to find a valid parameter name for {Tool.Function.Method.DeclaringType}.{Tool.Function.Method.Name}()"); + } + + if (ToolUse.Input.TryGetValue(parameterName, out var value)) + { + arguments[i] = value is string s && parameter.ParameterType.IsEnum + ? Enum.Parse(parameter.ParameterType, s, true) + : value is JsonElement element + ? JsonSerializer.Deserialize(element.GetRawText(), parameter.ParameterType, JsonSerializationOptions.DefaultOptions) + : value; + } + else + { + arguments[i] = parameter.HasDefaultValue + ? parameter.DefaultValue + : throw new ArgumentException($"Missing argument for parameter '{parameter.Name}'"); + } + } + + return arguments; + } } \ No newline at end of file diff --git a/src/AnthropicClient/Models/ToolCallResult.cs b/src/AnthropicClient/Models/ToolCallResult.cs new file mode 100644 index 0000000..441c39a --- /dev/null +++ b/src/AnthropicClient/Models/ToolCallResult.cs @@ -0,0 +1,56 @@ +namespace AnthropicClient.Models; + +/// +/// Represents a tool call result. +/// +public class ToolCallResult +{ + /// + /// The value of the tool call result. + /// + public T? Value { get; } + + /// + /// The error of the tool call result. + /// + public Exception Error { get; } + + /// + /// Indicates whether the tool call was successful. + /// + public bool IsSuccess { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of the tool call result. + /// The error of the tool call result. + /// Indicates whether the tool call was successful. + /// A new instance of the class. + protected ToolCallResult(T? value, Exception error, bool isSuccess) + { + Value = value; + Error = error; + IsSuccess = isSuccess; + } + + /// + /// Creates a successful tool call result. + /// + /// The value of the tool call result. + /// A new instance of the class. + public static ToolCallResult Success(T? value) + { + return new ToolCallResult(value, null!, true); + } + + /// + /// Creates a failed ool call result. + /// + /// The error of the tool call result. + /// A new instance of the class. + public static ToolCallResult Failure(Exception error) + { + return new ToolCallResult(default!, error, false); + } +} \ No newline at end of file diff --git a/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs b/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs new file mode 100644 index 0000000..4afe175 --- /dev/null +++ b/tests/AnthropicClient.Tests/Unit/Models/ToolCallTests.cs @@ -0,0 +1,149 @@ +namespace AnthropicClient.Tests.Unit.Models; + +public class ToolCallTests +{ + [Fact] + public async Task InvokeAsync_WhenCalledToolHasNoParamsIsNotAwaitableAndToolCallIsSuccessful_ItShouldReturnSuccessResult() + { + var func = () => "Hello, World!"; + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + var toolCall = new ToolCall(tool, new ToolUseContent()); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello, World!"); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasNoParamsIsNotAwaitableAndToolCallFails_ItShouldReturnFailureResult() + { + var func = new Func(() => throw new Exception("Error")); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + var toolCall = new ToolCall(tool, new ToolUseContent()); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Value.Should().BeNull(); + result.Error.Should().NotBeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasNoParamsIsAwaitableAndToolCallIsSuccessful_ItShouldReturnSuccessResult() + { + var func = async () => await Task.FromResult("Hello, World!"); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + var toolCall = new ToolCall(tool, new ToolUseContent()); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello, World!"); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasNoParamsIsAwaitableAndToolCallFails_ItShouldReturnFailureResult() + { + var func = new Func>(() => throw new Exception("Error")); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + var toolCall = new ToolCall(tool, new ToolUseContent()); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Value.Should().BeNull(); + result.Error.Should().NotBeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasParamsIsNotAwaitableAndToolCallIsSuccessful_ItShouldReturnSuccessResult() + { + var func = (int i) => i.ToString(); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + + var input = new Dictionary { { "i", 42 } }; + var toolCall = new ToolCall(tool, new ToolUseContent { Input = input }); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("42"); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasParamsIsNotAwaitableAndToolCallFails_ItShouldReturnFailureResult() + { + var func = new Func(i => throw new Exception("Error")); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + + var input = new Dictionary { { "i", 42 } }; + var toolCall = new ToolCall(tool, new ToolUseContent { Input = input }); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Value.Should().BeNull(); + result.Error.Should().NotBeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasParamsIsAwaitableAndToolCallIsSuccessful_ItShouldReturnSuccessResult() + { + var func = async (int i) => await Task.FromResult(i.ToString()); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + + var input = new Dictionary { { "i", 42 } }; + var toolCall = new ToolCall(tool, new ToolUseContent { Input = input }); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("42"); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledToolHasParamsIsAwaitableAndToolCallFails_ItShouldReturnFailureResult() + { + var func = new Func>(i => throw new Exception("Error")); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + + var input = new Dictionary { { "i", 42 } }; + var toolCall = new ToolCall(tool, new ToolUseContent { Input = input }); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Value.Should().BeNull(); + result.Error.Should().NotBeNull(); + } + + [Fact] + public async Task InvokeAsync_WhenCalledAndToolHasCustomParameterName_ItShouldReturnSuccessResult() + { + var func = ([FunctionParameter("Person's Age", "Age")]int i) => i.ToString(); + var anthropicFunction = new AnthropicFunction(func.Method, func.Target); + var tool = new Tool("tool", "description", anthropicFunction); + + var input = new Dictionary { { "Age", 42 } }; + var toolCall = new ToolCall(tool, new ToolUseContent { Input = input }); + + var result = await toolCall.InvokeAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("42"); + result.Error.Should().BeNull(); + } +} \ No newline at end of file