Memory lets an agent recall facts across turns. Declare memory on the agent, pass a memory store at runtime, and Jidoka injects matching entries into the prompt.

Use This When

  • adding short-term conversation memory to an agent;
  • integrating a custom memory store (Postgres, Redis, Jido.Memory, etc.).
  • debugging "the agent does not remember what I told it".

Prerequisites

mix deps.get
mix test

Add Session Memory

Start the bundled in-memory store, declare memory on the agent, and pass memory_store: when running the turn.

defmodule MyApp.MemoryAgent do
  use Jidoka.Agent

  agent :memory_agent do
    model "openai:gpt-4o-mini"
    instructions "Greet the user and use any remembered facts."
    memory scope: :session, capture: :conversation, max_entries: 5
  end
end

{:ok, store_pid} = Jidoka.Memory.Store.InMemory.start_link([])
store = {Jidoka.Memory.Store.InMemory, pid: store_pid}

{:ok, _write} =
  Jidoka.Memory.write(
    MyApp.MemoryAgent.spec(),
    "User prefers the name Alex.",
    memory_store: store,
    session_id: "conv-1"
  )

{:ok, result} =
  Jidoka.turn(MyApp.MemoryAgent, "hello",
    memory_store: store,
    session_id: "conv-1"
  )

result.content

The recall happened before the prompt was assembled. The entry was injected into the agent's instructions section (the default for inject: :instructions).

Concepts

Memory in Jidoka is three pieces of data and one boundary.

  1. Jidoka.Agent.Spec.Memory is the policy declared on the spec. It is the only memory data that lives on the spec itself: enabled, scope (:agent or :session), namespace, capture (:manual | :conversation | :off), inject (:instructions | :context), max_entries, and free-form metadata.
  2. Jidoka.Memory.Store is the behaviour each store implements: recall/2, write/2, list_entries/1. Stores are runtime data, not spec data; callers supply them per turn through the memory_store: option.
  3. Request/result data is a small set of structs: Jidoka.Memory.RecallRequest, Jidoka.Memory.RecallResult, Jidoka.Memory.WriteRequest, Jidoka.Memory.WriteResult, and Jidoka.Memory.Entry.
╭──────────────╮     ╭───────────────────╮     ╭──────────────────╮
│  Turn input  │────▶│ Memory.Runtime    │────▶│ Memory.Store     │
│   (request)  │     │ .recall(spec, req)│     │ .recall(req)     │
╰──────────────╯     ╰─────────┬─────────╯     ╰────────┬─────────╯
                               │                        │
                               ▼                        ▼
                     ╭──────────────────╮     ╭──────────────────╮
                     │ RecallResult     │◀────│ matching entries │
                     ╰────────┬─────────╯     ╰──────────────────╯
                              │
                              ▼
                     ╭───────────────────╮     ╭──────────────────╮
                     │ Steps.assemble    │────▶│ Prompt with      │
                     │   _prompt/1       │     │ memory injected  │
                     ╰────────┬──────────╯     ╰──────────────────╯
                              │
                              ▼
                     ╭───────────────────╮     ╭──────────────────╮
                     │ LLM + tools loop  │────▶│ Memory.Runtime   │
                     ╰───────────────────╯     │ .capture_turn/4  │
                                               ╰─────────┬────────╯
                                                         │
                                                         ▼
                                               ╭──────────────────╮
                                               │ WriteResult      │
                                               ╰──────────────────╯

Jidoka.Memory is the public helper that knows how to translate between the spec policy, the per-turn options, and the store. Applications talk to the store through Jidoka.Memory.Store directly (or through the runtime helper used in tests).

Scope And Session

  • scope: :agent returns any entry tagged with the agent id, ignoring session.
  • scope: :session returns only entries whose session_id matches the current session_id: option. Pass the option through both write and recall.

The InMemory store enforces these rules in matches_request?/2. Session filtering is exact-match; there is no fuzzy fallback.

Capture Modes

  • capture: :manual (default) - memory only changes through explicit Jidoka.Memory.write/3 calls.
  • capture: :conversation - after every successful turn, Jidoka writes "User: ...\nAssistant: ..." to the store.
  • capture: :off - the runtime never writes; useful when memory is populated by another system.

How To

Step 1: Declare The Memory Policy

The DSL accepts a keyword/map equivalent of Jidoka.Agent.Spec.Memory:

agent :support_agent do
  model "openai:gpt-4o-mini"
  instructions "Answer support questions tersely."
  memory scope: :session, capture: :conversation, max_entries: 8
end

memory true enables defaults. memory false disables memory. Anything else is parsed as a memory policy map.

Step 2: Start An InMemory Store For Tests

Jidoka.Memory.Store.InMemory is an Agent process keyed by :pid. The test process keeps its lifetime bounded.

{:ok, pid} = Jidoka.Memory.Store.InMemory.start_link([])
store = {Jidoka.Memory.Store.InMemory, pid: pid}

The two-tuple form {module, opts} is the standard store input; pass it wherever a Jidoka.Memory.Store.store() is required.

Step 3: Write An Entry Manually

Jidoka.Memory.write/3 builds the Memory.Entry, applies the spec policy (scope, namespace), and forwards to the store.

{:ok, %Jidoka.Memory.WriteResult{entry: entry}} =
  Jidoka.Memory.write(
    MyApp.MemoryAgent.spec(),
    "User prefers the name Alex.",
    memory_store: store,
    session_id: "conv-1",
    metadata: %{"kind" => :preference}
  )

entry.agent_id
#=> "memory_agent"

entry.session_id
#=> "conv-1"

