Current section
Files
Jump to
Current section
Files
lib/opal/message.ex
defmodule Opal.Message do
@moduledoc """
A struct representing messages in an agent conversation.
Messages flow through the agent loop and carry content between the user,
assistant, and tool executions. Each message has a role that determines
its semantics:
* `:system` — a system-level instruction (e.g. system prompt, summarizer directives)
* `:user` — a user-originated message with text content
* `:assistant` — an assistant response with text and optional tool calls
* `:tool_call` — a tool invocation specifying name, call ID, and arguments
* `:tool_result` — the result of a tool execution, keyed by call ID
Every message is assigned a unique ID at construction time.
"""
@type role :: :system | :user | :assistant | :tool_call | :tool_result
@type tool_call :: %{
call_id: String.t(),
name: String.t(),
arguments: map()
}
@type t :: %__MODULE__{
id: String.t(),
parent_id: String.t() | nil,
role: role(),
content: String.t() | nil,
thinking: String.t() | nil,
tool_calls: [tool_call()] | nil,
call_id: String.t() | nil,
name: String.t() | nil,
is_error: boolean(),
# Optional structured data attached by compaction or other subsystems.
# For compaction summaries this holds :type, :read_files, :modified_files.
metadata: map() | nil
}
@enforce_keys [:id, :role]
defstruct [
:id,
:parent_id,
:role,
:content,
:thinking,
:tool_calls,
:call_id,
:name,
:metadata,
is_error: false
]
@doc """
Creates a system message with the given instruction content.
## Examples
iex> msg = Opal.Message.system("You are a helpful assistant.")
iex> msg.role
:system
"""
@spec system(String.t()) :: t()
def system(content) when is_binary(content) do
%__MODULE__{id: Opal.Id.generate(), role: :system, content: content}
end
@doc """
Creates a user message with the given text content.
## Examples
iex> msg = Opal.Message.user("Hello")
iex> msg.role
:user
"""
@spec user(String.t()) :: t()
def user(content) when is_binary(content) do
%__MODULE__{id: Opal.Id.generate(), role: :user, content: content}
end
@doc """
Creates an assistant message with text content and optional tool calls.
## Examples
iex> msg = Opal.Message.assistant("Sure, let me check.", [])
iex> msg.role
:assistant
"""
@spec assistant(String.t() | nil, [tool_call()], keyword()) :: t()
def assistant(content, tool_calls \\ [], opts \\ []) do
%__MODULE__{
id: Opal.Id.generate(),
role: :assistant,
content: content,
thinking: Keyword.get(opts, :thinking),
tool_calls: tool_calls
}
end
@doc """
Creates a tool call message representing a tool invocation.
## Parameters
* `call_id` — unique identifier linking this call to its result
* `name` — the tool name to invoke
* `arguments` — a map of arguments to pass to the tool
"""
@spec tool_call(String.t(), String.t(), map()) :: t()
def tool_call(call_id, name, arguments)
when is_binary(call_id) and is_binary(name) and is_map(arguments) do
%__MODULE__{
id: Opal.Id.generate(),
role: :tool_call,
call_id: call_id,
name: name,
content: Jason.encode!(arguments)
}
end
@doc """
Creates a tool result message with the output of a tool execution.
## Parameters
* `call_id` — the call ID this result corresponds to
* `output` — the string output produced by the tool
* `is_error` — whether the tool execution resulted in an error (default: `false`)
"""
@spec tool_result(String.t(), String.t(), boolean()) :: t()
def tool_result(call_id, output, is_error \\ false)
when is_binary(call_id) and is_binary(output) and is_boolean(is_error) do
%__MODULE__{
id: Opal.Id.generate(),
role: :tool_result,
call_id: call_id,
content: output,
is_error: is_error
}
end
# -- Serialization --
@doc """
Serializes a message to a plain map suitable for JSON encoding.
Optional fields (`:thinking`, `:tool_calls`, `:call_id`, `:name`,
`:metadata`) are included only when present.
"""
@spec to_map(t()) :: map()
def to_map(%__MODULE__{} = msg) do
%{id: msg.id, role: to_string(msg.role), content: msg.content, is_error: msg.is_error}
|> put_if(msg.thinking, :thinking)
|> put_if(msg.call_id, :call_id)
|> put_if(msg.name, :name)
|> put_if(msg.metadata, :metadata)
|> maybe_put_tool_calls(msg.tool_calls)
end
defp put_if(map, val, key), do: Opal.Util.Map.put_non_nil(map, key, val)
defp maybe_put_tool_calls(map, nil), do: map
defp maybe_put_tool_calls(map, []), do: map
defp maybe_put_tool_calls(map, tcs) do
Map.put(
map,
:tool_calls,
Enum.map(tcs, &%{call_id: &1.call_id, name: &1.name, arguments: &1.arguments})
)
end
end