n4nAI

How to add memory to a ReAct reasoning loop

Learn how to add ReAct agent memory to a reasoning-action loop with practical steps, code samples, and verification tips for production LLM agents.

n4n Team3 min read642 words

Audio narration

Coming soon — every post will get a voice note here.

A ReAct agent memory component is what separates a demo that solves toy puzzles from an agent that survives real multi-turn tasks. The base ReAct loop interleaves thought, action, and observation, but if you discard those traces after each step the agent repeats mistakes and loses user context. This guide shows how to add ReAct agent memory to a reasoning-action loop with a concrete Python implementation you can run against any OpenAI-compatible endpoint.

Step 1: Define the memory interface

Before writing storage code, decide what the loop needs from memory. You require two capabilities: episodic recall of the current task trajectory (working memory) and semantic retrieval of prior facts (long-term memory). A clean abstraction prevents the rest of the agent from caring whether records live in SQLite, Redis, or a vector index.

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class MemoryRecord:
    text: str
    meta: dict
    embedding: list[float] | None = None

class MemoryStore(ABC):
    @abstractmethod
    def add(self, rec: MemoryRecord) -> None: ...

    @abstractmethod
    def search(self, query_embedding: list[float], k: int) -> list[MemoryRecord]: ...

    @abstractmethod
    def get_recent(self, n: int) -> list[MemoryRecord]: ...

The meta field stores the record type (thought, action, observation) and any tool metadata. Keeping the interface narrow means you can swap the backend later without touching the ReAct loop.

Step 2: Implement a persistent store with embeddings

For long-term recall you need similarity search. The simplest runnable approach is SQLite plus embeddings from an OpenAI-compatible API. Store the vector as JSON for clarity; in production you would use a real vector extension or a dedicated store.

import sqlite3, json, numpy as np, openai

class SqliteMemory(MemoryStore):
    def __init__(self, db_path="memory.db", client=None):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute("CREATE TABLE IF NOT EXISTS mem (id INTEGER PRIMARY KEY, text TEXT, meta TEXT, emb TEXT)")
        self.client = client or openai.OpenAI()

    def _embed(self, text: str) -> list[float]:
        resp = self.client.embeddings.create(model="text-embedding-3-small", input=text)
        return resp.data[0].embedding

    def add(self, rec: MemoryRecord) -> None:
        emb = rec.embedding or self._embed(rec.text)
        self.conn.execute(
            "INSERT INTO mem (text, meta, emb) VALUES (?,?,?)",
            (rec.text, json.dumps(rec.meta), json.dumps(emb)),
        )
        self.conn.commit()

    def search(self, query_embedding: list[float], k: int = 5) -> list[MemoryRecord]:
        rows = self.conn.execute("SELECT text, meta, emb FROM mem").fetchall()
        scored = []
        q = np.array(query_embedding)
        for text, meta, emb in rows:
            vec = np.array(json.loads(emb))
            score = float(np.dot(q, vec) / (np.linalg.norm(q) * np.linalg.norm(vec)))
            scored.append((score, text, meta))
        scored.sort(reverse=True)
        return [MemoryRecord(text=t, meta=json.loads(m)) for _, t, m in scored[:k]]

    def get_recent(self, n: int = 10) -> list[MemoryRecord]:
        rows = self.conn.execute(
            "SELECT text, meta FROM mem ORDER BY id DESC LIMIT ?", (n,)
        ).fetchall()
        return [MemoryRecord(text=r[0], meta=json.loads(r[1])) for r in rows]

This implementation gives you durable ReAct agent memory across process restarts. The cosine similarity in search is adequate for a few thousand records; beyond that, move to a proper ANN index.

Step 3: Retrieve and inject memories into the ReAct prompt

The ReAct prompt must include retrieved context or the memory is useless. Build a function that embeds the current task, pulls the top-k long-term records, and prepends recent trajectory lines.

