Skip to main content

Agent with Persistent Memory

Give an agent memory that persists across sessions: create a memory store, write memories and observe deduplication, combine memories with a Document via knowledge_config, let the agent write back with write_memory_store_id, give the store a memory rule that ingests finished turns, and query the knowledge layer directly.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

See Users.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project​

A project holds the memory store and agent.

PROJECT_ID=$(soat create-project --name "Support Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create an AI provider​

A local Ollama AI provider. For xAI, OpenAI, Anthropic, or Amazon Bedrock see Connect Third-Party LLMs.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"

Step 4 — Create a memory store​

A memory store is a named container of memories; key-value tags let an agent search a subset of a project's stores.

MEMORY_STORE_ID=$(soat create-memory-store \
--project-id "$PROJECT_ID" \
--name "Alice Profile" \
--description "Facts about customer Alice gathered during support interactions" \
--tags '{"customer":"alice","kind":"profile"}' | jq -r '.id')
echo "MEMORY_STORE_ID: $MEMORY_STORE_ID"

Step 5 — Write memories​

Every write goes through the same three-outcome algorithm (Memories — Write Algorithm), on every path: created (201, a distinct fact stored as its own memory), skipped (200, the fact is already known), or superseded (200, the same fact has changed — the old memory is retired and a new one replaces it). Each write also appends one assertion, whatever it resolved to; Step 5e reads them back.

5a — First memory (action: created)​

No similar memory exists, so it is stored.

soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice prefers email over phone calls for all support communication"
# → { "action": "created", ... }

5b — Near-duplicate (action: skipped)​

Near-identical to 5a; ignored.

soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice prefers email over phone calls"
# → { "action": "skipped", ... }

Overlaps 5a with new detail, but not closely enough to count as the same fact, so the richer statement is stored as its own atomic memory.

soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice prefers email, especially for billing inquiries; she checks it twice a day"
# → { "action": "created", ... }

5d — Second distinct fact (action: created)​

Unrelated; stored as a new memory.

soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "The Alice Corp fiscal year ends in March; she starts renewal discussions in January"
# → { "action": "created", ... }

5e — Read the write ledger​

Every write above left an assertion, including 5b, which produced no memory at all. Newest first:

soat list-memory-store-assertions --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {outcome, mechanism, principal_type, content}]'

Four rows for four writes: three created and the skipped from 5b, each mechanism: "api" because they came through the REST door. content is the text as asserted, so the skipped row holds what 5b tried to write, not what 5a stored.


Step 6 — List memories to verify​

Three memories remain; only 5b was discarded (Memories examples).

soat list-memories --memory-store-id "$MEMORY_STORE_ID" | jq '[.data[] | .content]'
# [
# "Alice prefers email over phone calls for all support communication",
# "Alice prefers email, especially for billing inquiries; she checks it twice a day",
# "The Alice Corp fiscal year ends in March; she starts renewal discussions in January"
# ]

Step 7 — Upload a support-policy document​

Store the support policy as a Document. The path /alice/support-policy.txt lets a single document_paths prefix select the /alice/ subtree.

DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--path "/alice/support-policy.txt" \
--content "Alice Corp Support Policy: All priority-1 incidents must receive an initial response within 2 hours. Priority-2 incidents within 8 hours. Refunds are approved automatically for outages exceeding 4 hours. Alice Corp is entitled to a dedicated support engineer during business hours (9 AM–6 PM EST)." \
| jq -r '.id')
echo "DOC_ID: $DOC_ID"

Step 8 — Create an agent with knowledge_config​

knowledge_config on an agent names the memory stores and documents searched before every generation, with the query derived from the last user message. Here it combines the memory store from Step 4 with the document from Step 7; write_memory_store_id gives the agent a write_memory tool.

AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Support Agent" \
--instructions "You are a helpful customer support assistant. Use the provided knowledge context to answer questions accurately and concisely. When you learn new facts about a customer, use the write_memory tool to persist them." \
--knowledge-config '{"memory_store_ids":["'"$MEMORY_STORE_ID"'"],"document_paths":["/alice/"],"limit":5,"write_memory_store_id":"'"$MEMORY_STORE_ID"'"}' \
| jq -r '.id')
echo "AGENT_ID: $AGENT_ID"

Step 9 — Run a generation​

A message needing both customer facts (memory) and the support policy (document). Matches are injected as a fenced reference-context user message, never as system content, since retrieved knowledge can be user-derived (Knowledge Config).

soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Alice has a P1 outage since 3 hours ago. How should we handle it and how do we best reach her?"}]' \
| jq '{status: .status, output: .output.content}'

Expected shape:

{
"status": "completed",
"output": "Since Alice has a P1 outage, an initial response should have been sent within 2 hours per the support policy ... Contact her by email, which she checks twice a day and prefers for all support communication ..."
}

The reply combines memory (email preference) and the document (2-hour P1 response); neither was in the user message.


Step 10 — Observe the agent writing to memory​

A write_memory call goes through exactly the same algorithm as a manual write, on the store's thresholds — an agent cannot loosen them. What differs is the assertion it records: mechanism: "tool", the agent as principal, and the generation it happened in. Send a message with a new fact:

soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Just so you know, Alice moved to the West Coast and is now in the PT timezone."}]' \
| jq '{status: .status, output: .output.content}'

