Packages
nous
0.10.0
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/message.ex
defmodule Nous.Message do
@moduledoc """
Represents a message in a conversation with an AI model.
Messages support multi-modal content, tool calls, and various roles
following OpenAI's standard message format while providing Elixir-native
validation and type safety.
## Message Roles
- `:system` - System instructions and context
- `:user` - User input and queries
- `:assistant` - AI model responses
- `:tool` - Tool execution results
## Examples
# Simple text messages
iex> Message.system("You are a helpful assistant")
%Message{role: :system, content: "You are a helpful assistant"}
iex> Message.user("Hello!")
%Message{role: :user, content: "Hello!"}
# Multi-modal user message
iex> Message.user([
...> ContentPart.text("What's in this image?"),
...> ContentPart.image_url("https://example.com/image.jpg")
...> ])
%Message{role: :user, content: [%ContentPart{}, %ContentPart{}]}
# Assistant message with tool calls
iex> Message.assistant("Let me search for that", tool_calls: [
...> %{id: "call_123", name: "search", arguments: %{"query" => "elixir"}}
...> ])
%Message{role: :assistant, content: "Let me search for that", tool_calls: [...]}
"""
use Ecto.Schema
import Ecto.Changeset
alias Nous.Message.ContentPart
@roles ~w(system user assistant tool)a
@primary_key false
embedded_schema do
field(:role, Ecto.Enum, values: @roles)
field(:content, :string)
field(:tool_calls, {:array, :map}, default: [])
field(:tool_call_id, :string)
# For tool messages
field(:name, :string)
field(:metadata, :map, default: %{})
timestamps(type: :utc_datetime_usec, inserted_at: :created_at, updated_at: false)
end
@type t :: %__MODULE__{
role: atom(),
content: String.t() | nil,
tool_calls: [map()],
tool_call_id: String.t() | nil,
name: String.t() | nil,
metadata: map(),
created_at: DateTime.t()
}
@doc """
Create a new message.
Returns `{:ok, message}` on success or `{:error, changeset}` on validation failure.
## Examples
iex> Message.new(%{role: :user, content: "Hello"})
{:ok, %Message{role: :user, content: "Hello"}}
iex> Message.new(%{role: :invalid})
{:error, %Ecto.Changeset{}}
"""
@spec new(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new(attrs) when is_map(attrs) do
%__MODULE__{}
|> changeset(attrs)
|> case do
%Ecto.Changeset{valid?: true} = changeset ->
{:ok, Ecto.Changeset.apply_changes(changeset)}
invalid_changeset ->
{:error, invalid_changeset}
end
end
@doc """
Create a new message, raising on validation failure.
## Examples
iex> Message.new!(%{role: :user, content: "Hello"})
%Message{role: :user, content: "Hello"}
"""
@spec new!(map()) :: t()
def new!(attrs) when is_map(attrs) do
case new(attrs) do
{:ok, message} -> message
{:error, changeset} -> raise Ecto.InvalidChangesetError, changeset: changeset
end
end
# Role-specific constructors
@doc """
Create a system message.
System messages provide instructions and context for the AI model.
## Examples
iex> Message.system("You are a helpful assistant")
%Message{role: :system, content: "You are a helpful assistant"}
"""
@spec system(String.t() | [ContentPart.t()], keyword()) :: t()
def system(content, opts \\ []) do
attrs =
%{role: :system, content: content}
|> Map.merge(Map.new(opts))
new!(attrs)
end
@doc """
Create a user message.
User messages contain input, queries, and multi-modal content.
## Examples
iex> Message.user("Hello!")
%Message{role: :user, content: "Hello!"}
iex> Message.user([ContentPart.text("Hi"), ContentPart.image_url("...")])
%Message{role: :user, content: [%ContentPart{}, %ContentPart{}]}
"""
@spec user(String.t() | [ContentPart.t()], keyword()) :: t()
def user(content, opts \\ [])
def user(content, opts) when is_binary(content) do
attrs =
%{role: :user, content: content}
|> Map.merge(Map.new(opts))
new!(attrs)
end
def user(content_parts, opts) when is_list(content_parts) do
# For multi-modal content, convert to text and store parts in metadata
text_content = ContentPart.to_text(content_parts)
attrs = %{
role: :user,
content: text_content,
metadata: Map.merge(Map.new(opts), %{content_parts: content_parts})
}
new!(attrs)
end
@doc """
Create an assistant message.
Assistant messages contain AI model responses, including tool calls.
## Examples
iex> Message.assistant("Hello there!")
%Message{role: :assistant, content: "Hello there!"}
iex> Message.assistant("Let me search", tool_calls: [%{...}])
%Message{role: :assistant, content: "Let me search", tool_calls: [%{...}]}
"""
@spec assistant(String.t() | [ContentPart.t()], keyword()) :: t()
def assistant(content, opts \\ []) do
attrs =
%{role: :assistant, content: content}
|> Map.merge(Map.new(opts))
new!(attrs)
end
@doc """
Create a tool result message.
Tool messages contain the results of tool/function executions.
## Examples
iex> Message.tool("call_123", "Search results: ...", name: "search")
%Message{role: :tool, content: "Search results: ...", tool_call_id: "call_123", name: "search"}
"""
@spec tool(String.t(), String.t() | map(), keyword()) :: t()
def tool(tool_call_id, result, opts \\ []) do
content = if is_binary(result), do: result, else: Jason.encode!(result)
attrs =
%{role: :tool, content: content, tool_call_id: tool_call_id}
|> Map.merge(Map.new(opts))
new!(attrs)
end
# Utility functions
@doc """
Extract text content from a message.
## Examples
iex> message = Message.user("Hello world")
iex> Message.extract_text(message)
"Hello world"
iex> message = Message.user([ContentPart.text("Hi"), ContentPart.image_url("...")])
iex> Message.extract_text(message)
"Hi"
"""
@spec extract_text(t()) :: String.t()
def extract_text(%__MODULE__{content: content}) when is_binary(content), do: content
def extract_text(%__MODULE__{content: content}) when is_list(content) do
ContentPart.extract_text(content)
end
@doc """
Convert message content to plain text representation.
## Examples
iex> message = Message.user([
...> ContentPart.text("Check this out: "),
...> ContentPart.image_url("https://example.com/img.jpg")
...> ])
iex> Message.to_text(message)
"Check this out: [Image: https://example.com/img.jpg]"
"""
@spec to_text(t()) :: String.t()
def to_text(%__MODULE__{content: content}) when is_binary(content), do: content
def to_text(%__MODULE__{content: content}) when is_list(content) do
ContentPart.to_text(content)
end
@doc """
Check if message has tool calls.
## Examples
iex> message = Message.assistant("Hello")
iex> Message.has_tool_calls?(message)
false
iex> message = Message.assistant("Search", tool_calls: [%{id: "call_1", ...}])
iex> Message.has_tool_calls?(message)
true
"""
@spec has_tool_calls?(t()) :: boolean()
def has_tool_calls?(%__MODULE__{tool_calls: tool_calls}) do
is_list(tool_calls) and length(tool_calls) > 0
end
@doc """
Check if message is tool-related (tool call or tool result).
## Examples
iex> Message.is_tool_related?(Message.tool("call_1", "result"))
true
iex> Message.is_tool_related?(Message.assistant("text", tool_calls: [%{}]))
true
iex> Message.is_tool_related?(Message.user("hello"))
false
"""
@spec is_tool_related?(t()) :: boolean()
def is_tool_related?(%__MODULE__{role: :tool}), do: true
def is_tool_related?(%__MODULE__{} = message), do: has_tool_calls?(message)
@doc """
Check if message is from user.
## Examples
iex> Message.from_user?(Message.user("hello"))
true
iex> Message.from_user?(Message.assistant("hi"))
false
"""
@spec from_user?(t()) :: boolean()
def from_user?(%__MODULE__{role: :user}), do: true
def from_user?(_), do: false
@doc """
Check if message is from assistant.
## Examples
iex> Message.from_assistant?(Message.assistant("hello"))
true
iex> Message.from_assistant?(Message.user("hi"))
false
"""
@spec from_assistant?(t()) :: boolean()
def from_assistant?(%__MODULE__{role: :assistant}), do: true
def from_assistant?(_), do: false
@doc """
Check if message is system instruction.
## Examples
iex> Message.is_system?(Message.system("You are helpful"))
true
iex> Message.is_system?(Message.user("hi"))
false
"""
@spec is_system?(t()) :: boolean()
def is_system?(%__MODULE__{role: :system}), do: true
def is_system?(_), do: false
@doc """
Get message content as ContentPart list.
Always returns a list, converting string content to text parts.
## Examples
iex> Message.get_content_parts(Message.user("Hello"))
[%ContentPart{type: :text, content: "Hello"}]
"""
@spec get_content_parts(t()) :: [ContentPart.t()]
def get_content_parts(%__MODULE__{content: content}) when is_binary(content) do
[ContentPart.text(content)]
end
def get_content_parts(%__MODULE__{content: content}) when is_list(content) do
content
end
@doc """
Add metadata to a message.
## Examples
iex> message = Message.user("hello")
iex> Message.put_metadata(message, :source, "web_ui")
%Message{metadata: %{source: "web_ui"}}
"""
@spec put_metadata(t(), atom() | String.t(), any()) :: t()
def put_metadata(%__MODULE__{} = message, key, value) do
%{message | metadata: Map.put(message.metadata, key, value)}
end
@doc """
Get metadata from a message.
## Examples
iex> message = Message.user("hello", metadata: %{source: "api"})
iex> Message.get_metadata(message, :source)
"api"
"""
@spec get_metadata(t(), atom() | String.t(), any()) :: any()
def get_metadata(%__MODULE__{metadata: metadata}, key, default \\ nil) do
Map.get(metadata, key, default)
end
# Conversion utilities for backward compatibility
@doc """
Convert from legacy tuple format.
## Examples
iex> Message.from_legacy({:user_prompt, "Hello"})
%Message{role: :user, content: "Hello"}
iex> Message.from_legacy({:system_prompt, "Instructions"})
%Message{role: :system, content: "Instructions"}
"""
@spec from_legacy(tuple() | map()) :: t()
def from_legacy({:system_prompt, content}) when is_binary(content) do
system(content)
end
def from_legacy({:user_prompt, content}) when is_binary(content) do
user(content)
end
def from_legacy({:user_prompt, content}) when is_list(content) do
parts =
Enum.map(content, fn
{:text, text} -> ContentPart.text(text)
{:image_url, url} -> ContentPart.image_url(url)
text when is_binary(text) -> ContentPart.text(text)
other -> ContentPart.text(inspect(other))
end)
user(parts)
end
def from_legacy({:tool_return, %{call_id: id, result: result}}) do
tool(id, result)
end
def from_legacy(%{parts: parts, model_name: model_name} = response) do
content_parts = convert_response_parts(parts)
tool_calls = extract_tool_calls_from_parts(parts)
attrs = %{
role: :assistant,
content: content_parts,
metadata: %{
model_name: model_name,
usage: Map.get(response, :usage),
timestamp: Map.get(response, :timestamp)
}
}
attrs = if length(tool_calls) > 0, do: Map.put(attrs, :tool_calls, tool_calls), else: attrs
new!(attrs)
end
# Private functions
defp changeset(message, attrs) do
message
|> cast(attrs, [:role, :content, :tool_calls, :tool_call_id, :name, :metadata])
|> validate_required([:role])
|> put_change(:created_at, DateTime.utc_now())
|> validate_content()
|> validate_role_constraints()
end
defp validate_content(%Ecto.Changeset{} = changeset) do
role = get_field(changeset, :role)
content = get_field(changeset, :content)
case {role, content} do
{_, nil} ->
# Allow nil content for assistant messages (streaming can populate later)
if role == :assistant do
changeset
else
add_error(changeset, :content, "content is required")
end
{_, ""} ->
add_error(changeset, :content, "content cannot be empty")
{_, content} when is_binary(content) ->
changeset
{_, content} when is_list(content) ->
validate_content_parts(changeset, content)
_ ->
add_error(changeset, :content, "content must be a string or list of content parts")
end
end
defp validate_content_parts(changeset, parts) do
if Enum.all?(parts, &is_struct(&1, ContentPart)) do
changeset
else
add_error(changeset, :content, "all content parts must be ContentPart structs")
end
end
defp validate_role_constraints(%Ecto.Changeset{} = changeset) do
role = get_field(changeset, :role)
tool_call_id = get_field(changeset, :tool_call_id)
tool_calls = get_field(changeset, :tool_calls)
case role do
:tool ->
if is_nil(tool_call_id) do
add_error(changeset, :tool_call_id, "tool_call_id is required for tool messages")
else
changeset
end
_ ->
if tool_call_id do
add_error(changeset, :tool_call_id, "tool_call_id is only allowed for tool messages")
else
changeset
end
end
|> validate_tool_calls(tool_calls)
end
defp validate_tool_calls(changeset, tool_calls) when is_list(tool_calls) do
if Enum.all?(tool_calls, &is_map/1) do
changeset
else
add_error(changeset, :tool_calls, "tool_calls must be a list of maps")
end
end
defp validate_tool_calls(changeset, _), do: changeset
defp convert_response_parts(parts) when is_list(parts) do
content_parts =
Enum.map(parts, fn
{:text, text} -> ContentPart.text(text)
{:thinking, content} -> ContentPart.thinking(content)
_ -> nil
end)
|> Enum.reject(&is_nil/1)
case content_parts do
[] -> ""
[%ContentPart{type: :text} = single_text] -> single_text.content
multiple -> ContentPart.to_text(multiple)
end
end
defp extract_tool_calls_from_parts(parts) when is_list(parts) do
parts
|> Enum.filter(&match?({:tool_call, _}, &1))
|> Enum.map(fn {:tool_call, call} -> call end)
end
end