Normandy.Components.AgentMemory
(normandy v2.2.0)
View Source
Conversation memory as a graph of parent-linked entries.
Each message is an AgentMemory.Entry carrying its own id and a parent_id
link to the prior entry on its branch. A linear conversation is a degenerate
single-parent chain: head points at the most-recent entry, and walking
parent_id back to a root reproduces the conversation. Branching is opt-in via
fork/2.
history/1 reconstructs the active branch in chronological order — output
identical to the previous linear implementation.
Each entry's model-facing encoding is computed once, when its content is set (an
append, a thinking drop, from_entries/1, load/1), and kept on the entry
(Normandy.Components.AgentMemory.Entry's wire and tokens). Building a request
or estimating the conversation reads it, so a content that cannot be encoded raises
at the append rather than at the next LLM call.
Summary
Functions
Appends a message to the active branch.
Total number of stored entries across all branches (map_size(entries)).
Removes every entry of turn turn_id, re-linking the survivors around it. Every
surviving entry that had a deleted entry among its ancestors loses its thinking (it was
produced with the deleted turn in its history). Raises Normandy.TurnNotFoundError
when the memory has no entry of that turn.
Drops thinking that no longer applies under the current system prompt and tools,
given as prefix_key (see Normandy.Agents.ToolCallResponse.prefix_key/3).
Removes thinking and redacted_thinking blocks from a message's content:
a ToolCallResponse's :thinking, or the blocks of a content list. Other
content is returned unchanged.
Returns memory as a JSON string (format version 1) for load/1.
Returns every entry of every branch, as a map of entry id to Entry.
The active branch head -> root, returned chronological (oldest -> newest).
Looks up the entry with id id: {:ok, entry} or :error.
Like fetch_entry/2, but returns the entry or raises KeyError (its message names the
id, never the memory).
Move head to from_entry_id; subsequent appends branch from there.
Rebuild a memory from a chronological list of Entry.t() (e.g. the output of
SessionStore.history/2). The head becomes the last entry's id and the
current_turn_id the last entry's turn_id, so the active branch reconstructs.
Each entry's encoding is computed here, once.
Returns the current turn id, or nil before initialize_turn/1 or the first append.
Returns the active branch as %{role: role, content: content} maps, oldest first.
Returns memory with a new random UUID as current_turn_id.
The newest message (the head entry), or nil when empty.
Rebuilds a memory from a string dump/1 returned.
The active branch as %Message{} structs, chronological.
Returns an empty memory with no current turn.
The messages of a request, from history/1's output.
Types
@type t() :: %Normandy.Components.AgentMemory{ current_turn_id: String.t() | nil, entries: %{required(String.t()) => Normandy.Components.AgentMemory.Entry.t()}, head: String.t() | nil, max_messages: pos_integer() | nil }
Functions
Appends a message to the active branch.
turn_id: stores the message under that turn instead of current_turn_id;
compaction uses it to keep each kept message's turn. id: gives the entry that id
instead of a new one (compaction keeps a kept message's id, which compactors report
dropped entries by); it must not already be in memory (raises ArgumentError).
Options
:turn_id- Store the message under this turn instead of the current one.:id- The entry's id (default: a new UUID). It must not already be in the memory.
@spec count_messages(t()) :: non_neg_integer()
Total number of stored entries across all branches (map_size(entries)).
For a linear conversation this equals the active-chain length; after a fork/2
with divergent appends it counts entries on every branch, not just the active one.
Removes every entry of turn turn_id, re-linking the survivors around it. Every
surviving entry that had a deleted entry among its ancestors loses its thinking (it was
produced with the deleted turn in its history). Raises Normandy.TurnNotFoundError
when the memory has no entry of that turn.
Linear in the number of entries for a linear history. Each surviving entry whose
parent was deleted walks the deleted run above it once, so many branches off one long
deleted turn cost the number of such entries times that run's length. A corrupt memory
(a parent cycle, a parent_id naming no entry) does not hang or raise.
Drops thinking that no longer applies under the current system prompt and tools,
given as prefix_key (see Normandy.Agents.ToolCallResponse.prefix_key/3).
Walking the active chain oldest first, a ToolCallResponse whose :prefix_key
differs loses its thinking. Earlier thinking is not part of a block's prefix, so
dropping a leading run leaves later blocks valid; once a block has been kept, a
dropped one breaks the chain and every later message loses its thinking too.
Responses without a key (stored before it existed) are left as they are.
Removes thinking and redacted_thinking blocks from a message's content:
a ToolCallResponse's :thinking, or the blocks of a content list. Other
content is returned unchanged.
A thinking block is valid only after the exact history that produced it, so
memory edits that remove or replace earlier messages (a max_messages trim,
delete_turn/2, summarizing or truncating with Normandy.Context) drop the
thinking of the messages they kept. Text and tool calls stay.
Returns memory as a JSON string (format version 1) for load/1.
It holds all entries, head, current_turn_id and max_messages. The per-entry
encodings are derived and not written.
@spec entries(t()) :: %{ required(String.t()) => Normandy.Components.AgentMemory.Entry.t() }
Returns every entry of every branch, as a map of entry id to Entry.
Unlike history/1, it includes entries off the active branch.
@spec entry_chain(t()) :: [Normandy.Components.AgentMemory.Entry.t()]
The active branch head -> root, returned chronological (oldest -> newest).
@spec fetch_entry(t(), String.t()) :: {:ok, Normandy.Components.AgentMemory.Entry.t()} | :error
Looks up the entry with id id: {:ok, entry} or :error.
@spec fetch_entry!(t(), String.t()) :: Normandy.Components.AgentMemory.Entry.t()
Like fetch_entry/2, but returns the entry or raises KeyError (its message names the
id, never the memory).
Move head to from_entry_id; subsequent appends branch from there.
@spec from_entries([Normandy.Components.AgentMemory.Entry.t()]) :: t()
Rebuild a memory from a chronological list of Entry.t() (e.g. the output of
SessionStore.history/2). The head becomes the last entry's id and the
current_turn_id the last entry's turn_id, so the active branch reconstructs.
Each entry's encoding is computed here, once.
An entry whose role is "compaction" is the snapshot Turn.Server stores after a
compaction: its content, %{version: 1, entries: [%{id: _, turn_id: _, role: _, content: _}]}, is the whole conversation as that compaction left it. The memory is
then the last snapshot's entries followed by the entries stored after it, linked
in that order; the entries before that snapshot and the snapshot entry itself are not
in the memory. A "compaction" entry of any other shape, or one holding an element
without those four keys, raises ArgumentError, as does an entry whose content cannot be
encoded for the model (the message names the entry).
Returns the current turn id, or nil before initialize_turn/1 or the first append.
Returns the active branch as %{role: role, content: content} maps, oldest first.
content is the entry's model-facing encoding: a string, or a list of blocks.
Returns memory with a new random UUID as current_turn_id.
add_message/4 stores a message under the current turn.
@spec latest_message(t()) :: Normandy.Components.Message.t() | nil
The newest message (the head entry), or nil when empty.
Rebuilds a memory from a string dump/1 returned.
Raises ArgumentError for a dump whose version is not 1 and for an entry whose
content cannot be encoded for the model (the message names the entry). A string that is
not valid JSON raises the JSON adapter's error.
@spec messages(t()) :: [Normandy.Components.Message.t()]
The active branch as %Message{} structs, chronological.
@spec new_memory(pos_integer() | nil) :: t()
Returns an empty memory with no current turn.
With max_messages, an append that takes the active branch past that many messages drops
the oldest, but keeps the window starting at a user message, so it can hold more than
max_messages until the turn ends. 0 keeps no message; nil keeps every message.
@spec request_messages([%{role: String.t(), content: term()}]) :: [ Normandy.Components.Message.t() ]
The messages of a request, from history/1's output.
Each run of consecutive "tool" messages whose content is a block list becomes one
"tool" message holding their blocks in order, so a batch of tool results reaches the
model as one user message, as the Messages API expects of parallel tool use. Memory,
the session store and the turn keep one entry per result; only the request merges
them. A "tool" message whose content is not a list stays a message of its own.
A non-empty text "user" message right after such a run (the iteration-cap notice, an
after_compact injection) becomes its last block, as the API expects text sent with
tool results.
An "assistant" message other than the last loses each "server_tool_use" or
"mcp_tool_use" block that nothing answers (no block of the same message or of any
later one has its id as "tool_use_id"), and goes if that leaves it empty or holding
only "thinking" and "redacted_thinking" blocks (a message the trim did not change is
never dropped).
The API accepts an unanswered server-tool use only in the last message, where it
continues a paused turn; once a later message follows, it rejects the request. A
continuation that pauses again or calls a client tool is stored as the next assistant
message, starting with the result of the earlier pause; the API joins consecutive
assistant messages into one turn, so that use and result pair up and both stay. What
is stored does not change.