Packages
langchain
0.8.12
0.9.2
0.9.1
0.9.0
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.4.0-rc.3
0.4.0-rc.2
0.4.0-rc.1
0.4.0-rc.0
0.3.3
0.3.2
0.3.1
0.3.0
0.3.0-rc.2
0.3.0-rc.1
0.3.0-rc.0
0.2.0
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Elixir implementation of a LangChain style framework that lets Elixir projects integrate with and leverage LLMs.
Current section
Files
Jump to
Current section
Files
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
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