An LLM has no persistent state between calls. Every inference starts from scratch. Agent memory is the infrastructure that gives agents the ability to remember across turns, sessions, and tasks.

The key insight is that different types of information need different storage and retrieval strategies:
| Memory Type | What It Stores | Storage | Retrieval |
|---|---|---|---|
| Conversational | Chat history per thread | SQL table | Exact, ordered by time |
| Knowledge base | Documents and facts | Vector table | Semantic similarity |
| Workflow | Procedural steps and patterns | Vector table | Semantic similarity |
| Toolbox | Available tools and descriptions | Vector table | Semantic similarity |
| Entity | Named entities and relationships | Vector table | Semantic similarity |
| Summary | Compressed context windows | Vector table | Semantic similarity |
| Tool log | Raw tool call outputs | SQL table | Exact, by tool call ID |
Conversational and tool log memory use plain SQL tables because you always need the exact, ordered history — there is no fuzzy retrieval, you need every message in sequence.
All semantic memory types use vector-enabled tables because you need relevance-ranked retrieval — you never retrieve the entire knowledge base, only what is relevant to the current query.
create_conversational_history_tableThis function creates the SQL table that stores chat history. Each row is one message turn.
Why SYS_GUID()? Oracle’s built-in UUID generator. It creates a globally unique ID for each row without requiring a sequence or application-side ID generation.
Why summary_id? When conversation messages are summarised and offloaded in Part 4, this column links each original message to its summary. Without it, the agent cannot track which messages have been compacted or retrieve the original conversation from a summary reference.
Complete solution:
def create_conversational_history_table(conn, table_name: str = "CONVERSATIONAL_MEMORY"):
with conn.cursor() as cur:
try:
cur.execute(f"DROP TABLE {table_name}")
except:
pass # Table does not exist yet — that is fine
cur.execute(f"""
CREATE TABLE {table_name} (
id VARCHAR2(100) DEFAULT SYS_GUID() PRIMARY KEY,
thread_id VARCHAR2(100) NOT NULL,
role VARCHAR2(50) NOT NULL,
content CLOB NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata CLOB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
summary_id VARCHAR2(100) DEFAULT NULL
)
""")
cur.execute(f"""
CREATE INDEX idx_{table_name.lower()}_thread_id ON {table_name}(thread_id)
""")
cur.execute(f"""
CREATE INDEX idx_{table_name.lower()}_timestamp ON {table_name}(timestamp)
""")
conn.commit()
print(f"Table {table_name} created successfully with indexes")
return table_name
Why role VARCHAR2(50)? Stores "user", "assistant", or "tool". 50 characters provides headroom for future role types.
Why CLOB for content? Messages can be long — tool outputs especially. CLOB (Character Large Object) stores up to 4GB of text. VARCHAR2 maxes out at 32KB in Oracle.
Why indexes? The thread_id index speeds up all per-thread lookups (every read and write is scoped by thread). The timestamp index speeds up ordering, which is used on every conversation retrieval.
Each semantic memory type gets its own OracleVS instance backed by its own vector-enabled SQL table. This separation gives you:
Complete solution:
knowledge_base_vs = OracleVS(
client=vector_conn,
embedding_function=embedding_model,
table_name=KNOWLEDGE_BASE_TABLE,
distance_strategy=DistanceStrategy.COSINE,
)
workflow_vs = OracleVS(
client=vector_conn,
embedding_function=embedding_model,
table_name=WORKFLOW_TABLE,
distance_strategy=DistanceStrategy.COSINE,
)
toolbox_vs = OracleVS(
client=vector_conn,
embedding_function=embedding_model,
table_name=TOOLBOX_TABLE,
distance_strategy=DistanceStrategy.COSINE,
)
entity_vs = OracleVS(
client=vector_conn,
embedding_function=embedding_model,
table_name=ENTITY_TABLE,
distance_strategy=DistanceStrategy.COSINE,
)
summary_vs = OracleVS(
client=vector_conn,
embedding_function=embedding_model,
table_name=SUMMARY_TABLE,
distance_strategy=DistanceStrategy.COSINE,
)
The MemoryManager class is provided in the next code cell after the guard assertions. It is provided complete — you do not need to modify it. But read through it, because understanding how it works is central to the workshop.
Key methods to understand:
write_conversation(thread_id, role, content) — Inserts a row into CONVERSATIONAL_MEMORY. Called programmatically by the agent harness on every turn.
read_conversation(thread_id, limit) — Retrieves the last N turns for a thread. Returns them in chronological order so the LLM sees a coherent conversation.
write_knowledge(text, metadata) — Embeds text and inserts into SEMANTIC_MEMORY. Used to load domain knowledge the agent can retrieve.
read_knowledge(query, k) — Semantic search over the knowledge base. Returns the k most relevant documents for a query.
write_toolbox(tool_name, description, metadata) — Embeds a tool description and stores it in TOOLBOX_MEMORY. Enables the agent to retrieve only relevant tools for a given task.
read_toolbox(query, k) — Semantic search over registered tools. This is how the agent selects which tools to use without being given all tools on every call.
This is the most important design decision in memory engineering. Read the “Programmatic vs Agent-Triggered Operations” section carefully.
Programmatic (always runs, harness controls it):
Agent-triggered (LLM decides when to call it):
Getting this boundary wrong in either direction causes problems:
AttributeError: 'NoneType' has no attribute ... — One of your OracleVS instances is still None. Check that your TODO cell ran successfully and the variables are assigned.
ORA-00942: table or view does not exist — The conversational memory table was not created. Re-run the create_conversational_history_table cell.
ORA-01408: such column list already indexed — An HNSW index already exists on this table from a prior run. The safe_create_index helper handles this. If you see it outside that helper, check the index name for a typo.
The MemoryManager class has four methods left for you to implement. Each one teaches a different pattern — SQL insert, vector add, structured vector add with metadata, and direct entity storage.
write_conversational_memory — SQL INSERTThis is the foundational SQL write. Every user and assistant message gets written here programmatically on each turn.
def write_conversational_memory(self, content: str, role: str, thread_id: str) -> str:
thread_id = str(thread_id)
with self.conn.cursor() as cur:
id_var = cur.var(str)
cur.execute(f"""
INSERT INTO {self.conversation_table} (thread_id, role, content, metadata, timestamp)
VALUES (:thread_id, :role, :content, :metadata, CURRENT_TIMESTAMP)
RETURNING id INTO :id
""", {"thread_id": thread_id, "role": role, "content": content, "metadata": "{}", "id": id_var})
record_id = id_var.getvalue()[0] if id_var.getvalue() else None
self.conn.commit()
return record_id
Key concept: RETURNING id INTO :id is Oracle’s way of capturing an auto-generated value from an INSERT in a single round trip. cur.var(str) creates an output bind variable that Oracle writes the new ID into.
write_knowledge_base — Vector AddThis is the simplest vector write — pass text and metadata directly to OracleVS which handles embedding and insertion.
def write_knowledge_base(self, text: str, metadata: dict):
self.knowledge_base_vs.add_texts([text], [metadata])
Key concept: Both arguments must be lists even when adding a single document. OracleVS batches the embedding calls, so the list interface keeps the API consistent whether you add 1 or 1,000 documents.
write_workflow — Structured Vector AddWorkflow memory requires structuring the data before storing it — the steps list needs formatting and the metadata needs computing before the vector write.
def write_workflow(self, query: str, steps: list, final_answer: str, success: bool = True):
steps_text = "\n".join([f"Step {i+1}: {s}" for i, s in enumerate(steps)])
text = f"Query: {query}\nSteps:\n{steps_text}\nAnswer: {final_answer[:200]}"
metadata = {
"query": query,
"success": success,
"num_steps": len(steps),
"timestamp": datetime.now().isoformat()
}
self.workflow_vs.add_texts([text], [metadata])
Key concept: The num_steps metadata field enables filtered retrieval — read_workflow uses filter={"num_steps": {"$gt": 0}} to exclude empty workflows. Storing computable fields as metadata is the pattern that makes filtered vector search useful.
write_entity — Direct Entity StorageThe direct storage branch (no LLM extraction) stores a single named entity as a vector alongside its metadata.
# else branch — direct storage
self.entity_vs.add_texts(
[f"{name} ({entity_type}): {description}"],
[{"name": name, "type": entity_type, "description": description}]
)
Key concept: The text format "Name (TYPE): description" is deliberate — embedding the type into the text means semantic searches like “find a database system” will surface entities of type SYSTEM, because the word “system” is part of the embedded string.