defmodule Planck.Agent.Message do
@moduledoc """
An agent-side message with metadata.
Wraps `Planck.AI.Message` content parts with an id, timestamp, and metadata map.
Messages with a `{:custom, atom()}` role are UI-only and filtered out before the
context is sent to the LLM.
"""
@type role :: :user | :assistant | :tool_result | {:custom, atom()}
@type t :: %__MODULE__{
id: non_neg_integer() | String.t(),
role: role(),
content: [Planck.AI.Message.content_part()],
timestamp: DateTime.t(),
metadata: map()
}
@enforce_keys [:id, :role, :content, :timestamp]
defstruct [:id, :role, :content, :timestamp, metadata: %{}]
@doc """
Estimate the token count for a list of messages using a character-based
approximation (4 characters ≈ 1 token). Fast enough for real-time display
and compaction threshold checks; not a substitute for model tokenization.
"""
@spec estimate_tokens([t()]) :: non_neg_integer()
def estimate_tokens(messages) do
Enum.reduce(messages, 0, fn msg, acc ->
Enum.reduce(msg.content, acc, fn
{:text, text}, a ->
a + div(String.length(text), 4)
{:thinking, text}, a ->
a + div(String.length(text), 4)
{:tool_result, _id, value}, a ->
a + div(String.length(value), 4)
{:tool_call, _id, name, args}, a ->
a + div(String.length(name) + String.length(inspect(args)), 4)
_other, a ->
a
end)
end)
end
@doc """
Build a new message with a generated id and current UTC timestamp.
"""
@spec new(role(), [Planck.AI.Message.content_part()], map()) :: t()
def new(role, content, metadata \\ %{}) do
%__MODULE__{
id: generate_id(),
role: role,
content: content,
timestamp: DateTime.utc_now(),
metadata: metadata
}
end
@doc """
Convert a list of agent messages to `Planck.AI.Message` structs.
`{:custom, :summary}` messages are converted to `:user` so the LLM sees
compacted context. All other `{:custom, _}` messages are dropped.
"""
@spec to_ai_messages([t()]) :: [Planck.AI.Message.t()]
def to_ai_messages(messages) do
Enum.flat_map(messages, fn
%__MODULE__{role: {:custom, :summary}, content: content} ->
[%Planck.AI.Message{role: :user, content: content}]
%__MODULE__{role: {:custom, :agent_response}, content: content, metadata: metadata} ->
[%Planck.AI.Message{role: :user, content: agent_response(content, metadata)}]
%__MODULE__{role: {:custom, _}} ->
[]
%__MODULE__{role: role, content: content} ->
[%Planck.AI.Message{role: role, content: content}]
end)
end
@spec generate_id() :: String.t()
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
@spec agent_response([Planck.AI.Message.content_part()], map()) :: [Planck.AI.Message.t()]
defp agent_response(content, metadata)
defp agent_response(content, %{sender_name: name}) when not is_nil(name) do
Enum.map(content, fn
{:text, text} -> {:text, "Response from #{name}: #{text}"}
part -> part
end)
end
defp agent_response(content, _metadata) do
content
end
end