List the store's memories and look for the timezone fact. A write_memory call happens inside a generation that may belong to no conversation, so what it writes carries source_type: "manual" — there is no source to name (Provenance):

soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {content, source_type, source_id}]'

If the model called write_memory, one memory holds the timezone fact, reading "source_type": "manual" with a null source_id. Where it came from is on its assertion instead — the door, the agent, and the turn:

soat list-memory-store-assertions --memory-store-id "$MEMORY_STORE_ID" --mechanism tool \
| jq '[.data[] | {outcome, principal_id, generation_id, content}]'

An empty list means the model did not call the tool; Step 11 removes that dependency.


Step 11 — Add a memory rule​

The write_memory tool is a capability grant: the agent may write, if it decides to. What the store accepts from a finished turn is the store's own policy — a memory rule. Create one on the store, selecting this agent's turns, with no handler so the built-in extractor runs:

MEMORY_RULE_ID=$(soat create-memory-rule \
--memory-store-id "$MEMORY_STORE_ID" \
--on generations.completed \
--source-agent-ids '["'"$AGENT_ID"'"]' \
| jq -r '.id')
echo "$MEMORY_RULE_ID"

With no agent_id or tool_id, the rule runs the built-in extractor on the source agent's provider and model, with a built-in prompt; prompt, ai_provider_id and model retune it (e.g. a cheaper model), and an agent_id or tool_id handler replaces the algorithm outright. Leaving source_agent_ids out would read every agent in the project.

Send a message revealing a new fact without asking the agent to remember it:

soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"By the way, Alice signed a 2-year contract renewal last week."}]' \
| jq '{status: .status}'

The rule fires asynchronously after the turn completes; wait a few seconds, then list:

sleep 5
soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {content, source_type, source_id}]'

Expect a memory like "Alice signed a 2-year contract renewal". This turn is a direct generation with no conversation behind it, so it reads "source_type": "manual"; the same turn inside a conversation would read "conversation" and name it in source_id. What the rule wrote is recorded on the generation's extraction field, keyed by the rule's id (Generations), and each write appends an assertion naming rule_id.


Step 12 — Query the knowledge layer directly​

The Knowledge endpoint is the search layer the agent uses. Pass memory_store_ids and document_paths to see which chunks would be injected for a question.

soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "P1 outage response and how to reach Alice" \
--memory-store-ids '["'"$MEMORY_STORE_ID"'"]' \
--document-paths '["/alice/"]' \
| jq '.results[] | {score, similarity_score, source_type, content}'

Expected output (two source_type values):

{ "score": 0.69, "similarity_score": 0.69, "source_type": "document", "content": "Alice Corp Support Policy: All priority-1 incidents must receive an initial response within 2 hours ..." }
{ "score": 0.62, "similarity_score": 0.62, "source_type": "memory", "content": "Alice prefers email, especially for billing inquiries; she checks it twice a day" }
{ "score": 0.0328, "similarity_score": 0.50, "source_type": "memory", "content": "The Alice Corp fiscal year ends in March; she starts renewal discussions in January" }

Two scores, two contracts (Relevance scoring):

  • score: the reciprocal-rank-fusion value results are ordered by. The ordering is the contract, the number is not — do not persist it or show it as a percentage.
  • similarity_score: raw cosine similarity, stable for comparing or logging, and the field min_similarity filters on.

They are different numbers: score encodes each result's position in the vector and lexical rankings, not how similar it is.


Step 13 — Trace a fact back to the conversation it came from​

A memory records whether there is a source to point at (provenance). Every write so far has been a direct generation with no conversation behind it, so all of them read manual with a null source_id. Write one that names a conversation and the pair becomes traceable.

CONVERSATION_ID=$(soat create-conversation \
--project_id "$PROJECT_ID" --name alice-renewal-call | jq -r '.id')

soat create-memory \
--memory-store-id "$MEMORY_STORE_ID" \
--content "Alice's account manager is Priya" \
--source_type conversation \
--source_id "$CONVERSATION_ID" \
| jq '{content, source_type, source_id}'
{
"content": "Alice's account manager is Priya",
"source_type": "conversation",
"source_id": "conv_0dR2mJk8xQ1vTbLp"
}

Read the whole store to see the two shapes side by side, then follow the id back:

soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq '[.data[] | {source_type, source_id}]'

SRC_ID=$(soat list-memories --memory-store-id "$MEMORY_STORE_ID" \
| jq -r '[.data[] | select(.source_type == "conversation")][0].source_id')

soat get-conversation --conversation-id "$SRC_ID" | jq '{id, name}'

A memory rule fills the same pair on its own: a turn inside a conversation writes conversation plus that conversation's id, and a bare generation writes manual, because there is nothing to name.

Provenance is set at creation and never rewritten. source_id is a loose pointer: deleting the conversation leaves the id in place, because the fact was still learned there. A fact that has changed is retired, not edited; --include-invalidated true on list-memories shows retired memories (Temporal invalidation), and list-memory-assertions on the replacement names the memory it retired.


What's next​

  • Tag-based filtering — one memory store per customer, tags on the agent.
  • Dedup policy — duplicate_threshold and supersede_threshold set how close a fact must be to be skipped or to retire the one it restates, per store or per write (Memories).
  • Audit what an agent was told — pair provenance ids with the injected <knowledge> block (Agents — Knowledge Config), whose source tags name the memory and document page behind each line.