From 7c7e763809a478b7696947a962c92cd63b41e7ea Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Fri, 6 Mar 2026 09:08:16 -0700 Subject: [PATCH] more improvements on tool calling --- lib/elixir_ai/ai_utils/chat_utils.ex | 23 +++ lib/elixir_ai/ai_utils/stream_line_utils.ex | 79 +++----- lib/elixir_ai/application.ex | 1 + lib/elixir_ai/chat_runner.ex | 96 ++++------ lib/elixir_ai/tool_testing.ex | 4 + lib/elixir_ai_web/components/chat_message.ex | 181 ++++++++++++++++--- lib/elixir_ai_web/live/chat_live.ex | 33 ++-- 7 files changed, 275 insertions(+), 142 deletions(-) diff --git a/lib/elixir_ai/ai_utils/chat_utils.ex b/lib/elixir_ai/ai_utils/chat_utils.ex index e620bab..f9e32f3 100644 --- a/lib/elixir_ai/ai_utils/chat_utils.ex +++ b/lib/elixir_ai/ai_utils/chat_utils.ex @@ -19,6 +19,7 @@ defmodule ElixirAi.ChatUtils do headers = [{"authorization", "Bearer #{api_key}"}] + Logger.info("sending AI request with body: #{inspect(body)}") case Req.post(api_url, json: body, headers: headers, @@ -39,6 +40,28 @@ defmodule ElixirAi.ChatUtils do end) end + def api_message(%{role: :assistant, tool_calls: [_ | _] = tool_calls} = msg) do + %{ + role: "assistant", + content: Map.get(msg, :content, ""), + tool_calls: + Enum.map(tool_calls, fn call -> + %{ + id: call.id, + type: "function", + function: %{ + name: call.name, + arguments: call.arguments + } + } + end) + } + end + + def api_message(%{role: :tool, tool_call_id: tool_call_id, content: content}) do + %{role: "tool", tool_call_id: tool_call_id, content: content} + end + def api_message(%{role: role, content: content}) do %{role: Atom.to_string(role), content: content} end diff --git a/lib/elixir_ai/ai_utils/stream_line_utils.ex b/lib/elixir_ai/ai_utils/stream_line_utils.ex index f0973ec..f3c6827 100644 --- a/lib/elixir_ai/ai_utils/stream_line_utils.ex +++ b/lib/elixir_ai/ai_utils/stream_line_utils.ex @@ -76,72 +76,49 @@ defmodule ElixirAi.AiUtils.StreamLineUtils do ) end - # start tool call + # start and middle tool call def handle_stream_line(server, %{ "choices" => [ %{ "delta" => %{ - "tool_calls" => [ - %{ - "function" => %{ - "name" => tool_name, - "arguments" => tool_args_start - } - } - ] + "tool_calls" => tool_calls }, - "finish_reason" => nil, - "index" => tool_index + "finish_reason" => nil } ], "id" => id - }) do - send( - server, - {:ai_tool_call_start, id, {tool_name, tool_args_start, tool_index}} - ) - end + }) + when is_list(tool_calls) do + Enum.each(tool_calls, fn + %{ + "id" => tool_call_id, + "index" => tool_index, + "type" => "function", + "function" => %{"name" => tool_name, "arguments" => tool_args_start} + } -> + Logger.info("Received tool call start for tool #{tool_name}") - # middle tool call - def handle_stream_line(server, %{ - "choices" => [ - %{ - "delta" => %{ - "tool_calls" => [ - %{ - "function" => %{ - "arguments" => tool_args_diff - } - } - ] - }, - "finish_reason" => nil, - "index" => tool_index - } - ], - "id" => id - }) do - send( - server, - {:ai_tool_call_middle, id, {tool_args_diff, tool_index}} - ) + send( + server, + {:ai_tool_call_start, id, {tool_name, tool_args_start, tool_index, tool_call_id}} + ) + + %{"index" => tool_index, "function" => %{"arguments" => tool_args_diff}} -> + Logger.info("Received tool call middle for index #{tool_index}") + send(server, {:ai_tool_call_middle, id, {tool_args_diff, tool_index}}) + + other -> + Logger.warning("Unmatched tool call item: #{inspect(other)}") + end) end # end tool call def handle_stream_line(server, %{ - "choices" => [ - %{ - "delta" => %{}, - "finish_reason" => "tool_calls", - "index" => tool_index - } - ], + "choices" => [%{"finish_reason" => "tool_calls"}], "id" => id }) do - send( - server, - {:ai_tool_call_end, id, tool_index} - ) + Logger.info("Received tool call end") + send(server, {:ai_tool_call_end, id}) end def handle_stream_line(_server, %{"error" => error_info}) do diff --git a/lib/elixir_ai/application.ex b/lib/elixir_ai/application.ex index 48df4f7..82299a8 100644 --- a/lib/elixir_ai/application.ex +++ b/lib/elixir_ai/application.ex @@ -9,6 +9,7 @@ defmodule ElixirAi.Application do {DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: ElixirAi.PubSub}, ElixirAi.ChatRunner, + ElixirAi.ToolTesting, ElixirAiWeb.Endpoint ] diff --git a/lib/elixir_ai/chat_runner.ex b/lib/elixir_ai/chat_runner.ex index 61a1784..4ebab77 100644 --- a/lib/elixir_ai/chat_runner.ex +++ b/lib/elixir_ai/chat_runner.ex @@ -37,7 +37,7 @@ defmodule ElixirAi.ChatRunner do }, "read_thing" => %{ definition: ElixirAi.ToolTesting.read_thing_definition("read_thing"), - function: &ElixirAi.ToolTesting.get_thing/0 + function: &ElixirAi.ToolTesting.get_thing/1 } } end @@ -102,6 +102,7 @@ defmodule ElixirAi.ChatRunner do end def handle_info({:ai_stream_finish, _id}, state) do + Logger.info("AI stream finished for id #{state.streaming_response.id}, broadcasting end of AI response") broadcast(:end_ai_response) final_message = %{ @@ -120,7 +121,10 @@ defmodule ElixirAi.ChatRunner do }} end - def handle_info({:ai_tool_call_start, _id, {tool_name, tool_args_start, tool_index}}, state) do + def handle_info( + {:ai_tool_call_start, _id, {tool_name, tool_args_start, tool_index, tool_call_id}}, + state + ) do Logger.info("AI started tool call #{tool_name}") new_streaming_response = %{ @@ -131,7 +135,8 @@ defmodule ElixirAi.ChatRunner do %{ name: tool_name, arguments: tool_args_start, - index: tool_index + index: tool_index, + id: tool_call_id } ] } @@ -161,68 +166,45 @@ defmodule ElixirAi.ChatRunner do {:noreply, %{state | streaming_response: new_streaming_response}} end - def handle_info({:ai_tool_call_end, _id, tool_index}, state) do + def handle_info({:ai_tool_call_end, _id}, state) do + Logger.info("ending tool call with tools: #{inspect(state.streaming_response.tool_calls)}") + tool_calls = - Enum.map(state.streaming_response.tool_calls, fn - %{ - arguments: existing_args, - index: ^tool_index - } = tool_call -> - case Jason.decode(existing_args) do - {:ok, decoded_args} -> - tool_function = tools()[tool_call.name].function - res = tool_function.(decoded_args) + Enum.map(state.streaming_response.tool_calls, fn tool_call -> + case Jason.decode(tool_call.arguments) do + {:ok, decoded_args} -> + tool_function = tools()[tool_call.name].function + res = tool_function.(decoded_args) + Map.put(tool_call, :result, res) - Map.put(tool_call, :result, res) - - {:error, e} -> - Map.put(tool_call, :error, "Failed to decode tool arguments: #{inspect(e)}") - end - - other -> - other + {:error, e} -> + Map.put(tool_call, :error, "Failed to decode tool arguments: #{inspect(e)}") + end end) - all_tool_calls_finished = - Enum.all?(tool_calls, fn call -> - Map.has_key?(call, :result) or Map.has_key?(call, :error) + tool_request_message = %{ + role: :assistant, + content: state.streaming_response.content, + reasoning_content: state.streaming_response.reasoning_content, + tool_calls: tool_calls + } + + result_messages = + Enum.map(tool_calls, fn call -> + if Map.has_key?(call, :result) do + %{role: :tool, content: "#{inspect(call.result)}", tool_call_id: call.id} + else + %{role: :tool, content: "Error in #{call.name}: #{call.error}", tool_call_id: call.id} + end end) - state = - case all_tool_calls_finished do - true -> - Logger.info("All tool calls finished, broadcasting updated tool calls with results") + new_messages = [tool_request_message] ++ result_messages - new_message = %{ - role: :assistant, - content: state.streaming_response.content, - reasoning_content: state.streaming_response.reasoning_content, - tool_calls: tool_calls - } + Logger.info("All tool calls finished, broadcasting updated tool calls with results") + broadcast({:tool_calls_finished, new_messages}) - new_state = %{ - state - | messages: - state.messages ++ - [ - new_message - ], - streaming_response: nil - } - - broadcast({:tool_calls_finished, new_message}) - - false -> - %{ - state - | streaming_response: %{ - state.streaming_response - | tool_calls: tool_calls - } - } - end - - {:noreply, state} + {:noreply, + %{state | messages: state.messages ++ new_messages, streaming_response: nil}} end def handle_call(:get_conversation, _from, state) do diff --git a/lib/elixir_ai/tool_testing.ex b/lib/elixir_ai/tool_testing.ex index b4a411c..c60c54c 100644 --- a/lib/elixir_ai/tool_testing.ex +++ b/lib/elixir_ai/tool_testing.ex @@ -9,6 +9,10 @@ defmodule ElixirAi.ToolTesting do GenServer.call(__MODULE__, :get_thing) end + def get_thing(_) do + GenServer.call(__MODULE__, :get_thing) + end + def store_thing_definition(name) do %{ "type" => "function", diff --git a/lib/elixir_ai_web/components/chat_message.ex b/lib/elixir_ai_web/components/chat_message.ex index 61e1c5b..5f2cff4 100644 --- a/lib/elixir_ai_web/components/chat_message.ex +++ b/lib/elixir_ai_web/components/chat_message.ex @@ -3,6 +3,26 @@ defmodule ElixirAiWeb.ChatMessage do alias ElixirAiWeb.Markdown alias Phoenix.LiveView.JS + attr :content, :string, required: true + attr :tool_call_id, :string, required: true + + def tool_result_message(assigns) do + ~H""" +
+
+ + + + tool result + {@tool_call_id} +
+
+
{@content}
+
+
+ """ + end + attr :content, :string, required: true def user_message(assigns) do @@ -104,29 +124,7 @@ defmodule ElixirAiWeb.ChatMessage do <% end %> <%= for tool_call <- @tool_calls do %> -
-
- - - - {tool_call.name} -
- <%= if tool_call[:arguments] && tool_call[:arguments] != "" do %> -
- args{tool_call.arguments} -
- <% end %> - <%= if Map.has_key?(tool_call, :result) do %> -
- result{inspect(tool_call.result)} -
- <% end %> - <%= if Map.has_key?(tool_call, :error) do %> -
- error{tool_call.error} -
- <% end %> -
+ <.tool_call_item tool_call={tool_call} /> <% end %> <%= if @content && @content != "" do %>
@@ -136,4 +134,141 @@ defmodule ElixirAiWeb.ChatMessage do
""" end + + # Dispatches to the appropriate tool call component based on result state + attr :tool_call, :map, required: true + + defp tool_call_item(%{tool_call: tool_call} = assigns) do + cond do + Map.has_key?(tool_call, :error) -> + assigns = + assigns + |> assign(:name, tool_call.name) + |> assign(:arguments, tool_call[:arguments] || "") + |> assign(:error, tool_call.error) + + ~H"<.error_tool_call name={@name} arguments={@arguments} error={@error} />" + + Map.has_key?(tool_call, :result) -> + assigns = + assigns + |> assign(:name, tool_call.name) + |> assign(:arguments, tool_call[:arguments] || "") + |> assign(:result, tool_call.result) + + ~H"<.success_tool_call name={@name} arguments={@arguments} result={@result} />" + + true -> + assigns = + assigns + |> assign(:name, tool_call.name) + |> assign(:arguments, tool_call[:arguments] || "") + + ~H"<.pending_tool_call name={@name} arguments={@arguments} />" + end + end + + attr :name, :string, required: true + attr :arguments, :string, default: "" + + defp pending_tool_call(assigns) do + ~H""" +
+
+ <.tool_call_icon /> + {@name} + + + running + +
+ <.tool_call_args arguments={@arguments} /> +
+ """ + end + + attr :name, :string, required: true + attr :arguments, :string, default: "" + attr :result, :any, required: true + + defp success_tool_call(assigns) do + assigns = + assign(assigns, :result_str, case assigns.result do + s when is_binary(s) -> s + other -> inspect(other, pretty: true, limit: :infinity) + end) + + ~H""" +
+
+ <.tool_call_icon /> + {@name} + + + + + done + +
+ <.tool_call_args arguments={@arguments} /> +
+
result
+
{@result_str}
+
+
+ """ + end + + attr :name, :string, required: true + attr :arguments, :string, default: "" + attr :error, :string, required: true + + defp error_tool_call(assigns) do + ~H""" +
+
+ <.tool_call_icon /> + {@name} + + + + + error + +
+ <.tool_call_args arguments={@arguments} /> +
+
error
+
{@error}
+
+
+ """ + end + + attr :arguments, :string, default: "" + + defp tool_call_args(%{arguments: args} = assigns) when args != "" do + assigns = + assign(assigns, :pretty_args, case Jason.decode(args) do + {:ok, decoded} -> Jason.encode!(decoded, pretty: true) + _ -> args + end) + + ~H""" +
+
arguments
+
{@pretty_args}
+
+ """ + end + + defp tool_call_args(assigns), do: ~H"" + + defp tool_call_icon(assigns) do + ~H""" + + + + """ + end end diff --git a/lib/elixir_ai_web/live/chat_live.ex b/lib/elixir_ai_web/live/chat_live.ex index 180d534..3c1c058 100644 --- a/lib/elixir_ai_web/live/chat_live.ex +++ b/lib/elixir_ai_web/live/chat_live.ex @@ -1,5 +1,6 @@ defmodule ElixirAiWeb.ChatLive do use ElixirAiWeb, :live_view + require Logger import ElixirAiWeb.Spinner import ElixirAiWeb.ChatMessage alias ElixirAi.ChatRunner @@ -28,14 +29,17 @@ defmodule ElixirAiWeb.ChatLive do

No messages yet.

<% end %> <%= for msg <- @messages do %> - <%= if msg.role == :user do %> - <.user_message content={msg.content} /> - <% else %> - <.assistant_message - content={msg.content} - reasoning_content={msg.reasoning_content} - tool_calls={Map.get(msg, :tool_calls, [])} - /> + <%= cond do %> + <% msg.role == :user -> %> + <.user_message content={msg.content} /> + <% msg.role == :tool -> %> + <.tool_result_message content={msg.content} tool_call_id={msg.tool_call_id} /> + <% true -> %> + <.assistant_message + content={msg.content} + reasoning_content={msg.reasoning_content} + tool_calls={Map.get(msg, :tool_calls, [])} + /> <% end %> <% end %> <%= if @streaming_response do %> @@ -102,18 +106,25 @@ defmodule ElixirAiWeb.ChatLive do {:noreply, assign(socket, streaming_response: updated_response)} end - def handle_info({:tool_calls_finished, final_message}, socket) do + def handle_info({:tool_calls_finished, tool_messages}, socket) do + Logger.info("Received tool_calls_finished with #{inspect(tool_messages)}") {:noreply, socket - |> update(:messages, &(&1 ++ [final_message])) + |> update(:messages, &(&1 ++ tool_messages)) |> assign(streaming_response: nil)} end + # tool_calls_finished already cleared streaming_response and committed messages — ignore + def handle_info(:end_ai_response, %{assigns: %{streaming_response: nil}} = socket) do + {:noreply, socket} + end + def handle_info(:end_ai_response, socket) do final_response = %{ role: :assistant, content: socket.assigns.streaming_response.content, - reasoning_content: socket.assigns.streaming_response.reasoning_content + reasoning_content: socket.assigns.streaming_response.reasoning_content, + tool_calls: socket.assigns.streaming_response.tool_calls } {:noreply,