Give an NPC a Memory It Can Search¶
Let a character store what the player tells it and find it again on a later
turn, even after the game is saved and loaded. The graph searches the
character's memory with a Retrieve node,
replies, and then stores the player's line with a
WriteToMemory node. All four clients
can build this graph and save the store. The editor Knowledge window is
Unity and Unreal only.
Experimental
Writable memory is experimental (protocol v20). The node parameters, the store API, and the TMKB save format may change between releases.
Prerequisites
- A connected session with
CreateSessionalready called. See Connect and Manage a Session. - A language model and the embedding model on disk, for example
"Llama 3.2 3B Instruct (Q4_K_M)"and"All-MiniLM-L6-v2 (Q4_K_M)". Models are never downloaded for you. Acquire both first, with the Model Manager or as described in Use Your Own Local Model. A missing model fails agent creation.
The pattern¶
flowchart LR
memory["Retrieve<br>memory<br>(From Name)"]
reply["Generate<br>reply"]
remember["WriteToMemory<br>remember"]
memory -- "found / not_found" --> reply
reply -- "default" --> remember
remember -- "written / failed" --> END
memory searches the store for lines that match the player's message.
reply sees those lines in its prompt and answers. remember then stores the
player's message, so a later turn can find it. Both memory nodes name the same
store and the same schema.
Steps¶
1. Pick a bundled schema¶
A schema sets the store's embedding model and how many records it can hold.
The server reads the catalog from data/schemas.json. It ships three schemas:
| Schema | Embedding model | max_records |
|---|---|---|
npc_memory |
All-MiniLM-L6-v2 (Q4_K_M) |
256 |
player_log |
All-MiniLM-L6-v2 (Q4_K_M) |
512 |
shared_notes |
All-MiniLM-L6-v2 (Q4_K_M) |
256 |
The three differ only in their record cap. Pick the name that says what the
store is for. This guide uses npc_memory for one character's memory.
Pick a store name too. One store per character works well, for example
npc.memory.nessa. A name may use letters, digits, _, . and -, up to
128 characters. storage_name may include agent variables (var.*), which
resolve once, when the agent is created.
In the Unity Inspector, Schema is a dropdown filled from the bundled catalog. An unknown value shows as Unregistered. The server has the final say either way.
2. Retrieve before the reply¶
Add a Retrieve node as the start node. Set source_kind to FromName, then
set storage_name and schema. Leave embedded_string_storage empty: it is
for prebuilt knowledge files only, and setting it with FromName fails agent
creation.
Route both found and not_found to the reply. On the first turn the store
is empty, so not_found is the normal path.
The reply reads the hits through its Mustache template. With source left
empty, the section is named after the node, so a node named memory fills
{{#knowledge_memory}}. See Use Mustache Templates.
3. Write to memory in the same turn¶
Add a WriteToMemory node after the reply, on the same storage_name and
schema. Two nodes that name one store with different schemas fail agent
creation.
input. Leave it blank to store the player's message. Set it to an earlier node's output name to store that text instead, for example a hiddenGeneratethat rewrites the line as a standalone fact. The node writes nothing only when the resolved text is empty; it still takeswritten.id. Leave it blank to add a new record on every write. Set it to store one record under a fixed id, such asplayer_name; writing that id again replaces the record.idmay include agent variables, which resolve on each turn.failed. The node takesfailedwhen the store rejects the write (store full or schema mismatch) or theidtemplate fails. Leave it empty to end the turn, or route it to a node that logs or reacts.
The agent creates the store when it is created, if the store does not exist
yet. Problems with the store are never an exit: an empty schema, an unknown
schema, a bad store name, or a store that already exists under another schema
fails agent creation, and the error message names the node.
using Tryll.Client;
const string Store = "npc.memory.nessa";
const string Schema = "npc_memory";
var graph = new TryllGraphBuilder()
.AddRetrieve("memory", new TryllRetrieveParams
{
SourceKind = TryllKnowledgeSourceKind.FromName,
StorageName = Store,
Schema = Schema,
TopK = 3,
Threshold = 0.7f,
FoundExit = "reply",
NotFoundExit = "reply",
})
.AddGenerate("reply", new TryllGenerateParams
{
SystemPrompt = "You are Nessa, the innkeeper. Use what you remember about the player.",
Template = "What you remember:\n{{#knowledge_memory}}- {{text}}\n{{/knowledge_memory}}",
DefaultExit = "remember",
})
.AddWriteToMemory("remember", new TryllWriteToMemoryParams
{
StorageName = Store,
Schema = Schema,
// Input left empty: store the player's message.
// WrittenExit and FailedExit left empty: end the turn.
})
.SetStartNode("memory")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)")
.Build();
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
const FString Store = TEXT("npc.memory.nessa");
const FString Schema = TEXT("npc_memory");
UTryllRetrieveParams* MemoryP = UTryllNodeParamsFactory::MakeRetrieveParams(this);
MemoryP->SourceKind = ETryllKnowledgeSourceKind::FromName;
MemoryP->StorageName = Store;
MemoryP->Schema = Schema;
MemoryP->TopK = 3;
MemoryP->Threshold = 0.7f;
MemoryP->FoundExit = TEXT("reply");
MemoryP->NotFoundExit = TEXT("reply");
UTryllGenerateParams* ReplyP = UTryllNodeParamsFactory::MakeGenerateParams(this);
ReplyP->bOverrideSystemPrompt = true;
ReplyP->SystemPrompt = TEXT("You are Nessa, the innkeeper. Use what you remember about the player.");
ReplyP->bOverrideTemplate = true;
ReplyP->Template = TEXT("What you remember:\n{{#knowledge_memory}}- {{text}}\n{{/knowledge_memory}}");
ReplyP->DefaultExit = TEXT("remember");
UTryllWriteToMemoryParams* RememberP = UTryllNodeParamsFactory::MakeWriteToMemoryParams(this);
RememberP->StorageName = Store;
RememberP->Schema = Schema;
// Input left empty: store the player's message.
FTryllGraphBuilder Builder;
AddRetrieveNode(Builder, TEXT("memory"), MemoryP);
AddGenerateNode(Builder, TEXT("reply"), ReplyP);
AddWriteToMemoryNode(Builder, TEXT("remember"), RememberP);
FTryllGraphDescription Graph = Builder
.SetStartNode(TEXT("memory"))
.SetDefaultModelName(TEXT("Llama 3.2 3B Instruct (Q4_K_M)"))
.Build();
#include <tryll/TryllClient.h>
using namespace Tryll::Client;
using namespace Tryll::NodeParams;
const std::string store = "npc.memory.nessa";
const std::string schema = "npc_memory";
RetrieveParamsT memoryP;
memoryP.source_kind = ::Tryll::KnowledgeSourceKind_FromName;
memoryP.storage_name = store;
memoryP.schema = schema;
memoryP.top_k = 3;
memoryP.threshold = 0.7f;
memoryP.found_exit = "reply";
memoryP.not_found_exit = "reply";
GenerateParamsT replyP;
replyP.system_prompt = "You are Nessa, the innkeeper. Use what you remember about the player.";
replyP.template_ = "What you remember:\n{{#knowledge_memory}}- {{text}}\n{{/knowledge_memory}}";
replyP.default_exit = "remember";
WriteToMemoryParamsT rememberP;
rememberP.storage_name = store;
rememberP.schema = schema;
// input left empty: store the player's message.
GraphDescription graph;
graph.AddRetrieve("memory", std::move(memoryP))
.AddGenerate("reply", std::move(replyP))
.AddWriteToMemory("remember", std::move(rememberP))
.SetStartNode("memory")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)");
auto agent = client.CreateAgent(graph);
from tryll_client.graph import (
GraphDescription, GenerateParams, KnowledgeSourceKind,
RetrieveParams, WriteToMemoryParams,
)
STORE, SCHEMA = "npc.memory.nessa", "npc_memory"
graph = (
GraphDescription()
.add_node("memory", RetrieveParams(
source_kind=KnowledgeSourceKind.FromName,
storage_name=STORE, schema=SCHEMA,
top_k=3, threshold=0.7,
found_exit="reply", not_found_exit="reply",
))
.add_node("reply", GenerateParams(
system_prompt="You are Nessa, the innkeeper. Use what you remember about the player.",
template="What you remember:\n{{#knowledge_memory}}- {{text}}\n{{/knowledge_memory}}",
default_exit="remember",
))
.add_node("remember", WriteToMemoryParams(
storage_name=STORE, schema=SCHEMA,
# input left empty: store the player's message.
))
.set_start_node("memory")
.set_default_model_name("Llama 3.2 3B Instruct (Q4_K_M)")
)
agent = client.create_agent(graph)
4. Read it back on a later turn¶
A write never refreshes knowledge that was already put into the prompt that
turn. memory runs before remember, so the line the player says now is
found from the next turn on.
- Send a line with a fact, for example "My name is Arin and I am scared of
spiders."
memoryfinds nothing, andrememberstores the line. - Send a question about it, for example "What am I afraid of?"
memoryfinds the stored line, andreplysees it under What you remember.
The chat history can also hold the answer while the conversation is short. To prove the answer comes from memory, create a second agent with the same graph in the same session. It has no history, but it opens the same store, because a store lasts for the session. Ask it the question.
If the line is not found, lower the bar: raise threshold (a larger cosine
distance counts as a match) or raise top_k. Check what the store returns for
a phrase in the editor (step 6) before you change the prompt.
5. Save and load the store¶
The store lives only as long as the session. To keep it across a game save,
write every store in the session to one TMKB file with SaveGameMemory, and
read it back with LoadGameMemory.
- Every agent must be idle for both calls: no turn running, not paused, no
KV-cache operation. Otherwise the call fails with
8101 StorageBusy. - A relative path joins the server's save root. See Server Configuration → Saves.
LoadGameMemoryneeds an embed timing.Lazyloads the text now and builds the vectors on the first search.Nowbuilds every vector during the load.- Load replaces the records of each store in the file. A live store that is not in the file stays, and the result carries a warning.
- On load, recreate the agents first, then call
LoadGameMemory. The agents see the loaded records on their next turn.
Save the dialog too, with the same save id, so a mismatched pair warns on load. See Save and Load Agent State.
var client = TryllClient.Instance;
var (saved, saveError) = await client.SaveGameMemoryAsync("memory/slot-1.tmkb", "slot-1");
if (!saveError.IsOk) Debug.LogError(saveError.Message);
// Later, in a new session, after the agents are recreated:
var (loaded, loadError) = await client.LoadGameMemoryAsync(
"memory/slot-1.tmkb", TryllMutableKbEmbedTiming.Lazy);
if (!loadError.IsOk) Debug.LogError(loadError.Message);
else foreach (var warning in loaded.Warnings) Debug.LogWarning(warning);
UTryllSubsystem* Subsystem = GetGameInstance()->GetSubsystem<UTryllSubsystem>();
Subsystem->RequestSaveGameMemory(TEXT("memory/slot-1.tmkb"), TEXT("slot-1"),
[](FTryllGameMemorySaveResult Result, FTryllError Error)
{
if (!Error.IsOk()) UE_LOG(LogTemp, Error, TEXT("%s"), *Error.Message);
});
// Later, in a new session, after the agents are recreated:
Subsystem->RequestLoadGameMemory(TEXT("memory/slot-1.tmkb"),
ETryllMutableKbEmbedTiming::Lazy, ETryllMutableKbCompatibility::Strict,
[](FTryllGameMemoryLoadResult Result, FTryllError Error)
{
if (!Error.IsOk()) UE_LOG(LogTemp, Error, TEXT("%s"), *Error.Message);
for (const FString& Warning : Result.Warnings)
UE_LOG(LogTemp, Warning, TEXT("%s"), *Warning);
});
from tryll_client.mkb_snapshot import MutableKbEmbedTiming
client.save_game_memory("memory/slot-1.tmkb", "slot-1")
# Later, in a new session, after the agents are recreated:
result = client.load_game_memory("memory/slot-1.tmkb", MutableKbEmbedTiming.Lazy)
for warning in result.warnings:
print(warning)
6. Check it in the editor¶
Run the workflow in the editor Chat window, then open the Knowledge window: Window → Tryll → Knowledge in Unity, or Window → Tryll → Tryll Knowledge in Unreal. It shows the stores of the shared editor session, the one Chat uses. A game build or a Python client has its own session, so its stores do not appear there.
- Click Refresh. The window never polls.
- Select
npc.memory.nessa. The summary line shows the schema, the live record count, and the write generation. A highergenmeans something wrote to the store. - Switch to Query, type a phrase the player might say, and click
Run query. The hits are the same kind of search
Retrieveruns, best first, with their distance. - Use Save game memory and Load game memory to test the same file your game writes.
See Inspect and Edit Knowledge Stores for every field in the window.
From C++ or Python, query the store directly instead:
Limits of this experimental store¶
- It is capped. Each schema has a
max_recordscap. When the store is full, a new record is rejected andWriteToMemorytakesfailed. Nothing is evicted for you. Replacing an existingidstill works. - It stores text only. A record is an id and a line of text. The node writes no metadata.
- The embedding model is fixed by the schema. All three bundled schemas use
All-MiniLM-L6-v2 (Q4_K_M). You cannot pick another model per node. - It lasts for the session. Disconnecting drops every store unless you saved it. Stores are per session, so a game build and the editor never share one.
- A save holds text, not vectors. Loading re-embeds every record, either
during the load (
Now) or on the first search (Lazy). Budget for that time on a loading screen. - New lines appear on the next turn. A write does not refresh knowledge already projected in the current turn.
Common pitfalls¶
- Different schemas on one store.
RetrieveandWriteToMemorymust name the sameschema. A mismatch fails agent creation. A save from another schema failsLoadGameMemorywith8102 SchemaMismatch. embedded_string_storageset withFromName. Leave it empty. It is for prebuilt knowledge files.- Writing before retrieving. If
rememberruns first, the reply still does not see the new line this turn. KeepRetrievefirst. - Load fails with
StorageBusy. Wait until every agent is idle. Finish or cancel open turns, and resume or cancel paused ones.