From bce3669d087144b9d82a53faf21de3eda59c558a Mon Sep 17 00:00:00 2001 From: Victor Kryukov Date: Fri, 23 Jan 2026 15:51:39 -0800 Subject: [PATCH 1/3] feat: support structured and multimodal tool outputs Allow tool results to carry structured output metadata and content parts, and wire provider encoders accordingly. Add ToolResult, update context helpers, and expand tests for Anthropic/Google/OpenAI responses. Closes #349 --- lib/req_llm/context.ex | 177 ++++++++++++++++-- lib/req_llm/provider/defaults.ex | 4 +- .../providers/amazon_bedrock/converse.ex | 22 ++- lib/req_llm/providers/anthropic/context.ex | 27 +-- lib/req_llm/providers/google.ex | 52 +++-- lib/req_llm/providers/openai/responses_api.ex | 23 ++- lib/req_llm/tool.ex | 1 + lib/req_llm/tool_result.ex | 42 +++++ .../openai/responses_api_unit_test.exs | 35 ++++ test/providers/anthropic_test.exs | 45 +++++ test/providers/google_test.exs | 48 +++++ test/req_llm/context_conversational_test.exs | 19 ++ test/req_llm/context_test.exs | 34 ++++ 13 files changed, 481 insertions(+), 48 deletions(-) create mode 100644 lib/req_llm/tool_result.ex diff --git a/lib/req_llm/context.ex b/lib/req_llm/context.ex index 6f2d48687..980c76ac7 100644 --- a/lib/req_llm/context.ex +++ b/lib/req_llm/context.ex @@ -24,6 +24,7 @@ defmodule ReqLLM.Context do alias ReqLLM.Message alias ReqLLM.Message.ContentPart alias ReqLLM.ToolCall + alias ReqLLM.ToolResult @derive Jason.Encoder typedstruct enforce: true do @@ -313,8 +314,26 @@ defmodule ReqLLM.Context do assistant(text || "", tool_calls: tool_calls) end - @doc "Create a tool result message with tool_call_id and content." - @spec tool_result(String.t(), String.t()) :: Message.t() + @doc """ + Create a tool result message with tool_call_id and content. + + Accepts plain text, content parts, or a `ReqLLM.ToolResult` for structured and + multi-part outputs. + """ + @spec tool_result(String.t(), String.t() | [ContentPart.t()] | ToolResult.t() | term()) :: + Message.t() + def tool_result(tool_call_id, %ToolResult{} = result) do + tool_result_from_struct(tool_call_id, nil, result) + end + + def tool_result(tool_call_id, content) when is_list(content) do + %Message{ + role: :tool, + content: normalize_content_parts(content), + tool_call_id: tool_call_id + } + end + def tool_result(tool_call_id, content) when is_binary(content) do %Message{ role: :tool, @@ -323,8 +342,35 @@ defmodule ReqLLM.Context do } end - @doc "Create a tool result message with tool_call_id, name, and content." - @spec tool_result(String.t(), String.t(), String.t()) :: Message.t() + def tool_result(tool_call_id, output) do + tool_result_message(nil, tool_call_id, output) + end + + @doc """ + Create a tool result message with tool_call_id, name, and content. + + Accepts plain text, content parts, or a `ReqLLM.ToolResult` for structured and + multi-part outputs. + """ + @spec tool_result( + String.t(), + String.t(), + String.t() | [ContentPart.t()] | ToolResult.t() | term() + ) :: + Message.t() + def tool_result(tool_call_id, name, %ToolResult{} = result) do + tool_result_from_struct(tool_call_id, name, result) + end + + def tool_result(tool_call_id, name, content) when is_list(content) do + %Message{ + role: :tool, + name: name, + content: normalize_content_parts(content), + tool_call_id: tool_call_id + } + end + def tool_result(tool_call_id, name, content) when is_binary(content) do %Message{ role: :tool, @@ -334,6 +380,10 @@ defmodule ReqLLM.Context do } end + def tool_result(tool_call_id, name, output) do + tool_result_message(name, tool_call_id, output) + end + @deprecated "Use assistant(\"\", tool_calls: [{name, input}]) instead" @doc "Build an assistant message with a tool call." @spec assistant_tool_call(String.t(), term(), keyword()) :: Message.t() @@ -352,18 +402,26 @@ defmodule ReqLLM.Context do assistant("", tool_calls: tool_calls, metadata: meta) end - @doc "Build a tool result message." - @spec tool_result_message(String.t(), String.t(), term(), map()) :: Message.t() + @doc """ + Build a tool result message. + + Non-text outputs are stored in metadata to allow provider-specific structured + encoding. + """ + @spec tool_result_message(String.t() | nil, String.t(), term(), map()) :: Message.t() def tool_result_message(tool_name, tool_call_id, output, meta \\ %{}) do - content_str = if is_binary(output), do: output, else: Jason.encode!(output) + {content, output_meta} = normalize_tool_result_input(output) + metadata = Map.merge(meta, output_meta) - %Message{ + message = %Message{ role: :tool, name: tool_name, tool_call_id: tool_call_id, - content: [ContentPart.text(content_str)], - metadata: meta + content: content, + metadata: metadata } + + if is_nil(tool_name), do: %{message | name: nil}, else: message end @doc """ @@ -371,6 +429,8 @@ defmodule ReqLLM.Context do Takes a list of tool call maps (with :id, :name, :arguments keys) and a list of available tools, executes each call, and appends the results as tool messages. + Tool callbacks may return plain text, structured data, content parts, or a + `ReqLLM.ToolResult`. ## Parameters @@ -398,8 +458,12 @@ defmodule ReqLLM.Context do tool_result_msg = tool_result_message(name, id, result) append(ctx, tool_result_msg) - {:error, _error} -> - error_result = %{error: "Tool execution failed"} + {:error, %ToolResult{} = result} -> + tool_result_msg = tool_result_message(name, id, result) + append(ctx, tool_result_msg) + + {:error, error} -> + error_result = %{error: to_string(error)} tool_result_msg = tool_result_message(name, id, error_result) append(ctx, tool_result_msg) end @@ -753,6 +817,25 @@ defmodule ReqLLM.Context do {:ok, assistant(content, tool_calls: tool_calls)} end + defp convert_loose_map(%{role: :tool, tool_call_id: id, content: content} = msg) + when is_binary(id) and is_list(content) do + name = Map.get(msg, :name) + + if name do + {:ok, tool_result(id, name, content)} + else + {:ok, tool_result(id, content)} + end + end + + defp convert_loose_map(%{role: :tool, tool_call_id: id, output: output} = msg) + when is_binary(id) do + name = Map.get(msg, :name) + meta = Map.get(msg, :metadata, %{}) + + {:ok, tool_result_message(name, id, output, meta)} + end + defp convert_loose_map(%{role: :tool, tool_call_id: id, content: content} = msg) when is_binary(id) and is_binary(content) do name = Map.get(msg, :name) @@ -845,4 +928,74 @@ defmodule ReqLLM.Context do end end) end + + defp tool_result_from_struct(tool_call_id, tool_name, %ToolResult{} = result) do + {content, output_meta} = normalize_tool_result_struct(result) + + message = %Message{ + role: :tool, + name: tool_name, + tool_call_id: tool_call_id, + content: content, + metadata: output_meta + } + + if is_nil(tool_name), do: %{message | name: nil}, else: message + end + + defp normalize_tool_result_struct(%ToolResult{} = result) do + content = normalize_tool_result_content(result.content, result.output) + metadata = ToolResult.put_output_metadata(result.metadata, result.output) + {content, metadata} + end + + defp normalize_tool_result_input(%ToolResult{} = result) do + normalize_tool_result_struct(result) + end + + defp normalize_tool_result_input(content) when is_list(content) do + {normalize_content_parts(content), %{}} + end + + defp normalize_tool_result_input(content) when is_binary(content) do + {[ContentPart.text(content)], %{}} + end + + defp normalize_tool_result_input(output) do + encoded = encode_tool_output(output) + {[ContentPart.text(encoded)], %{ToolResult.metadata_key() => output}} + end + + defp normalize_tool_result_content(nil, nil), do: [] + + defp normalize_tool_result_content(nil, output) do + [ContentPart.text(encode_tool_output(output))] + end + + defp normalize_tool_result_content(content, _output) when is_list(content) do + normalize_content_parts(content) + end + + defp normalize_tool_result_content(content, _output) when is_binary(content) do + [ContentPart.text(content)] + end + + defp normalize_tool_result_content(content, _output) do + [ContentPart.text(to_string(content))] + end + + defp normalize_content_parts(parts) do + Enum.flat_map(parts, fn + %ContentPart{} = part -> [part] + text when is_binary(text) -> [ContentPart.text(text)] + part -> [ContentPart.text(to_string(part))] + end) + end + + defp encode_tool_output(output) when is_binary(output), do: output + + defp encode_tool_output(output) when is_map(output) or is_list(output), + do: Jason.encode!(output) + + defp encode_tool_output(output), do: to_string(output) end diff --git a/lib/req_llm/provider/defaults.ex b/lib/req_llm/provider/defaults.ex index f8910a750..f31d81b04 100644 --- a/lib/req_llm/provider/defaults.ex +++ b/lib/req_llm/provider/defaults.ex @@ -591,7 +591,8 @@ defmodule ReqLLM.Provider.Defaults do tool_calls: tc, tool_call_id: tcid, name: name, - reasoning_details: rd + reasoning_details: rd, + metadata: metadata }) do base_message = %{ role: to_string(r), @@ -603,6 +604,7 @@ defmodule ReqLLM.Provider.Defaults do |> maybe_add_field(:tool_call_id, tcid) |> maybe_add_field(:name, name) |> maybe_add_field(:reasoning_details, rd) + |> maybe_add_field(:metadata, metadata) end defp maybe_add_field(message, _key, nil), do: message diff --git a/lib/req_llm/providers/amazon_bedrock/converse.ex b/lib/req_llm/providers/amazon_bedrock/converse.ex index 9b78fd61d..6a696e4e5 100644 --- a/lib/req_llm/providers/amazon_bedrock/converse.ex +++ b/lib/req_llm/providers/amazon_bedrock/converse.ex @@ -510,14 +510,14 @@ defmodule ReqLLM.Providers.AmazonBedrock.Converse do end # Tool result message (new ToolCall pattern) - defp encode_message(%Message{role: :tool, tool_call_id: id, content: content}) do + defp encode_message(%Message{role: :tool, tool_call_id: id} = msg) do %{ "role" => "user", "content" => [ %{ "toolResult" => %{ "toolUseId" => id, - "content" => [%{"text" => extract_text_content(content)}] + "content" => [%{"text" => extract_tool_result_text(msg)}] } } ] @@ -621,6 +621,24 @@ defmodule ReqLLM.Providers.AmazonBedrock.Converse do defp extract_text_content(_), do: "" + defp extract_tool_result_text(%Message{content: content} = msg) do + text = extract_text_content(content) + output = ReqLLM.ToolResult.output_from_message(msg) + + cond do + text != "" -> text + output == nil -> "" + true -> encode_tool_output(output) + end + end + + defp encode_tool_output(output) when is_binary(output), do: output + + defp encode_tool_output(output) when is_map(output) or is_list(output), + do: Jason.encode!(output) + + defp encode_tool_output(output), do: to_string(output) + defp image_format_from_media_type("image/png"), do: "png" defp image_format_from_media_type("image/jpeg"), do: "jpeg" defp image_format_from_media_type("image/jpg"), do: "jpeg" diff --git a/lib/req_llm/providers/anthropic/context.ex b/lib/req_llm/providers/anthropic/context.ex index b8d83fdfa..72d18d4b0 100644 --- a/lib/req_llm/providers/anthropic/context.ex +++ b/lib/req_llm/providers/anthropic/context.ex @@ -136,14 +136,14 @@ defmodule ReqLLM.Providers.Anthropic.Context do } end - defp encode_message(%ReqLLM.Message{role: :tool, tool_call_id: id, content: content}) do + defp encode_message(%ReqLLM.Message{role: :tool, tool_call_id: id} = msg) do %{ role: "user", content: [ %{ type: "tool_result", tool_use_id: id, - content: extract_text_content(content) + content: encode_tool_result_content(msg) } ] } @@ -283,18 +283,23 @@ defmodule ReqLLM.Providers.Anthropic.Context do defp encode_single_reasoning_detail(_), do: [] - defp extract_text_content(content_parts) when is_list(content_parts) do - content_parts - |> Enum.find_value(fn - %ReqLLM.Message.ContentPart{type: :text, text: text} -> text - _ -> nil - end) - |> case do - nil -> "" - text -> text + defp encode_tool_result_content(%ReqLLM.Message{content: content} = msg) do + output = ReqLLM.ToolResult.output_from_message(msg) + + cond do + content != [] -> encode_content(content) + output != nil -> encode_tool_output(output) + true -> "" end end + defp encode_tool_output(output) when is_binary(output), do: output + + defp encode_tool_output(output) when is_map(output) or is_list(output), + do: Jason.encode!(output) + + defp encode_tool_output(output), do: to_string(output) + defp add_tools(request, []), do: request defp add_tools(request, tools) when is_list(tools) do diff --git a/lib/req_llm/providers/google.ex b/lib/req_llm/providers/google.ex index 7f8d1468c..ef9048ade 100644 --- a/lib/req_llm/providers/google.ex +++ b/lib/req_llm/providers/google.ex @@ -1705,25 +1705,14 @@ defmodule ReqLLM.Providers.Google do tool_result_parts = case message do + %{tool_call_id: _call_id, role: "tool"} -> + [build_tool_result_part(message, raw_content)] + %{"tool_call_id" => _call_id, "role" => "tool"} -> - [ - %{ - functionResponse: %{ - name: "unknown", - response: %{content: extract_content_text(raw_content)} - } - } - ] + [build_tool_result_part(message, raw_content)] %{tool_call_id: _call_id, role: :tool} -> - [ - %{ - functionResponse: %{ - name: "unknown", - response: %{content: extract_content_text(raw_content)} - } - } - ] + [build_tool_result_part(message, raw_content)] _ -> [] @@ -1812,6 +1801,37 @@ defmodule ReqLLM.Providers.Google do defp extract_content_text(_), do: "" + defp build_tool_result_part(message, raw_content) do + %{ + functionResponse: %{ + name: tool_result_name(message), + response: tool_result_response(message, raw_content) + } + } + end + + defp tool_result_name(%{name: name}) when is_binary(name) and name != "", do: name + defp tool_result_name(%{"name" => name}) when is_binary(name) and name != "", do: name + defp tool_result_name(_), do: "unknown" + + defp tool_result_response(message, raw_content) do + output = ReqLLM.ToolResult.output_from_message(message) + + cond do + is_map(output) or is_list(output) -> + output + + is_binary(output) -> + %{content: output} + + output != nil -> + %{content: to_string(output)} + + true -> + %{content: extract_content_text(raw_content)} + end + end + # Extract text content from a message for system instruction defp extract_text_content(%{content: content}) when is_binary(content), do: content defp extract_text_content(%{"content" => content}) when is_binary(content), do: content diff --git a/lib/req_llm/providers/openai/responses_api.ex b/lib/req_llm/providers/openai/responses_api.ex index fb91a5c2a..58ba64d91 100644 --- a/lib/req_llm/providers/openai/responses_api.ex +++ b/lib/req_llm/providers/openai/responses_api.ex @@ -572,19 +572,30 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do defp extract_tool_outputs_from_messages(tool_messages) do Enum.map(tool_messages, fn msg -> - output_text = - msg.content - |> Enum.find_value(fn part -> - if part.type == :text, do: part.text - end) || "" + output = + case ReqLLM.ToolResult.output_from_message(msg) do + nil -> extract_tool_output_text(msg.content) + value -> value + end %{ call_id: msg.tool_call_id, - output: output_text + output: output } end) end + defp extract_tool_output_text(content_parts) do + content_parts + |> Enum.find_value(fn part -> + if part.type == :text, do: part.text + end) + |> case do + nil -> "" + text -> text + end + end + defp encode_tool_outputs(outputs) when is_list(outputs) do Enum.map(outputs, fn output -> call_id = output[:call_id] || output["call_id"] diff --git a/lib/req_llm/tool.ex b/lib/req_llm/tool.ex index 5d0dc6b41..5edcc9f79 100644 --- a/lib/req_llm/tool.ex +++ b/lib/req_llm/tool.ex @@ -224,6 +224,7 @@ defmodule ReqLLM.Tool do Validates input parameters against the tool's schema and calls the callback function. The callback is expected to return `{:ok, result}` or `{:error, reason}`. + Tool results can be plain text, structured data, content parts, or a `ReqLLM.ToolResult`. ## Parameters diff --git a/lib/req_llm/tool_result.ex b/lib/req_llm/tool_result.ex new file mode 100644 index 000000000..09610f45e --- /dev/null +++ b/lib/req_llm/tool_result.ex @@ -0,0 +1,42 @@ +defmodule ReqLLM.ToolResult do + @moduledoc """ + ToolResult represents structured and multi-part tool outputs. + + Tool outputs can include structured data via `output` and/or multimodal + content parts via `content`. + """ + + use TypedStruct + + alias ReqLLM.Message + alias ReqLLM.Message.ContentPart + + @metadata_key :tool_output + + typedstruct enforce: false do + field(:content, [ContentPart.t()] | nil, default: nil) + field(:output, term() | nil, default: nil) + field(:metadata, map(), default: %{}) + end + + @spec metadata_key() :: atom() + def metadata_key, do: @metadata_key + + @spec output_from_message(Message.t() | map()) :: term() | nil + def output_from_message(%Message{metadata: metadata}) when is_map(metadata) do + Map.get(metadata, @metadata_key) + end + + def output_from_message(%{metadata: metadata}) when is_map(metadata) do + Map.get(metadata, @metadata_key) || Map.get(metadata, to_string(@metadata_key)) + end + + def output_from_message(_), do: nil + + @spec put_output_metadata(map(), term() | nil) :: map() + def put_output_metadata(metadata, nil) when is_map(metadata), do: metadata + + def put_output_metadata(metadata, output) when is_map(metadata) do + Map.put(metadata, @metadata_key, output) + end +end diff --git a/test/provider/openai/responses_api_unit_test.exs b/test/provider/openai/responses_api_unit_test.exs index 63c85b27b..be399f0e5 100644 --- a/test/provider/openai/responses_api_unit_test.exs +++ b/test/provider/openai/responses_api_unit_test.exs @@ -122,6 +122,41 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do assert body["tool_choice"] == "required" end + test "encodes structured tool outputs from context metadata" do + tool_call = %ReqLLM.ToolCall{ + id: "call_1", + type: "function", + function: %{name: "get_weather", arguments: ~s({"location":"SF"})} + } + + assistant_msg = %ReqLLM.Message{ + role: :assistant, + content: [], + tool_calls: [tool_call] + } + + tool_result = + ReqLLM.Context.tool_result_message( + "get_weather", + "call_1", + %ReqLLM.ToolResult{output: %{temp: 72}} + ) + + context = %ReqLLM.Context{messages: [assistant_msg, tool_result]} + request = build_request(context: context) + + encoded = ResponsesAPI.encode_body(request) + body = Jason.decode!(encoded.body) + + tool_output = + Enum.find(body["input"], fn item -> + item["type"] == "function_call_output" + end) + + assert tool_output["call_id"] == "call_1" + assert Jason.decode!(tool_output["output"]) == %{"temp" => 72} + end + test "encodes specific tool choice with atom keys" do request = build_request(tool_choice: %{type: "function", function: %{name: "get_weather"}}) diff --git a/test/providers/anthropic_test.exs b/test/providers/anthropic_test.exs index a2abfda86..a94ed56b7 100644 --- a/test/providers/anthropic_test.exs +++ b/test/providers/anthropic_test.exs @@ -8,6 +8,7 @@ defmodule ReqLLM.Providers.AnthropicTest do use ReqLLM.ProviderCase, provider: ReqLLM.Providers.Anthropic + alias ReqLLM.Message.ContentPart alias ReqLLM.Providers.Anthropic describe "provider contract" do @@ -155,6 +156,50 @@ defmodule ReqLLM.Providers.AnthropicTest do assert result2["tool_use_id"] == "tool_2" end + test "encode_body preserves multimodal tool_result content blocks" do + {:ok, model} = ReqLLM.model("anthropic:claude-sonnet-4-5-20250929") + + image_part = ContentPart.image(<<137, 80, 78, 71>>, "image/png") + file_part = ContentPart.file("doc", "note.txt", "text/plain") + + context = + ReqLLM.Context.new([ + ReqLLM.Context.user("Use the tool."), + ReqLLM.Context.assistant("", + tool_calls: [ + %ReqLLM.ToolCall{ + id: "tool_1", + type: "function", + function: %{name: "get_asset", arguments: ~s({"id":"1"})} + } + ] + ), + ReqLLM.Context.tool_result("tool_1", [image_part, file_part]) + ]) + + mock_request = %Req.Request{ + options: [ + context: context, + model: model.model, + stream: false + ] + } + + updated_request = Anthropic.encode_body(mock_request) + decoded = Jason.decode!(updated_request.body) + + tool_result_msg = List.last(decoded["messages"]) + [tool_result_block] = tool_result_msg["content"] + assert tool_result_block["type"] == "tool_result" + assert tool_result_block["tool_use_id"] == "tool_1" + + content_blocks = tool_result_block["content"] + assert is_list(content_blocks) + + assert Enum.any?(content_blocks, fn block -> block["type"] == "image" end) + assert Enum.any?(content_blocks, fn block -> block["type"] == "document" end) + end + test "encode_body without tools" do {:ok, model} = ReqLLM.model("anthropic:claude-sonnet-4-5-20250929") context = context_fixture() diff --git a/test/providers/google_test.exs b/test/providers/google_test.exs index 9cd82bd4d..e7a8328f5 100644 --- a/test/providers/google_test.exs +++ b/test/providers/google_test.exs @@ -201,6 +201,54 @@ defmodule ReqLLM.Providers.GoogleTest do assert is_list(tool_def["functionDeclarations"]) end + test "encode_body includes tool_result name and structured response" do + {:ok, model} = ReqLLM.model("google:gemini-1.5-flash") + + tool_result = + Context.tool_result_message( + "get_weather", + "call_1", + %ReqLLM.ToolResult{output: %{"temperature" => 72}} + ) + + context = + Context.new([ + Context.user("What's the weather?"), + Context.assistant("", + tool_calls: [ + %ReqLLM.ToolCall{ + id: "call_1", + type: "function", + function: %{name: "get_weather", arguments: ~s({"location":"SF"})} + } + ] + ), + tool_result + ]) + + mock_request = %Req.Request{ + options: [ + context: context, + model: model.model, + stream: false, + operation: :chat + ] + } + + updated_request = Google.encode_body(mock_request) + decoded = Jason.decode!(updated_request.body) + + tool_parts = + decoded["contents"] + |> Enum.flat_map(& &1["parts"]) + |> Enum.filter(&Map.has_key?(&1, "functionResponse")) + + [tool_part] = tool_parts + function_response = tool_part["functionResponse"] + assert function_response["name"] == "get_weather" + assert function_response["response"]["temperature"] == 72 + end + test "encode_body with Google-specific options" do {:ok, model} = ReqLLM.model("google:gemini-1.5-flash") context = context_fixture() diff --git a/test/req_llm/context_conversational_test.exs b/test/req_llm/context_conversational_test.exs index 5baac0c41..209331c63 100644 --- a/test/req_llm/context_conversational_test.exs +++ b/test/req_llm/context_conversational_test.exs @@ -4,6 +4,7 @@ defmodule ReqLLM.ContextConversationalTest do alias ReqLLM.Context alias ReqLLM.Message alias ReqLLM.Message.ContentPart + alias ReqLLM.ToolResult describe "append/2" do test "appends single message" do @@ -68,6 +69,14 @@ defmodule ReqLLM.ContextConversationalTest do } = message end + test "creates tool result message with content parts" do + image = ContentPart.image(<<137, 80, 78, 71>>, "image/png") + message = Context.tool_result("call_456", [ContentPart.text("Result"), image]) + + assert %Message{role: :tool, tool_call_id: "call_456"} = message + assert [%ContentPart{type: :text}, %ContentPart{type: :image}] = message.content + end + test "creates tool result message with JSON output" do message = Context.tool_result_message("my_tool", "call_123", "success") @@ -139,6 +148,16 @@ defmodule ReqLLM.ContextConversationalTest do assert part.type == :text end + test "preserves structured output metadata" do + result = %ToolResult{output: %{status: "ok"}, metadata: %{source: "tool"}} + message = Context.tool_result_message("test_tool", "call_789", result) + + assert message.metadata[:source] == "tool" + assert message.metadata[:tool_output] == %{status: "ok"} + assert [%ContentPart{type: :text, text: text}] = message.content + assert text =~ "status" + end + test "defaults to empty metadata" do message = Context.tool_result_message("test_tool", "call_456", "result") diff --git a/test/req_llm/context_test.exs b/test/req_llm/context_test.exs index 14bfc5988..8075bd15f 100644 --- a/test/req_llm/context_test.exs +++ b/test/req_llm/context_test.exs @@ -697,6 +697,40 @@ defmodule ReqLLM.ContextTest do assert [%ContentPart{type: :text, text: "Result data"}] = msg.content end + test "normalizes tool result message with content parts" do + input = [ + %{ + role: :tool, + tool_call_id: "call_789", + content: [ContentPart.text("ok")] + } + ] + + {:ok, context} = Context.normalize(input, validate: false) + + [msg] = context.messages + assert msg.role == :tool + assert msg.tool_call_id == "call_789" + assert [%ContentPart{type: :text, text: "ok"}] = msg.content + end + + test "normalizes tool result message with output field" do + input = [ + %{ + role: :tool, + tool_call_id: "call_999", + output: %{ok: true} + } + ] + + {:ok, context} = Context.normalize(input, validate: false) + + [msg] = context.messages + assert msg.role == :tool + assert msg.tool_call_id == "call_999" + assert msg.metadata[:tool_output] == %{ok: true} + end + test "normalizes full tool conversation flow" do input = [ %{role: :user, content: "What's the weather in SF?"}, From 837c28e2eb221edb37c65a364d3c4d569ca30a3e Mon Sep 17 00:00:00 2001 From: Victor Kryukov Date: Fri, 23 Jan 2026 15:56:47 -0800 Subject: [PATCH 2/3] Fix tests --- lib/req_llm/provider/defaults.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/req_llm/provider/defaults.ex b/lib/req_llm/provider/defaults.ex index f31d81b04..fc296c0b7 100644 --- a/lib/req_llm/provider/defaults.ex +++ b/lib/req_llm/provider/defaults.ex @@ -609,6 +609,7 @@ defmodule ReqLLM.Provider.Defaults do defp maybe_add_field(message, _key, nil), do: message defp maybe_add_field(message, _key, []), do: message + defp maybe_add_field(message, _key, %{} = value) when map_size(value) == 0, do: message defp maybe_add_field(message, key, value), do: Map.put(message, key, value) defp encode_openai_content(content) when is_binary(content), do: content From f7485c15e625c6283ea9e4ad1ae17c76cb27cdbc Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:37:10 -0600 Subject: [PATCH 3/3] fix: replace TypedStruct with Zoi schema in ToolResult TypedStruct is not a project dependency. Use Zoi.struct pattern consistent with rest of codebase (ToolCall, Message, etc.). Amp-Thread-ID: https://ampcode.com/threads/T-019c110a-9914-777f-9dd5-23a54d9d2fd0 Co-authored-by: Amp --- lib/req_llm/tool_result.ex | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/req_llm/tool_result.ex b/lib/req_llm/tool_result.ex index 09610f45e..ca60dca68 100644 --- a/lib/req_llm/tool_result.ex +++ b/lib/req_llm/tool_result.ex @@ -6,24 +6,26 @@ defmodule ReqLLM.ToolResult do content parts via `content`. """ - use TypedStruct + @metadata_key :tool_output - alias ReqLLM.Message - alias ReqLLM.Message.ContentPart + @schema Zoi.struct(__MODULE__, %{ + content: Zoi.list(Zoi.any()) |> Zoi.optional(), + output: Zoi.any() |> Zoi.optional(), + metadata: Zoi.map() |> Zoi.default(%{}) + }) - @metadata_key :tool_output + @type t :: unquote(Zoi.type_spec(@schema)) - typedstruct enforce: false do - field(:content, [ContentPart.t()] | nil, default: nil) - field(:output, term() | nil, default: nil) - field(:metadata, map(), default: %{}) - end + defstruct Zoi.Struct.struct_fields(@schema) + + @doc "Returns the Zoi schema for this module" + def schema, do: @schema @spec metadata_key() :: atom() def metadata_key, do: @metadata_key - @spec output_from_message(Message.t() | map()) :: term() | nil - def output_from_message(%Message{metadata: metadata}) when is_map(metadata) do + @spec output_from_message(ReqLLM.Message.t() | map()) :: term() | nil + def output_from_message(%ReqLLM.Message{metadata: metadata}) when is_map(metadata) do Map.get(metadata, @metadata_key) end