more improvements on tool calling

This commit is contained in:
2026-03-06 09:08:16 -07:00
parent b89d4e5a28
commit 7c7e763809
7 changed files with 275 additions and 142 deletions

View File

@@ -19,6 +19,7 @@ defmodule ElixirAi.ChatUtils do
headers = [{"authorization", "Bearer #{api_key}"}] headers = [{"authorization", "Bearer #{api_key}"}]
Logger.info("sending AI request with body: #{inspect(body)}")
case Req.post(api_url, case Req.post(api_url,
json: body, json: body,
headers: headers, headers: headers,
@@ -39,6 +40,28 @@ defmodule ElixirAi.ChatUtils do
end) end)
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 def api_message(%{role: role, content: content}) do
%{role: Atom.to_string(role), content: content} %{role: Atom.to_string(role), content: content}
end end

View File

@@ -76,72 +76,49 @@ defmodule ElixirAi.AiUtils.StreamLineUtils do
) )
end end
# start tool call # start and middle tool call
def handle_stream_line(server, %{ def handle_stream_line(server, %{
"choices" => [ "choices" => [
%{ %{
"delta" => %{ "delta" => %{
"tool_calls" => [ "tool_calls" => tool_calls
%{
"function" => %{
"name" => tool_name,
"arguments" => tool_args_start
}
}
]
}, },
"finish_reason" => nil, "finish_reason" => nil
"index" => tool_index
} }
], ],
"id" => id "id" => id
}) do })
send( when is_list(tool_calls) do
server, Enum.each(tool_calls, fn
{:ai_tool_call_start, id, {tool_name, tool_args_start, tool_index}} %{
) "id" => tool_call_id,
end "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 send(
def handle_stream_line(server, %{ server,
"choices" => [ {:ai_tool_call_start, id, {tool_name, tool_args_start, tool_index, tool_call_id}}
%{ )
"delta" => %{
"tool_calls" => [ %{"index" => tool_index, "function" => %{"arguments" => tool_args_diff}} ->
%{ Logger.info("Received tool call middle for index #{tool_index}")
"function" => %{ send(server, {:ai_tool_call_middle, id, {tool_args_diff, tool_index}})
"arguments" => tool_args_diff
} other ->
} Logger.warning("Unmatched tool call item: #{inspect(other)}")
] end)
},
"finish_reason" => nil,
"index" => tool_index
}
],
"id" => id
}) do
send(
server,
{:ai_tool_call_middle, id, {tool_args_diff, tool_index}}
)
end end
# end tool call # end tool call
def handle_stream_line(server, %{ def handle_stream_line(server, %{
"choices" => [ "choices" => [%{"finish_reason" => "tool_calls"}],
%{
"delta" => %{},
"finish_reason" => "tool_calls",
"index" => tool_index
}
],
"id" => id "id" => id
}) do }) do
send( Logger.info("Received tool call end")
server, send(server, {:ai_tool_call_end, id})
{:ai_tool_call_end, id, tool_index}
)
end end
def handle_stream_line(_server, %{"error" => error_info}) do def handle_stream_line(_server, %{"error" => error_info}) do

View File

@@ -9,6 +9,7 @@ defmodule ElixirAi.Application do
{DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ElixirAi.PubSub}, {Phoenix.PubSub, name: ElixirAi.PubSub},
ElixirAi.ChatRunner, ElixirAi.ChatRunner,
ElixirAi.ToolTesting,
ElixirAiWeb.Endpoint ElixirAiWeb.Endpoint
] ]

View File