The store you supplied receives a Jidoka.Memory.WriteRequest whose :entry is the validated Memory.Entry struct.

Step 4: Recall Through The Store Directly

You can also bypass the runtime helper and talk to Jidoka.Memory.Store:

request =
  Jidoka.Memory.RecallRequest.new!(
    agent_id: "memory_agent",
    session_id: "conv-1",
    scope: :session,
    query: "hello",
    limit: 5
  )

{:ok, %Jidoka.Memory.RecallResult{entries: entries}} =
  Jidoka.Memory.Store.recall(store, request)

Enum.map(entries, & &1.content)
#=> ["User prefers the name Alex."]

This is what Jidoka.Memory.recall/3 does when Jidoka assembles the prompt for a turn.

Step 5: Inspect Memory Injection With Preflight

Before running a live turn, confirm the recalled entries are landing in the prompt:

{:ok, preflight} =
  Jidoka.preflight(MyApp.MemoryAgent, "hello",
    memory_store: store,
    session_id: "conv-1"
  )

preflight.prompt.messages

With inject: :instructions (the default) the recalled content is appended to the system message. With inject: :context it is added as structured context the prompt assembler renders separately.

Step 6: Implement A Custom Store

A custom store is one module that implements Jidoka.Memory.Store:

defmodule MyApp.MapStore do
  @behaviour Jidoka.Memory.Store

  alias Jidoka.Memory.Entry
  alias Jidoka.Memory.RecallRequest
  alias Jidoka.Memory.RecallResult
  alias Jidoka.Memory.WriteRequest
  alias Jidoka.Memory.WriteResult

  def start_link(_opts), do: Agent.start_link(fn -> [] end)

  @impl true
  def recall(%RecallRequest{} = request, opts) do
    pid = Keyword.fetch!(opts, :pid)
    entries = pid |> Agent.get(& &1) |> Enum.take(request.limit)
    RecallResult.new(request: request, entries: entries)
  end

  @impl true
  def write(%WriteRequest{entry: %Entry{} = entry} = request, opts) do
    pid = Keyword.fetch!(opts, :pid)
    Agent.update(pid, &[entry | &1])
    WriteResult.new(request: request, entry: entry)
  end

  @impl true
  def list_entries(opts) do
    pid = Keyword.fetch!(opts, :pid)
    {:ok, pid |> Agent.get(& &1) |> Enum.reverse()}
  end
end

Pass it as {MyApp.MapStore, pid: pid} to memory_store:. Stores must return {:ok, result} or {:error, reason} and must never raise on missing entries; an empty RecallResult is the normal "nothing matched" answer.

Common Patterns

  • Default to scope: :session for chat experiences. It keeps unrelated conversations from leaking facts into each other.
  • Set max_entries deliberately. The recall limit caps tokens; the default of 5 is small.
  • Pair capture: :conversation with Jidoka.Session. Manual writes are fine for direct turn/3 callers, but multi-turn applications benefit from automatic capture once a session id is in scope.
  • Namespace memory per tenant. Use namespace: {:context, :tenant_id} and supply context: %{tenant_id: "..."} per turn; the runtime resolves the namespace before talking to the store.
  • Treat the store as data. A {module, opts} tuple is the same kind of capability as the LLM function or operations function. Inject it; do not hard-code it in the agent.

Testing

A complete memory test exercises write -> recall -> prompt assembly without calling a provider. The InMemory store and a fake LLM are all that is needed.

defmodule MyApp.MemoryAgentTest do
  use ExUnit.Case, async: true

  setup do
    {:ok, pid} = Jidoka.Memory.Store.InMemory.start_link([])
    {:ok, store: {Jidoka.Memory.Store.InMemory, pid: pid}}
  end

  test "recalls a remembered preference", %{store: store} do
    {:ok, _write} =
      Jidoka.Memory.write(
        MyApp.MemoryAgent.spec(),
        "User prefers the name Alex.",
        memory_store: store,
        session_id: "conv-1"
      )

    {:ok, preflight} =
      Jidoka.preflight(MyApp.MemoryAgent, "hello",
        memory_store: store,
        session_id: "conv-1"
      )

    system_message = Enum.find(preflight.prompt.messages, &(&1.role == :system))
    assert system_message.content =~ "prefers the name Alex"

    llm = fn _intent, _journal ->
      {:ok, %{type: :final, content: "Welcome back, Alex."}}
    end

    assert {:ok, result} =
             Jidoka.turn(MyApp.MemoryAgent, "hello",
               llm: llm,
               memory_store: store,
               session_id: "conv-1"
             )

    assert result.content =~ "Alex"
  end
end

To assert the conversation capture path, run the turn with capture: :conversation and read Jidoka.Memory.Store.list_entries/1 afterwards. The latest entry should contain both the user input and the assistant content.

Troubleshooting

SymptomLikely CauseFix
Jidoka.Memory.recall/3 returns {:ok, nil}The spec has no memory policy or it is disabled.Add memory ... to the DSL, or pass a non-false memory policy when building the spec from data.
Memory entries are written but never returnedMismatched scope or session_id.Use the same session_id on both write and recall; for scope: :agent, pass no session id.
in-memory memory store requires :pidThe InMemory store was started but the :pid opt was not threaded through.Pass {Jidoka.Memory.Store.InMemory, pid: pid} to memory_store:.
{:error, :missing_memory_store} from manual writesJidoka.Memory.write/3 was called without memory_store:.Supply the store explicitly; the runtime does not pull from application config.
Entries land in the prompt twiceA manual write/3 plus capture: :conversation recorded the same content.Pick one capture mode per agent or filter on metadata["source"].

Reference