Skip to content

Build an Intent-Driven NPC

Use a small labelled knowledge base and a ClassifyIntentIntentToInstructionGenerate 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 LlamaCpp engine.
  • 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 to models.json with type: "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

info = client.create_embedded_string_storage(
    name="guard_intent_kb",
    config_path="data/guard_intents.json",
)
print(f"{info.record_count} records, {info.embedding_dim} dims")
auto info = client.CreateEmbeddedStringStorage(
    "guard_intent_kb",
    "data/guard_intents.json");

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 is not yet exposed on the C++ client SDK.
// Use the Python client or the raw wire API to create Map storages.

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):

{
  "classify.result.intent": "",
  "classify.raw_distance":  ""
}

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 to not_found, widen to 0.7 and 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_model param on the ClassifyIntent node must match the model name declared in the KB config's embedding_model field.
  • Wrong metadata field type. ClassifyIntent rejects fields that are not string-typed at agent-creation time. Check your fields array in the config.
  • not_found path not wired. Both found and not_found must be wired — an unwired exit is a graph compilation error.
  • Instruction never rendered. Ensure the Generate node'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 ChangeParam to adjust classify.threshold while the agent is running.
  • Filter by metadata — combine metadata_field classification with a filter param to restrict which records compete during search (e.g. filter to a specific NPC or scene).