Build an Intent-Driven NPC¶
Use a small labelled knowledge base and a
ClassifyIntent →
IntentToInstruction →
Generate pipeline to give an NPC
distinct, tone-appropriate replies for recognised player intents (insult,
compliment, joke, …) while falling back gracefully to a general response
for anything unclassified.
Prerequisites
- A session connected and configured with the
LlamaCppengine. - A language model and an embedding model available
(e.g.
"Llama 3.2 3B Instruct (Q4_K_M)"and"All-MiniLM-L6-v2 (Q4_K_M)"). Add an embedding model tomodels.jsonwithtype: "Embedding"if you do not have one yet.
Overview¶
flowchart LR
classify["ClassifyIntent\nclassify"]
pick["IntentToInstruction\npick"]
respond["Generate\nrespond"]
classify -- "found" --> pick
classify -- "not_found" --> respond
pick -- "default" --> respond
respond -- "default" --> END
The ClassifyIntent node embeds the user's message, searches a small KB
of labelled example phrases (5–10 per intent is enough), and attaches an
IntentionComponent on "found". IntentToInstruction looks up that
label in a Map string storage to retrieve a tone directive, attaches it
as an InstructionComponent, and then Generate renders the directive
above the user message via Mustache. If the intent is unknown
("not_found"), Generate runs directly with no directive — the NPC
answers naturally without any override.
Step 1 — prepare the intent knowledge base¶
Create two files alongside the server.
guard_intents.kb.json — labelled example phrases (5 per intent):
[
{ "id": "insult_1", "text": "You are an absolute idiot, move aside.", "metadata": { "intent": "insult" } },
{ "id": "insult_2", "text": "Out of my way, you worthless fool.", "metadata": { "intent": "insult" } },
{ "id": "insult_3", "text": "What a pathetic excuse for a guard.", "metadata": { "intent": "insult" } },
{ "id": "insult_4", "text": "You are dumber than a bag of rocks.", "metadata": { "intent": "insult" } },
{ "id": "insult_5", "text": "Move, you incompetent buffoon.", "metadata": { "intent": "insult" } },
{ "id": "pleasing_1", "text": "Good evening, kind sir, may I please pass?", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_2", "text": "I humbly request entry into the city, good guard.", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_3", "text": "You look like a fine guardian. Might you let me through?", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_4", "text": "Thank you for your service. Could I trouble you for passage?", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_5", "text": "I appreciate your diligence. Would it be possible to enter?", "metadata": { "intent": "pleasing" } },
{ "id": "joking_1", "text": "Why did the orc cross the road? To get to the other fortress!", "metadata": { "intent": "joking" } },
{ "id": "joking_2", "text": "A paladin, a rogue, and a wizard walk into a tavern… ouch.", "metadata": { "intent": "joking" } },
{ "id": "joking_3", "text": "How many guards does it take to open a gate?", "metadata": { "intent": "joking" } },
{ "id": "joking_4", "text": "Knock knock. Who's there? A very impatient traveller.", "metadata": { "intent": "joking" } },
{ "id": "joking_5", "text": "I've got a joke about a magic door but I'm not sure it will open.", "metadata": { "intent": "joking" } }
]
guard_intents.json — the config pointing at it:
{
"version": 1,
"embedding_model": "All-MiniLM-L6-v2 (Q4_K_M)",
"records_file": "guard_intents.kb.json",
"index_file": "guard_intents.usearch",
"fields": [
{ "name": "intent", "type": "string", "default": "" }
]
}
The fields array is required — ClassifyIntent validates that the
field referenced by metadata_field exists and is string-typed. On
first run the server embeds every record and writes guard_intents.usearch
to disk; subsequent runs skip the embedding pass.
Step 2 — create the embedded string storage¶
Step 3 — create the instruction map¶
The instruction map is a Map-kind string storage: keys are intent
labels, values are tone directives fed to the model as invisible
instructions.
client.create_keyed_string_storage(
name="guard_instructions",
keys= ["insult", "pleasing", "joking"],
values=[
"The traveller has insulted you. Respond curtly and insult them back — short and sharp.",
"The traveller is being polite. Stay grumpy but show grudging satisfaction. One sentence.",
'The traveller told a joke. Permit yourself one brief laugh (e.g. "Ha.") then immediately refocus on duty.',
],
)
Map-kind SDK support
create_keyed_string_storage is currently only available on the
Python client. C++ and Unity/Unreal support for Map kind is planned;
see the String Storage reference.
Step 4 — build the graph¶
The Generate node's Mustache template renders the tone directive (if
found) as a hidden system-style line above the user message. When no
intent was matched the {{#instructions}} block is empty and the model
answers freely.
from tryll_client.graph import (
GraphDescription, ClassifyIntentParams, IntentToInstructionParams,
GenerateParams, Placement,
)
SYSTEM_PROMPT = (
"You are roleplaying as Aldric, a grumpy gate guard. "
"Stay in character at all times. Use short sentences."
)
TEMPLATE = (
"{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}"
"Traveller says: {{user_message}}"
)
graph = (
GraphDescription()
.add_node("classify", ClassifyIntentParams(
embedded_string_storage="guard_intent_kb",
embedding_model="All-MiniLM-L6-v2 (Q4_K_M)",
metadata_field="intent",
threshold=0.6,
found_exit="pick",
not_found_exit="respond",
))
.add_node("pick", IntentToInstructionParams(
string_storage="guard_instructions",
instruction_name="tone",
default_exit="respond",
))
.add_node("respond", GenerateParams(
system_prompt=SYSTEM_PROMPT,
template=TEMPLATE,
placement=Placement.InPlaceOfUser,
# default_exit is "" (END) by default.
))
.set_start_node("classify")
.set_default_model_name("Llama 3.2 3B Instruct (Q4_K_M)")
)
agent = client.create_agent(graph)
using namespace Tryll::Client;
using namespace Tryll::NodeParams;
constexpr const char* kSystemPrompt =
"You are roleplaying as Aldric, a grumpy gate guard. "
"Stay in character at all times. Use short sentences.";
constexpr const char* kTemplate =
"{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}"
"Traveller says: {{user_message}}";
ClassifyIntentParamsT classifyParams;
classifyParams.embedded_string_storage = "guard_intent_kb";
classifyParams.embedding_model = "All-MiniLM-L6-v2 (Q4_K_M)";
classifyParams.metadata_field = "intent";
classifyParams.threshold = 0.6f;
classifyParams.found_exit = "pick";
classifyParams.not_found_exit = "respond";
IntentToInstructionParamsT pickParams;
pickParams.string_storage = "guard_instructions";
pickParams.instruction_name = "tone";
pickParams.default_exit = "respond";
GenerateParamsT respondParams;
respondParams.system_prompt = kSystemPrompt;
respondParams.template_ = kTemplate;
respondParams.placement = Placement::InPlaceOfUser;
// respondParams.default_exit is "" (END) by default.
GraphDescription graph;
graph.AddClassifyIntent("classify", std::move(classifyParams))
.AddIntentToInstruction("pick", std::move(pickParams))
.AddGenerate("respond", std::move(respondParams))
.SetStartNode("classify")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)");
auto agent = client.CreateAgent(graph);
const string systemPrompt =
"You are roleplaying as Aldric, a grumpy gate guard. " +
"Stay in character at all times. Use short sentences.";
const string template =
"{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}" +
"Traveller says: {{user_message}}";
var graph = new TryllGraphBuilder()
.AddClassifyIntent("classify", new TryllClassifyIntentParams
{
EmbeddedStringStorage = "guard_intent_kb",
EmbeddingModel = "All-MiniLM-L6-v2 (Q4_K_M)",
MetadataField = "intent",
Threshold = 0.6f,
FoundExit = "pick",
NotFoundExit = "respond",
})
.AddIntentToInstruction("pick", new TryllIntentToInstructionParams
{
StringStorage = "guard_instructions",
InstructionName = "tone",
DefaultExit = "respond",
})
.AddGenerate("respond", new TryllGenerateParams
{
SystemPrompt = systemPrompt,
Template = template,
Placement = TryllPlacement.InPlaceOfUser,
// DefaultExit is "" (END) by default.
})
.SetStartNode("classify")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)")
.Build();
var (agent, error) = await TryllClient.Instance.RequestCreateAgentAsync(graph);
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
UTryllClassifyIntentParams* ClassifyP = UTryllNodeParamsFactory::MakeClassifyIntentParams(this);
ClassifyP->EmbeddedStringStorage = TEXT("guard_intent_kb");
ClassifyP->EmbeddingModel = TEXT("All-MiniLM-L6-v2 (Q4_K_M)");
ClassifyP->MetadataField = TEXT("intent");
ClassifyP->Threshold = 0.6f;
ClassifyP->FoundExit = TEXT("pick");
ClassifyP->NotFoundExit = TEXT("respond");
UTryllIntentToInstructionParams* PickP = UTryllNodeParamsFactory::MakeIntentToInstructionParams(this);
PickP->StringStorage = TEXT("guard_instructions");
PickP->InstructionName = TEXT("tone");
PickP->DefaultExit = TEXT("respond");
UTryllGenerateParams* RespondP = UTryllNodeParamsFactory::MakeGenerateParams(this);
RespondP->bOverrideSystemPrompt = true;
RespondP->SystemPrompt = TEXT("You are roleplaying as Aldric, a grumpy gate guard. Stay in character at all times. Use short sentences.");
RespondP->bOverrideTemplate = true;
RespondP->Template = TEXT("{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}Traveller says: {{user_message}}");
RespondP->Placement = ETryllPlacement::InPlaceOfUser;
// RespondP->DefaultExit is TEXT("") (END) by default.
FTryllGraphDescription Graph = FTryllGraphBuilder()
.AddNode(TEXT("classify"), ClassifyP)
.AddNode(TEXT("pick"), PickP)
.AddNode(TEXT("respond"), RespondP)
.SetStartNode(TEXT("classify"))
.SetDefaultModelName(TEXT("Llama 3.2 3B Instruct (Q4_K_M)"))
.Build();
Step 5 — send messages and observe intents¶
# insult → found → pick → respond (with insult directive)
print(agent.send_message("Move aside, you dull-witted oaf."))
# pleasing → found → pick → respond (with polite directive)
print(agent.send_message("Good day to you, honourable guard."))
# joking → found → pick → respond (with joke directive)
print(agent.send_message("Why did the troll apply to be a guard? He heard it was rocky!"))
# neutral → not_found → respond (general answer, no directive)
print(agent.send_message("How far is it to the next town?"))
Verify it worked¶
With enable_diagnostics = True on the agent, each
TurnComplete.debug_info JSON will include:
{
"classify.result.intent": "insult",
"classify.raw_distance": "0.21",
"classify.threshold": "0.6",
"pick.intent": "insult",
"pick.found": "true",
"pick.result_text": "The traveller has insulted you. Respond curtly..."
}
For a neutral message (not_found path):
Server logs at info show:
[info] Node classify: found intent=insult distance=0.21
[info] Node pick: default found=true
[info] Node respond: default
Common pitfalls¶
- Threshold too tight. Start with
0.6. If clearly-labelled phrases fall through tonot_found, widen to0.7and add more diverse examples to the KB. - Too few examples per intent. Five phrases per intent is a minimum. More variation (formal/informal, long/short) improves classification.
- Embedding model mismatch. The
embedding_modelparam on theClassifyIntentnode must match the model name declared in the KB config'sembedding_modelfield. - Wrong metadata field type.
ClassifyIntentrejects fields that are notstring-typed at agent-creation time. Check yourfieldsarray in the config. not_foundpath not wired. Bothfoundandnot_foundmust be wired — an unwired exit is a graph compilation error.- Instruction never rendered. Ensure the
Generatenode's template contains{{#instructions}}…{{/instructions}}or{{instruction_tone}}. Without it, the instruction is attached but never inserted into the prompt.
Next steps¶
- Add more intents — extend the KB with new example phrases and add a matching key/value pair to the instruction map.
- Tune threshold at runtime — use
ChangeParamto adjustclassify.thresholdwhile the agent is running. - Filter by metadata — combine
metadata_fieldclassification with afilterparam to restrict which records compete during search (e.g. filter to a specific NPC or scene).