From 3f01ad2b07e6696874b6d129ba491459fe8ebe0f Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:34:46 -0600 Subject: [PATCH] feat: add ToolCall.from_map/1 and to_map/1 for normalized tool call handling Adds two helper functions to ToolCall module: - to_map/1: Convert ToolCall struct to flat map with decoded arguments - from_map/1: Normalize map or ToolCall to standard %{id, name, arguments} format These are used by jido_ai for consistent tool call processing across providers. Amp-Thread-ID: https://ampcode.com/threads/T-019c1692-7ccb-732c-b146-db36ab4ec368 Co-authored-by: Amp --- lib/req_llm/tool_call.ex | 61 ++++++++++++++++++++++++++ test/req_llm/tool_call_test.exs | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/lib/req_llm/tool_call.ex b/lib/req_llm/tool_call.ex index b69b41641..673784aea 100644 --- a/lib/req_llm/tool_call.ex +++ b/lib/req_llm/tool_call.ex @@ -104,6 +104,67 @@ defmodule ReqLLM.ToolCall do end end + @doc """ + Convert a ToolCall to a flat map with decoded arguments. + + Returns a map with `:id`, `:name`, and `:arguments` keys. + Arguments are decoded from JSON; returns empty map if decoding fails. + + ## Examples + + iex> tc = ToolCall.new("call_123", "get_weather", ~s({"location":"Paris"})) + iex> ToolCall.to_map(tc) + %{id: "call_123", name: "get_weather", arguments: %{"location" => "Paris"}} + + iex> tc = ToolCall.new("call_456", "get_time", "{}") + iex> ToolCall.to_map(tc) + %{id: "call_456", name: "get_time", arguments: %{}} + """ + @spec to_map(t()) :: %{id: String.t(), name: String.t(), arguments: map()} + def to_map(%__MODULE__{id: id, function: %{name: name}} = tc) do + %{ + id: id, + name: name, + arguments: args_map(tc) || %{} + } + end + + @doc """ + Normalize a map or ToolCall to the standard `%{id, name, arguments}` format. + + Accepts ToolCall structs or plain maps with atom/string keys. + Arguments are decoded from JSON if provided as a string. + + ## Examples + + iex> ToolCall.from_map(%{"id" => "call_123", "name" => "get_weather", "arguments" => ~s({"location":"Paris"})}) + %{id: "call_123", name: "get_weather", arguments: %{"location" => "Paris"}} + + iex> tc = ToolCall.new("call_456", "get_time", "{}") + iex> ToolCall.from_map(tc) + %{id: "call_456", name: "get_time", arguments: %{}} + """ + @spec from_map(t() | map()) :: %{id: String.t(), name: String.t(), arguments: map()} + def from_map(%__MODULE__{} = tc), do: to_map(tc) + + def from_map(map) when is_map(map) do + %{ + id: map[:id] || map["id"] || generate_id(), + name: map[:name] || map["name"], + arguments: parse_arguments(map[:arguments] || map["arguments"] || %{}) + } + end + + defp parse_arguments(args) when is_binary(args) do + case Jason.decode(args) do + {:ok, parsed} -> parsed + {:error, _} -> %{} + end + end + + defp parse_arguments(args) when is_map(args), do: args + defp parse_arguments(_), do: %{} + @doc """ Check if a ToolCall matches the given function name. """ diff --git a/test/req_llm/tool_call_test.exs b/test/req_llm/tool_call_test.exs index 44b300193..ab262d985 100644 --- a/test/req_llm/tool_call_test.exs +++ b/test/req_llm/tool_call_test.exs @@ -51,6 +51,29 @@ defmodule ReqLLM.ToolCallTest do end end + describe "to_map/1" do + test "converts ToolCall to flat map with decoded arguments" do + tool_call = ToolCall.new("call_123", "get_weather", ~s({"location":"Paris"})) + result = ToolCall.to_map(tool_call) + + assert result == %{id: "call_123", name: "get_weather", arguments: %{"location" => "Paris"}} + end + + test "returns empty map for invalid JSON arguments" do + tool_call = ToolCall.new("call_123", "broken", "invalid json") + result = ToolCall.to_map(tool_call) + + assert result == %{id: "call_123", name: "broken", arguments: %{}} + end + + test "handles empty arguments" do + tool_call = ToolCall.new("call_456", "no_args", "{}") + result = ToolCall.to_map(tool_call) + + assert result == %{id: "call_456", name: "no_args", arguments: %{}} + end + end + describe "args_map/1" do test "decodes valid JSON arguments to map" do args = ~s({"location":"Paris","unit":"celsius"}) @@ -177,6 +200,59 @@ defmodule ReqLLM.ToolCallTest do end end + describe "from_map/1" do + test "converts a ToolCall struct (delegates to to_map)" do + tool_call = ToolCall.new("call_123", "get_weather", ~s({"location":"Paris"})) + result = ToolCall.from_map(tool_call) + + assert result == %{id: "call_123", name: "get_weather", arguments: %{"location" => "Paris"}} + end + + test "converts a map with string keys" do + map = %{"id" => "call_456", "name" => "get_time", "arguments" => ~s({"timezone":"UTC"})} + result = ToolCall.from_map(map) + + assert result == %{id: "call_456", name: "get_time", arguments: %{"timezone" => "UTC"}} + end + + test "converts a map with atom keys" do + map = %{id: "call_789", name: "search", arguments: %{"query" => "elixir"}} + result = ToolCall.from_map(map) + + assert result == %{id: "call_789", name: "search", arguments: %{"query" => "elixir"}} + end + + test "parses JSON string arguments" do + map = %{id: "call_abc", name: "calc", arguments: ~s({"x":1,"y":2})} + result = ToolCall.from_map(map) + + assert result == %{id: "call_abc", name: "calc", arguments: %{"x" => 1, "y" => 2}} + end + + test "generates id when missing" do + map = %{"name" => "no_id_func", "arguments" => "{}"} + result = ToolCall.from_map(map) + + assert String.starts_with?(result.id, "call_") + assert result.name == "no_id_func" + assert result.arguments == %{} + end + + test "handles missing arguments" do + map = %{id: "call_xyz", name: "no_args"} + result = ToolCall.from_map(map) + + assert result == %{id: "call_xyz", name: "no_args", arguments: %{}} + end + + test "handles invalid JSON arguments" do + map = %{id: "call_bad", name: "broken", arguments: "not valid json"} + result = ToolCall.from_map(map) + + assert result == %{id: "call_bad", name: "broken", arguments: %{}} + end + end + describe "Inspect implementation" do test "provides readable inspection format" do tool_call = ToolCall.new("call_123", "get_weather", ~s({"location":"Paris"}))