Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions lib/req_llm/tool_call.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
76 changes: 76 additions & 0 deletions test/req_llm/tool_call_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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"}))
Expand Down