@@ -37,7 +37,7 @@ defmodule ElixirAi.ChatRunner do
}, },
"read_thing" => %{ "read_thing" => %{
definition: ElixirAi.ToolTesting.read_thing_definition("read_thing"), definition: ElixirAi.ToolTesting.read_thing_definition("read_thing"),
function: &ElixirAi.ToolTesting.get_thing/0 function: &ElixirAi.ToolTesting.get_thing/1
} }
} }
end end
@@ -102,6 +102,7 @@ defmodule ElixirAi.ChatRunner do
end end
def handle_info({:ai_stream_finish, _id}, state) do 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) broadcast(:end_ai_response)
final_message = %{ final_message = %{
@@ -120,7 +121,10 @@ defmodule ElixirAi.ChatRunner do
}} }}
end 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}") Logger.info("AI started tool call #{tool_name}")
new_streaming_response = %{ new_streaming_response = %{
@@ -131,7 +135,8 @@ defmodule ElixirAi.ChatRunner do
%{ %{
name: tool_name, name: tool_name,
arguments: tool_args_start, 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}} {:noreply, %{state | streaming_response: new_streaming_response}}
end 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 = tool_calls =
Enum.map(state.streaming_response.tool_calls, fn Enum.map(state.streaming_response.tool_calls, fn tool_call ->
%{ case Jason.decode(tool_call.arguments) do
arguments: existing_args, {:ok, decoded_args} ->
index: ^tool_index tool_function = tools()[tool_call.name].function
} = tool_call -> res = tool_function.(decoded_args)
case Jason.decode(existing_args) do Map.put(tool_call, :result, res)
{:ok, decoded_args} ->
tool_function = tools()[tool_call.name].function
res = tool_function.(decoded_args)
Map.put(tool_call, :result, res) {:error, e} ->
Map.put(tool_call, :error, "Failed to decode tool arguments: #{inspect(e)}")
{:error, e} -> end
Map.put(tool_call, :error, "Failed to decode tool arguments: #{inspect(e)}")
end
other ->
other
end) end)
all_tool_calls_finished = tool_request_message = %{
Enum.all?(tool_calls, fn call -> role: :assistant,
Map.has_key?(call, :result) or Map.has_key?(call, :error) 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) end)
state = new_messages = [tool_request_message] ++ result_messages
case all_tool_calls_finished do
true ->
Logger.info("All tool calls finished, broadcasting updated tool calls with results")
new_message = %{ Logger.info("All tool calls finished, broadcasting updated tool calls with results")
role: :assistant, broadcast({:tool_calls_finished, new_messages})
content: state.streaming_response.content,
reasoning_content: state.streaming_response.reasoning_content,
tool_calls: tool_calls
}
new_state = %{ {:noreply,
state %{state | messages: state.messages ++ new_messages, streaming_response: nil}}
| 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}
end end
def handle_call(:get_conversation, _from, state) do def handle_call(:get_conversation, _from, state) do

View File

@@ -9,6 +9,10 @@ defmodule ElixirAi.ToolTesting do
GenServer.call(__MODULE__, :get_thing) GenServer.call(__MODULE__, :get_thing)
end end
def get_thing(_) do
GenServer.call(__MODULE__, :get_thing)
end
def store_thing_definition(name) do def store_thing_definition(name) do
%{ %{
"type" => "function", "type" => "function",

View File

@@ -3,6 +3,26 @@ defmodule ElixirAiWeb.ChatMessage do
alias ElixirAiWeb.Markdown alias ElixirAiWeb.Markdown
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
attr :content, :string, required: true
attr :tool_call_id, :string, required: true
def tool_result_message(assigns) do
~H"""
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900/40 bg-cyan-950/20 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-cyan-900/40 bg-cyan-900/10 text-cyan-600">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 shrink-0">
<path fill-rule="evenodd" d="M10 2a.75.75 0 0 1 .75.75v.258a33.186 33.186 0 0 1 6.668 2.372.75.75 0 1 1-.636 1.354 31.66 31.66 0 0 0-1.598-.632l1.44 7.402a.75.75 0 0 1-.26.726A18.698 18.698 0 0 1 10 15.75a18.698 18.698 0 0 1-6.364-1.518.75.75 0 0 1-.26-.726l1.44-7.402a31.66 31.66 0 0 0-1.598.632.75.75 0 1 1-.636-1.354 33.186 33.186 0 0 1 6.668-2.372V2.75A.75.75 0 0 1 10 2Z" clip-rule="evenodd" />
</svg>
<span class="text-cyan-600/70 flex-1 truncate">tool result</span>
<span class="text-cyan-800 text-[10px] truncate max-w-[12rem]">{@tool_call_id}</span>
</div>
<div class="px-3 py-2">
<pre class="text-cyan-500/70 whitespace-pre-wrap break-all">{@content}</pre>
</div>
</div>
"""
end
attr :content, :string, required: true attr :content, :string, required: true
def user_message(assigns) do def user_message(assigns) do
@@ -104,29 +124,7 @@ defmodule ElixirAiWeb.ChatMessage do
</div> </div>
<% end %> <% end %>
<%= for tool_call <- @tool_calls do %> <%= for tool_call <- @tool_calls do %>
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900 bg-cyan-950/40 text-xs font-mono overflow-hidden"> <.tool_call_item tool_call={tool_call} />
<div class="flex items-center gap-2 px-3 py-1 border-b border-cyan-900 bg-cyan-900/30 text-cyan-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
<path fill-rule="evenodd" d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
<span class="text-cyan-300 font-semibold">{tool_call.name}</span>
</div>
<%= if tool_call[:arguments] && tool_call[:arguments] != "" do %>
<div class="px-3 py-2 text-cyan-500 border-b border-cyan-900/50">
<span class="text-cyan-700 mr-1">args</span>{tool_call.arguments}
</div>
<% end %>
<%= if Map.has_key?(tool_call, :result) do %>
<div class="px-3 py-2 text-cyan-200">
<span class="text-cyan-700 mr-1">result</span>{inspect(tool_call.result)}
</div>
<% end %>
<%= if Map.has_key?(tool_call, :error) do %>
<div class="px-3 py-2 text-red-400">
<span class="text-red-600 mr-1">error</span>{tool_call.error}
</div>
<% end %>
</div>
<% end %> <% end %>
<%= if @content && @content != "" do %> <%= if @content && @content != "" do %>
<div class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50"> <div class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50">
@@ -136,4 +134,141 @@ defmodule ElixirAiWeb.ChatMessage do
</div> </div>
""" """
end 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"""
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-cyan-900 bg-cyan-900/30 text-cyan-400">
<.tool_call_icon />
<span class="text-cyan-300 font-semibold flex-1">{@name}</span>
<span class="flex items-center gap-1 text-cyan-600">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-600 animate-pulse inline-block"></span>
<span class="text-[10px]">running</span>
</span>
</div>
<.tool_call_args arguments={@arguments} />
</div>
"""
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"""
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-cyan-900 bg-cyan-900/30 text-cyan-400">
<.tool_call_icon />
<span class="text-cyan-300 font-semibold flex-1">{@name}</span>
<span class="flex items-center gap-1 text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd" />
</svg>
<span class="text-[10px]">done</span>
</span>
</div>
<.tool_call_args arguments={@arguments} />
<div class="px-3 py-2">
<div class="text-cyan-700 mb-1 uppercase tracking-wider text-[10px]">result</div>
<pre class="text-emerald-300/80 whitespace-pre-wrap break-all">{@result_str}</pre>
</div>
</div>
"""
end
attr :name, :string, required: true
attr :arguments, :string, default: ""
attr :error, :string, required: true
defp error_tool_call(assigns) do
~H"""
<div class="mb-1 max-w-prose rounded-lg border border-red-900/50 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-red-900/50 bg-red-900/20 text-cyan-400">
<.tool_call_icon />
<span class="text-cyan-300 font-semibold flex-1">{@name}</span>
<span class="flex items-center gap-1 text-red-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm0-10a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5Zm0 6.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
<span class="text-[10px]">error</span>
</span>
</div>
<.tool_call_args arguments={@arguments} />
<div class="px-3 py-2 bg-red-950/20">
<div class="text-red-700 mb-1 uppercase tracking-wider text-[10px]">error</div>
<pre class="text-red-400 whitespace-pre-wrap break-all">{@error}</pre>
</div>
</div>
"""
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"""
<div class="px-3 py-2 border-b border-cyan-900/50">
<div class="text-cyan-700 mb-1 uppercase tracking-wider text-[10px]">arguments</div>
<pre class="text-cyan-400 whitespace-pre-wrap break-all">{@pretty_args}</pre>
</div>
"""
end
defp tool_call_args(assigns), do: ~H""
defp tool_call_icon(assigns) do
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 shrink-0">
<path fill-rule="evenodd" d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
"""
end
end end

View File

@@ -1,5 +1,6 @@
defmodule ElixirAiWeb.ChatLive do defmodule ElixirAiWeb.ChatLive do
use ElixirAiWeb, :live_view use ElixirAiWeb, :live_view
require Logger
import ElixirAiWeb.Spinner import ElixirAiWeb.Spinner
import ElixirAiWeb.ChatMessage import ElixirAiWeb.ChatMessage
alias ElixirAi.ChatRunner alias ElixirAi.ChatRunner
@@ -28,14 +29,17 @@ defmodule ElixirAiWeb.ChatLive do
<p class="text-sm text-center mt-4">No messages yet.</p> <p class="text-sm text-center mt-4">No messages yet.</p>
<% end %> <% end %>
<%= for msg <- @messages do %> <%= for msg <- @messages do %>
<%= if msg.role == :user do %> <%= cond do %>
<.user_message content={msg.content} /> <% msg.role == :user -> %>
<% else %> <.user_message content={msg.content} />
<.assistant_message <% msg.role == :tool -> %>
content={msg.content} <.tool_result_message content={msg.content} tool_call_id={msg.tool_call_id} />
reasoning_content={msg.reasoning_content} <% true -> %>
tool_calls={Map.get(msg, :tool_calls, [])} <.assistant_message
/> content={msg.content}
reasoning_content={msg.reasoning_content}
tool_calls={Map.get(msg, :tool_calls, [])}
/>
<% end %> <% end %>
<% end %> <% end %>
<%= if @streaming_response do %> <%= if @streaming_response do %>
@@ -102,18 +106,25 @@ defmodule ElixirAiWeb.ChatLive do
{:noreply, assign(socket, streaming_response: updated_response)} {:noreply, assign(socket, streaming_response: updated_response)}
end 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, {:noreply,
socket socket
|> update(:messages, &(&1 ++ [final_message])) |> update(:messages, &(&1 ++ tool_messages))
|> assign(streaming_response: nil)} |> assign(streaming_response: nil)}
end 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 def handle_info(:end_ai_response, socket) do
final_response = %{ final_response = %{
role: :assistant, role: :assistant,
content: socket.assigns.streaming_response.content, 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, {:noreply,