def build_prompt(task: str, memory: MemoryStore, client) -> str:
    q_emb = client.embeddings.create(model="text-embedding-3-small", input=task).data[0].embedding
    long_term = memory.search(q_emb, k=3)
    recent = memory.get_recent(5)
    sys = "You are a ReAct agent. Emit 'Thought:', 'Action:', then 'Observation:' lines."
    mem_block = "\n".join(f"[Memory] {r.text}" for r in long_term)
    traj_block = "\n".join(f"[Trajectory] {r.text}" for r in recent)
    return f"{sys}\n{mem_block}\n{traj_block}\nTask: {task}\n"

Injecting both blocks lets the model reference a fact from last week and the action it took two steps ago. Without explicit retrieval, the ReAct agent memory stays latent.

Step 4: Persist thoughts, actions, and observations

After each LLM turn, parse the output and write every meaningful step to the store. Do not wait until the task ends; a crash mid-loop should not lose state.

def persist_step(thought: str, action: str, obs: str, memory: MemoryStore) -> None:
    memory.add(MemoryRecord(text=f"Thought: {thought}", meta={"type": "thought"}))
    memory.add(MemoryRecord(text=f"Action: {action}", meta={"type": "action"}))
    memory.add(MemoryRecord(text=f"Observation: {obs}", meta={"type": "observation"}))

Call this inside the loop immediately after you execute the action and capture the observation. This discipline is what makes ReAct agent memory reliable.

Step 5: Bound the working set

Unbounded trajectory growth blows the context window. Keep a short working list in memory and archive older records to long-term storage once it exceeds a threshold.

class WorkingMemory:
    def __init__(self, long_term: MemoryStore, limit: int = 12):
        self.buffer: list[MemoryRecord] = []
        self.long_term = long_term
        self.limit = limit

    def add(self, rec: MemoryRecord) -> None:
        self.buffer.append(rec)
        if len(self.buffer) > self.limit:
            old = self.buffer.pop(0)
            self.long_term.add(old)

    def recent(self, n: int) -> list[MemoryRecord]:
        return self.buffer[-n:]

The ReAct agent memory thus keeps the last dozen steps hot and pushes the rest into searchable storage. You avoid re-embedding on every turn by embedding only when archiving.

Step 6: Run the full loop

Wire the pieces together. Use a chat completion call, parse the ReAct format, execute a stub tool, and persist. For resilience across providers, point the OpenAI client at a gateway that fronts many models; for example, n4n.ai exposes one OpenAI-compatible endpoint for 240+ models with automatic fallback when a provider is degraded.

from openai import OpenAI

client = OpenAI(base_url="https://api.openai.com/v1", api_key="sk-...")
memory = SqliteMemory(client=client)
working = WorkingMemory(memory)

task = "Find the cheapest flight to NYC under $300"
prompt = build_prompt(task, memory, client)

for _ in range(6):
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "system", "content": prompt}],
    )
    text = resp.choices[0].message.content
    # Minimal parser: split on labels. Replace with robust regex in prod.
    thought = text.split("Thought:")[1].split("Action:")[0].strip()
    action = text.split("Action:")[1].split("Observation:")[0].strip()
    obs = "tool result stub"
    persist_step(thought, action, obs, working)
    prompt = build_prompt(task, memory, client)

The loop now reads from and writes to memory every iteration. The build_prompt call after each step refreshes both retrieved context and recent trajectory.

Step 7: Verify success

Verification must be concrete, not vibes.

  1. Unit test retrieval. Insert two records, embed a query matching one, assert search returns it first.
  2. Integration run. Execute the loop on a task requiring a fact from step 1 at step 4. Open memory.db and confirm row counts increase each iteration.
  3. Prompt inspection. Log the prompt string. Confirm it contains [Memory] lines referencing earlier steps and [Trajectory] lines from the current episode.
  4. Failure drill. Kill the process after step 3, restart, rebuild SqliteMemory, and confirm get_recent returns prior observations.

If the agent stops repeating early mistakes and completes a task that spans more than ten tool calls without context overflow, your ReAct agent memory is working. Treat the store as a first-class component: back it up, index it, and version your schema.

Tagsreact-patternai-agent-memoryai-agents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All react & reasoning-action loops posts →