Packages
fnord
0.9.39
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/ai/message.ex
defmodule AI.Message do
@moduledoc """
Canonical in-memory representation of conversation messages, modeled after
the Responses API item shape.
Each message is a struct from one of the impl modules below. Pattern-matching
on `%mod{}` dispatches the behaviour callbacks (`text/1`, `for_transcript/1`,
`to_map/1`). Construction goes through the short helpers at this module's
top level (e.g. `AI.Message.user/1`); persistence and wire serialization go
through `to_map/1`; hydration from disk or API response goes through
`from_map/1`.
## Impls
* `AI.Message.User` - user input message
* `AI.Message.Assistant` - assistant reply message
* `AI.Message.System` - system/developer instruction
* `AI.Message.FunctionCall` - assistant-emitted tool call request
* `AI.Message.FunctionCallOutput` - tool execution result
* `AI.Message.Reasoning` - opaque reasoning item (round-tripped verbatim)
## Invariants
* `FunctionCall.arguments` is ALWAYS a JSON string. Never decode it to a
map for persistence - LLM-emitted garbage keys exhaust the BEAM atom
table downstream. This is the cliff the previous Responses API attempt
drove over; see the "Conversation file corruption" engram memory.
* Content for User/Assistant/System is stored as a list of typed parts
matching the wire format (`%{type: "input_text" | "output_text", text:
...}`). The `text/1` callback joins the parts back to a single binary.
"""
@type t ::
AI.Message.User.t()
| AI.Message.Assistant.t()
| AI.Message.System.t()
| AI.Message.FunctionCall.t()
| AI.Message.FunctionCallOutput.t()
| AI.Message.Reasoning.t()
# --------------------------------------------------------------------------
# Behaviour callbacks
# --------------------------------------------------------------------------
@doc """
Returns the plain-text representation of a message, or `nil` if the message
has no textual content (e.g. a tool-call request or opaque reasoning blob).
Joins multi-part content lists with newlines.
"""
@callback text(t()) :: binary() | nil
@doc """
Returns a transcript-formatted block (with role headers, tool-call summary,
etc.) suitable for inclusion in `AI.Util.research_transcript/1`-style
outputs. `nil` if the message is omitted from transcripts.
"""
@callback for_transcript(t()) :: binary() | nil
@doc """
Serializes a message struct to its on-the-wire (Responses API native) map
shape. The result is what gets persisted to disk and sent to the API.
"""
@callback to_map(t()) :: map()
# --------------------------------------------------------------------------
# Dispatching helpers
# --------------------------------------------------------------------------
@spec text(t()) :: binary() | nil
def text(%mod{} = msg), do: mod.text(msg)
@spec for_transcript(t()) :: binary() | nil
def for_transcript(%mod{} = msg), do: mod.for_transcript(msg)
@spec to_map(t()) :: map()
def to_map(%mod{} = msg), do: mod.to_map(msg)
# --------------------------------------------------------------------------
# Constructors (terse aliases for the impl modules' new/1)
# --------------------------------------------------------------------------
@doc "Build an `AI.Message.User` from a binary or pre-built content list."
@spec user(binary() | [map()]) :: AI.Message.User.t()
def user(content), do: AI.Message.User.new(content)
@doc "Build an `AI.Message.Assistant` from a binary or pre-built content list."
@spec assistant(binary() | [map()]) :: AI.Message.Assistant.t()
def assistant(content), do: AI.Message.Assistant.new(content)
@doc """
Build an `AI.Message.System`. Role defaults to `"developer"` (OpenAI's
Responses-era convention); pass `role: "system"` for providers that prefer
the legacy label.
"""
@spec system(binary() | [map()], keyword()) :: AI.Message.System.t()
def system(content, opts \\ []), do: AI.Message.System.new(content, opts)
@doc """
Build an `AI.Message.FunctionCall`. `arguments` MUST be a JSON-encoded string
(the same form the API returns). Never pass a decoded map - see the module
doc.
"""
@spec function_call(binary(), binary(), binary()) :: AI.Message.FunctionCall.t()
def function_call(call_id, name, arguments) when is_binary(arguments) do
AI.Message.FunctionCall.new(call_id, name, arguments)
end
@doc "Build an `AI.Message.FunctionCallOutput` from a tool's textual result."
@spec function_call_output(binary(), binary()) :: AI.Message.FunctionCallOutput.t()
def function_call_output(call_id, output) do
AI.Message.FunctionCallOutput.new(call_id, output)
end
@doc """
Build an `AI.Message.Reasoning` from the raw reasoning item map returned by
the API. The struct round-trips the raw shape so reasoning continuity is
preserved when `store: false`.
"""
@spec reasoning(map()) :: AI.Message.Reasoning.t()
def reasoning(raw), do: AI.Message.Reasoning.new(raw)
# --------------------------------------------------------------------------
# Hydration from disk or API response
# --------------------------------------------------------------------------
@doc """
Hydrate a struct from a Responses-API-shaped map. Accepts either atom-keyed
or string-keyed maps - on-disk persistence uses string keys; in-memory
construction uses atoms.
Dispatches on `type` (and `role` for `"message"` items). Unknown shapes
return `{:error, {:unknown_message_shape, raw}}`.
"""
@spec from_map(map()) :: {:ok, t()} | {:error, {:unknown_message_shape, map()}}
def from_map(raw) when is_map(raw) do
case {get(raw, :type), get(raw, :role)} do
{"message", "user"} -> {:ok, AI.Message.User.from_map(raw)}
{"message", "assistant"} -> {:ok, AI.Message.Assistant.from_map(raw)}
{"message", "system"} -> {:ok, AI.Message.System.from_map(raw)}
{"message", "developer"} -> {:ok, AI.Message.System.from_map(raw)}
{"function_call", _} -> {:ok, AI.Message.FunctionCall.from_map(raw)}
{"function_call_output", _} -> {:ok, AI.Message.FunctionCallOutput.from_map(raw)}
{"reasoning", _} -> {:ok, AI.Message.Reasoning.from_map(raw)}
_ -> {:error, {:unknown_message_shape, raw}}
end
end
@doc """
Same as `from_map/1` but raises on unknown shapes. Use when persistence
guarantees the shape (e.g. immediately after a successful Format migration).
"""
@spec from_map!(map()) :: t()
def from_map!(raw) do
case from_map(raw) do
{:ok, msg} -> msg
{:error, reason} -> raise ArgumentError, "from_map!: #{inspect(reason)}"
end
end
# Read a value from a map that may use either atom or string keys. We do
# NOT call `String.to_atom/1` on raw map keys - that's the atom-table
# cliff. Instead we look up both representations explicitly.
@doc false
def get(map, key) when is_atom(key) do
Map.get(map, key) || Map.get(map, Atom.to_string(key))
end
end