Memory architecture
How Flux structures agent memory — working memory, long-term memory, dreaming — and how each plugs into the agent loop.
A useful agent has to remember things. Inside a single conversation it has to remember the last thing the user said, the tool it just called, and the result that came back. Across conversations it has to remember the user’s name, the project they’re working on, the decision they made last week. Long-running agents have a third problem: their transcripts grow unboundedly, and at some point what was useful context becomes dead weight that won’t fit in the context window.
Flux models these three jobs with three primitives — working memory, long-term memory, and dreaming — sitting on top of the same task / event-log substrate that the rest of the framework uses. This page is about how they fit together. The reference and recipes live in Agent memory and Dreaming and reflection; the goal here is the model, not the API.
The short version:
- Working memory is the conversation — the messages the agent has exchanged with the user and the tools during the current execution.
- Long-term memory is the fact store — the things the agent has decided are worth keeping across executions.
- Dreaming is the consolidation step that reads working memory after a session ends and writes a curated subset to long-term memory.
The clean separation matters because each primitive has different durability, different read/write semantics, and different costs.
Working memory: the active conversation
Working memory holds the messages of the current execution. The class is WorkingMemory in flux/tasks/ai/memory/working_memory.py; the constructor signature is:
class WorkingMemory:
def __init__(
self,
window: int | None = None,
max_tokens: int | None = None,
compact_model: str | None = None,
compact_threshold: float = 0.70,
compact_preserve: int = 4,
) -> None: ...
(See flux/tasks/ai/memory/working_memory.py:39-52.) Every parameter is optional — WorkingMemory() with no arguments gives you an unbounded message log. window caps the recall to the last N messages, max_tokens caps the byte budget (estimated at one token per four characters), and the compact_* trio enables LLM-driven summarization when the budget is nearly full.
The interface is small: memorize(role, content), recall(), forget(message_id), keys(). Each call to memorize writes a new task event into the workflow’s log. recall does not call the LLM and does not hit a database; it walks the in-memory event list and returns the live messages, applying the window and token cap on the way out.
The interesting design point is that working memory is not a separate store. Every memorize is implemented as a tiny @task named wm_memorize_<counter> whose return value is {"role": ..., "content": ...}. When the workflow checkpoints, those task results are persisted alongside every other task event. When the workflow replays, the events come back the way the runtime already knows how to bring back any other task — TASK_COMPLETED is TASK_COMPLETED, whether it stored a database row or an assistant message.
This has two practical consequences. First, working memory is durable for free: pause the workflow, restart the worker, resume two days later, and the conversation is intact. Second, working memory is replay-safe for free: on resume, the LLM is not re-queried for messages it already produced — those messages are recorded task outputs, and recall() reads them straight from the log. The same mechanism that gives a workflow free idempotency gives an agent free conversation persistence.
The optional compaction path is also stored in the event log. When the token budget gets close to full, WorkingMemory summarizes the older portion of the conversation via a wm_compact_summarizer agent, then writes wm_forget_<n> and wm_compact_<n> events that tell future recall() calls to skip or replace specific messages. Compaction is itself a recorded effect — replay will re-apply the same summary, not regenerate it.
Long-term memory: the cross-session fact store
Long-term memory is the place where the agent stores things it wants to remember after this execution ends. The class is LongTermMemory in flux/tasks/ai/memory/long_term_memory.py, and its constructor is the one place to look twice:
class LongTermMemory:
def __init__(self, provider: MemoryProvider, agent: str, scope: str) -> None: ...
(See flux/tasks/ai/memory/long_term_memory.py:8-12.) All three arguments are required. provider is the backend — where the bytes actually live. agent is a logical identity: every fact is stored under one agent string so that two agents on the same database don’t collide. scope is a sub-namespace within an agent — typically a user ID, a session ID, or a task name, used to keep one user’s facts out of another user’s recall. There is no default value for any of the three; the factory long_term_memory(provider=..., agent=..., scope=...) in flux/tasks/ai/memory/__init__.py forwards all three positional arguments through.
The data shape is a three-level dictionary: agent -> scope -> {key: value}. The interface mirrors working memory but takes keys instead of running counters: memorize(key, value), recall(key=None), forget(key=None), keys(), scopes(). A recall() with no key returns the entire scope as a dict; with a key it returns one value or None.
The other public method is as_tools(). It returns four @task callables — recall_memory, store_memory, forget_memory, list_memory_keys — bound to this LongTermMemory instance. Drop them into agent(..., tools=[...]) and the LLM gains the ability to read and write its own fact store via tool calls. The system prompt hint that explains these tools to the model is exposed as system_prompt_hint().
Providers
The provider argument is what actually persists the bytes. The provider list lives in flux/tasks/ai/memory/providers/:
in_memory.py—InMemoryProvider, a plain process-local dict. Useful for tests and ad-hoc scripts; loses everything when the process exits.sqlalchemy.py—SqlAlchemyProvider, a singlememorytable with(agent, scope, key)as a composite primary key and a JSON-serializedvalue. Backs both SQLite and PostgreSQL; the dialect is detected from the engine and an atomic upsert is built per-dialect.protocol.py— theMemoryProviderruntime-checkableProtocolthat defines the five-method contract every provider implements (memorize,recall,forget,keys,scopes).
The package’s __init__.py exposes convenience factories: in_memory(), sqlite(db_path), postgresql(connection_string). The protocol is what you implement if you want a vector store, Redis, or a custom backend — the rest of the system depends on the five methods, not on SQLAlchemy.
The choice of provider is purely a storage decision. The LongTermMemory API is the same in every case. What changes is durability (process-only vs. on-disk vs. networked), latency (microseconds vs. milliseconds vs. tens of milliseconds), and what failure modes you have to handle.
Memory reads as task events
LongTermMemory.memorize and recall are not themselves tasks — they call the provider directly. The durability story is different from working memory: long-term memory is persisted because the provider talks to a database, not because Flux records the call in the event log. That distinction matters when you think about replay.
When the agent uses as_tools() to read or write memory, however, those tool calls are tasks. recall_memory(key="user_name") is invoked via the tool executor, which wraps it in a per-iteration task. On first run it queries the provider and returns “Alice”; the result is checkpointed as a TASK_COMPLETED event. On replay, Flux finds the recorded event and returns “Alice” without re-querying the provider — even if the underlying database has changed in the meantime. This is the same idempotency-by-replay rule that applies to every other task; long-term memory reads inherit it as long as they go through the tool interface. (For the wider replay contract, see Idempotency.)
Dreaming: consolidation between sessions
Dreaming is the third primitive, and it answers a problem the first two don’t: working memory grows during a long conversation, and most of what’s in it is not worth keeping forever. The user said hello, the tools returned three pages of JSON, the assistant said “okay” — most of that is process noise. A few signals — the user’s stated preference, a decision they made, a fact they corrected — are worth carrying into the next session. Dreaming is the LLM-driven step that picks the signals out.
The implementation lives in flux/tasks/ai/dreaming.py. The user-facing entry point is dream(working_memory=..., long_term_memory=...), which returns an async hook intended to be passed to agent(..., on_complete=[dream(...)]). When the agent’s turn ends, the hook fires a separate agent_dream workflow asynchronously via call("agent_dream", payload, mode="async") — dreaming runs out-of-band, not in the user’s request path.
The agent_dream workflow is a four-phase pipeline, and each phase is itself a small agent with the long-term memory tools attached:
- Orient (
dream_orient) — list all keys in long-term memory, read each one, build a mental map. Identify duplicates, contradictions, stale entries. - Gather signal (
dream_gather_signal) — scan the working-memory snapshot for high-value signals: corrections, decisions, facts referenced three or more times, error responses about entities in memory. - Consolidate (
dream_consolidate) — merge duplicates, resolve contradictions, convert relative dates to absolute, enrich memory with the gathered signals. - Prune (
dream_prune) — remove stale entries, summarize verbose ones, cap the total number of keys, verify consistency. Produce a summary of what changed.
Each phase calls recall_memory, store_memory, forget_memory, and list_memory_keys — the tools that LongTermMemory.as_tools() produces. The whole pipeline is therefore an agent that operates on another agent’s memory, using tool calls that go through Flux’s task layer, recorded in the event log, replay-safe.
There is a small failure gate: check_failure_gate reads a special _dream:failures key and skips the dream if it has failed too many times consecutively. After a successful dream, reset_failure_counter zeroes that counter. So dreaming is allowed to fail without infinitely retrying.
The cost model is important. Dreaming runs an LLM through four phases of analysis. It is meaningfully more expensive than a single agent turn. The whole reason it runs out-of-band — call(..., mode="async") — is so that the user’s response time isn’t held up by it. The trade is: pay a few thousand tokens of LLM cost on session boundary so that the long-term memory store stays clean and small for every subsequent session.
How memory plugs into the agent loop
The run-time relationship between the three primitives shows up in flux/tasks/ai/agent_loop.py. A single turn through run_agent_loop does roughly this:
- Build the message list from the system prompt plus
working_memory.recall(). - Call the LLM, store any reasoning trace into working memory via
memorize("reasoning", ...). - If the response has tool calls, write a
tool_callentry to working memory, execute the tools, write eachtool_resultback to working memory, and loop. - When the LLM produces a final text response, write the user input and the assistant response to working memory.
- Fire
on_completehooks — which is where the dream hook runs, asynchronously.
Long-term memory is not addressed directly by this loop. It enters as a set of tools: the LLM decides when to call recall_memory or store_memory. That keeps the loop ignorant of where memory lives — it just executes tools that the agent author wired in. Dreaming similarly does not run during the loop; it runs after, in a separate workflow.
The boundary is the part to internalize: working memory is inside the loop and lives in the event log; long-term memory is outside the loop and lives in a provider; dreaming is the bridge between them and runs as its own workflow. All three are durable, but the durability comes from different mechanisms.
When to use each
The right combination depends on what the agent does.
Working memory only. Short interactions, single-turn tasks, or multi-turn tasks where each session is fresh. A research agent that scans a document and writes a summary. A code-fix agent that reads three files and proposes a patch. Anything where “next session” doesn’t exist or doesn’t need continuity.
Working memory + long-term memory. Chatbots that need to remember the user across sessions. Support agents that come back to the same case file the next day. Anything where the same user / same agent identity will return and you want the agent to recognize state from a previous run. Without dreaming, you push facts into long-term memory either explicitly (the agent calls store_memory itself) or via custom logic at session end.
Working memory + long-term memory + dreaming. Long-running agents: research assistants that work over weeks, support agents that accumulate user knowledge over months, code agents that build up a per-repo project model. Working memory keeps each session usable; long-term memory holds the durable picture; dreaming keeps long-term memory clean. Skip dreaming if you don’t have growth pressure — it costs tokens.
Costs to budget
The three primitives have very different runtime costs. Working memory is essentially free — each memorize is a small task event, each recall is an in-memory list walk. The cost only appears when the message list itself becomes large enough to slow the LLM call or trigger compaction.
Long-term memory reads are cheap with InMemoryProvider, cheap-on-disk with SqlAlchemyProvider over SQLite, and a database round-trip with PostgreSQL. Anything that goes through the tool interface — that is, every memory read or write the LLM makes — adds a tool-call turn to the loop, which is its own LLM round-trip.
Dreaming costs the most. Four LLM-driven phases, each making tool calls into long-term memory, each producing a structured report. A reasonable mental model: budget a dream run at roughly 5–10x the cost of a single agent turn. The mitigation is that dreaming is async and rare — once per session, or once per N turns.
Next: Sub-agents and trees of work, where the same durability story shows up at a higher level — agents spawning agents and coordinating their results. Previous: Agents as durable workflows.