Current section

Files

Jump to
langchain lib message tool_call.ex
Raw

lib/message/tool_call.ex

defmodule LangChain.Message.ToolCall do
@moduledoc """
Represents an LLM's request to use tool. It specifies the tool to execute and
may provide arguments for the tool to use.
## Example
ToolCall.new!(%{
call_id: "call_123",
name: "give_greeting"
arguments: %{"name" => "Howard"}
})
"""
use Ecto.Schema
import Ecto.Changeset
alias __MODULE__
alias LangChain.LangChainError
@primary_key false
embedded_schema do
field :status, Ecto.Enum, values: [:incomplete, :complete], default: :incomplete
field :type, Ecto.Enum, values: [:function], default: :function
field :call_id, :string
field :name, :string
field :arguments, :any, virtual: true
# when the tool call is incomplete, the index indicates which tool call to
# update on a ToolCall.
field :index, :integer
# Text to display in a UI for the tool call. Optional.
field :display_text, :string
# Map of model-specific metadata attributes
field :metadata, :map
end
# https://cookbook.openai.com/examples/how_to_call_functions_with_chat_models
#
# if assistant_message.tool_calls:
# results = execute_function_call(assistant_message)
# messages.append({"role": "function", "tool_call_id": assistant_message.tool_calls[0].id, "name": assistant_message.tool_calls[0].function.name, "content": results})
@type t :: %ToolCall{}
@update_fields [:status, :type, :call_id, :name, :arguments, :index, :display_text, :metadata]
@create_fields @update_fields
@doc """
Build a new ToolCall and return an `:ok`/`:error` tuple with the result.
"""
@spec new(attrs :: map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new(attrs \\ %{}) do
%ToolCall{}
|> cast(attrs, @create_fields)
|> assign_string_value(:arguments, attrs)
|> common_validations()
|> apply_action(:insert)
end
@doc """
Build a new ToolCall and return it or raise an error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, message} ->
message
{:error, changeset} ->
raise LangChainError, changeset
end
end
@doc """
Ensure the ToolCall's status is set to `:complete`. The process of completing
it parses the tool arguments, which may be invalid. Any problems parsing are
returned as a changeset error.
"""
@spec complete(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def complete(%ToolCall{status: :complete} = tool_call), do: {:ok, tool_call}
def complete(%ToolCall{} = tool_call) do
tool_call
|> change()
|> put_change(:status, :complete)
|> common_validations()
|> apply_action(:insert)
end
@doc """
Return the tool call's `arguments` as a map, suitable for serializing to a
provider API that expects a JSON object.
`arguments` are only parsed from a JSON string into a map by `complete/1` (via
`validate_and_parse_arguments/1`), which runs when a tool call reaches
`status: :complete`. A tool call that is serialized without going through that
path (e.g. a persisted/replayed `:incomplete` call, or one accumulated from a
truncated stream) can still hold a raw string. Passing that string to a
provider results in an API error like Anthropic's "Input should be an object".
This normalizes any shape into a map:
* a map is returned unchanged
* a JSON-object string is decoded
* `nil`, an empty string, and undecodable/non-object strings become `%{}`
## Examples
iex> LangChain.Message.ToolCall.arguments_as_map(%LangChain.Message.ToolCall{arguments: %{"a" => 1}})
%{"a" => 1}
iex> LangChain.Message.ToolCall.arguments_as_map(%LangChain.Message.ToolCall{arguments: ~s({"a":1})})
%{"a" => 1}
iex> LangChain.Message.ToolCall.arguments_as_map(%LangChain.Message.ToolCall{arguments: ""})
%{}
iex> LangChain.Message.ToolCall.arguments_as_map(%LangChain.Message.ToolCall{arguments: nil})
%{}
"""
@spec arguments_as_map(t()) :: map()
def arguments_as_map(%ToolCall{arguments: arguments}), do: normalize_arguments(arguments)
defp normalize_arguments(arguments) when is_map(arguments), do: arguments
defp normalize_arguments(arguments) when is_binary(arguments) do
case Jason.decode(arguments) do
{:ok, map} when is_map(map) -> map
_ -> %{}
end
end
defp normalize_arguments(_arguments), do: %{}
defp common_validations(changeset) do
case get_field(changeset, :status) do
nil ->
changeset
# when representing a "delta" tool call, we are very lax on requirements.
:incomplete ->
validate_required(changeset, [:status, :type])
# when the message should be complete, we are more strict
:complete ->
changeset
|> validate_required([:status, :type, :call_id, :name])
|> validate_and_parse_arguments()
end
end
defp validate_and_parse_arguments(changeset) do
case get_field(changeset, :arguments) do
# the "arguments" are not set
nil ->
changeset
text when is_binary(text) ->
# assume JSON and convert. If invalid, add an error
case Jason.decode(text) do
{:ok, json} when is_map(json) ->
put_change(changeset, :arguments, json)
{:ok, _json} ->
add_error(changeset, :arguments, "a json object is expected for tool arguments")
{:error, _reason} ->
add_error(changeset, :arguments, "invalid json")
end
data when is_map(data) ->
# return unmodified
changeset
end
end
@doc """
The left side, or `primary`, is the ToolCall that is being accumulated. The
`call_part` is being merged into the `primary`.
Used to process streamed deltas where a single tool call can be split over
many smaller parts.
"""
@spec merge(nil | t(), t()) :: t()
def merge(primary, call_part)
def merge(nil, %ToolCall{} = call_part), do: call_part
def merge(%ToolCall{index: t1}, %ToolCall{index: t2}) when t1 != t2 do
raise LangChainError, "Can only merge tool calls with the same index"
end
def merge(%ToolCall{} = t1, %ToolCall{} = t2) when t1 == t2, do: t1
def merge(%ToolCall{} = primary, %ToolCall{} = call_part) do
# merge the "part" into the primary.
primary
|> append_tool_name(call_part)
|> append_arguments(call_part)
|> update_index(call_part)
|> update_call_id(call_part)
|> update_type(call_part)
|> update_status(call_part)
|> preserve_display_text(call_part)
|> preserve_metadata(call_part)
end
defp append_tool_name(%ToolCall{name: primary_name} = primary, %ToolCall{name: new_name})
when primary_name == new_name do
primary
end
defp append_tool_name(%ToolCall{} = primary, %ToolCall{name: new_name})
when is_binary(new_name) do
%ToolCall{primary | name: (primary.name || "") <> new_name}
end
defp append_tool_name(%ToolCall{} = primary, %ToolCall{} = _delta_part) do
# no function name to merge
primary
end
defp update_index(%ToolCall{} = primary, %ToolCall{index: new_index})
when is_number(new_index) do
%ToolCall{primary | index: new_index}
end
defp update_index(%ToolCall{} = primary, %ToolCall{} = _delta_part) do
# no index update
primary
end
defp update_call_id(%ToolCall{} = primary, %ToolCall{call_id: id}) when is_binary(id) do
%ToolCall{primary | call_id: id}
end
defp update_call_id(%ToolCall{} = primary, %ToolCall{} = _delta_part) do
# no call_id update
primary
end
defp update_type(%ToolCall{} = primary, %ToolCall{type: type})
when is_atom(type) and not is_nil(type) do
%ToolCall{primary | type: type}
end
defp update_type(%ToolCall{} = primary, %ToolCall{} = _delta_part) do
# no type update
primary
end
# support changing it to complete, does not go back to incomplete from there
defp update_status(%ToolCall{status: :incomplete} = primary, %ToolCall{
status: :complete
}) do
%ToolCall{primary | status: :complete}
end
defp update_status(%ToolCall{} = primary, %ToolCall{} = _delta_part) do
# status flag not updated
primary
end
# Preserve display_text: use the delta's value if present, otherwise keep primary's
defp preserve_display_text(%ToolCall{} = primary, %ToolCall{display_text: text})
when is_binary(text) do
%ToolCall{primary | display_text: text}
end
defp preserve_display_text(%ToolCall{} = primary, %ToolCall{}), do: primary
# Preserve metadata: use the delta's value if present, otherwise keep primary's
defp preserve_metadata(%ToolCall{} = primary, %ToolCall{metadata: meta})
when is_map(meta) do
%ToolCall{primary | metadata: Map.merge(primary.metadata || %{}, meta)}
end
defp preserve_metadata(%ToolCall{} = primary, %ToolCall{}), do: primary
defp append_arguments(%ToolCall{status: :incomplete} = primary, %ToolCall{
status: :complete,
arguments: new_arguments
})
when is_map(new_arguments) do
%ToolCall{primary | arguments: new_arguments}
end
defp append_arguments(%ToolCall{arguments: existing} = primary, %ToolCall{
arguments: new_arguments
})
when is_binary(new_arguments) and (is_binary(existing) or is_nil(existing)) do
%ToolCall{primary | arguments: (existing || "") <> new_arguments}
end
# Once a complete delta with map arguments has been merged (via the clause
# above), the primary's arguments are a decoded map. Some providers then send
# trailing deltas that still carry binary JSON fragments for the same tool
# call. Appending a binary to a map would crash with an ArgumentError on <>.
# The complete arguments are already present, so the fragment is safely
# discarded — there is no meaningful way to merge a partial JSON string into
# an already-decoded map.
defp append_arguments(%ToolCall{arguments: existing} = primary, %ToolCall{
arguments: new_arguments
})
when is_map(existing) and is_binary(new_arguments) do
primary
end
defp append_arguments(%ToolCall{} = primary, %ToolCall{} = _delta_part) do
# no arguments to merge
primary
end
@doc """
Get the execution status from a ToolCall's metadata.
Returns the status string ("identified", "executing", "completed", "failed")
or the default value if not set.
"""
@spec execution_status(t(), String.t()) :: String.t()
def execution_status(%ToolCall{metadata: metadata}, default \\ "identified") do
case metadata do
%{"execution_status" => status} -> status
_ -> default
end
end
@doc """
Set the execution status on a ToolCall's metadata.
"""
@spec set_execution_status(t(), String.t()) :: t()
def set_execution_status(%ToolCall{} = tool_call, status) when is_binary(status) do
metadata = tool_call.metadata || %{}
%{tool_call | metadata: Map.put(metadata, "execution_status", status)}
end
# The contents and arguments get streamed as a string. A tool call may be part of a delta and it
# might be expected to have strings made up of spaces. The "cast" process of the changeset turns
# this into `nil` causing us to lose data.
#
# We want to take whatever we are given here.
defp assign_string_value(changeset, field, attrs) do
# get both possible versions of the arguments.
case Map.get(attrs, field) || Map.get(attrs, to_string(field)) do
"" ->
changeset
val when is_binary(val) ->
put_change(changeset, field, val)
_ ->
changeset
end
end
end