Score Several Attributes of One Line¶
Label one player line on several axes in a single call: what it is about,
whether it is a threat, and how polite it is. The
ClassifyAttributesLLM node
asks each question with its own prompt, scores them all in one batched pass,
and writes one slot per question. All four clients can build this graph.
Python has a typed callback for the result; Unity, Unreal, and C++ read it
from the generic node event.
Prerequisites
- A connected session with
CreateSessionalready called. See Connect and Manage a Session. - A language model on disk, for example
"Llama 3.2 3B Instruct (Q4_K_M)". Models are never downloaded for you. Acquire it first, with the Model Manager or as described in Use Your Own Local Model.
The pattern¶
flowchart LR
read["ClassifyAttributesLLM<br>read"]
react["Branch<br>react"]
backoff["CannedResponse<br>back_off"]
deflect["CannedResponse<br>deflect"]
reply["Generate<br>reply"]
read -- "triggered" --> react
read -- "default" --> reply
react -- "then" --> backoff
react -- "else" --> deflect
backoff -- "default" --> END
deflect -- "default" --> END
reply -- "default" --> END
read asks three questions about the player's line:
| Attribute | Kind | Writes | Fires triggered when |
|---|---|---|---|
topic |
Choice | world or meta |
the answer is meta |
threat |
Boolean | true or false |
the answer is true |
politeness |
Score | 0.0000 to 1.0000 |
never |
If any attribute fires, read takes triggered, and react checks which one
it was. Otherwise reply answers and reads the politeness score in its
prompt.
Steps¶
1. Use one batched node instead of a chain¶
Three ClassifyIntentLLM nodes in a row would each run the model in turn.
ClassifyAttributesLLM puts every question on its own cached sequence and
scores them together. From two attributes up, it is faster than the chain.
Each attribute keeps its own prompt. Do not merge the questions into one
prompt: on every model tested, the second question collapsed to a constant
answer. An existing ClassifyIntentLLM prompt works unchanged as a Choice
attribute.
A node takes 1 to 16 attributes. Attribute names use letters, digits and
_, must not start with a digit, and must be unique in the node.
2. Pick Choice, Boolean, or Score¶
| Kind | Use it for | You write | Slot value |
|---|---|---|---|
| Choice | "Which of these?" | 2 to 26 options, each an id and a prompt |
The winning id, or uncertain_choice when the winner is too weak |
| Boolean | "Is this true?" | One statement |
true when P(True) is at least threshold, else false |
| Score | "How much, on this ladder?" | 2 to 26 levels, lowest first | The expected level scaled to 0..1, four decimals |
Rules the server checks when the agent is created:
- Choice needs
uncertain_choice: one of its own ids, or#uncertain. Mark the option that should firetriggeredwithtriggers. - Boolean takes no
choices. Setboolean_triggertoTrueorFalseto make it fire, or leave itNever. - Score takes no
triggerson its levels. Setscore_triggertoAtOrAboveorBelowwith ascore_thresholdto make it fire, or leave itNever. - Set
triggered_exitexactly when at least one attribute can fire. Atriggered_exitwith nothing that can fire, or the reverse, fails agent creation.
A Score of 0.7 means 70% of the way from the lowest level to the highest. It is not the probability of the top level.
Use Boolean only for real yes/no questions. Splitting a many-way question into several Booleans classified worse than one Choice.
3. Write each specialist prompt¶
Leave system_prompt empty to use the built-in prompt for the attribute's
kind. To write your own, start from the default for that kind:
You are a classifier. Pick the best label for the user's LATEST message.
Labels:
{{intents_block}}
Respond with a single letter. No other text.
- A Choice or Score prompt must include
{{intents_block}}. The node renders it as a lettered menu:A. <prompt>,B. <prompt>, and so on. - A Boolean prompt must include both
{{statement}}and{{intents_block}}. The menu is fixed:A. True,B. False. - The model never sees a choice
id. Put the meaning in the optionprompt.
The node reads only the first letter the model would write. Instructions such as "explain your reasoning" cost prefill and change nothing. For wording, ordering, and thresholds, follow Design an Intent-Classifier Prompt; every rule there applies to one attribute.
history_turns is 0 by default, so each question sees only the current line.
4. Take the triggered exit¶
Wire triggered_exit to what should happen when any attribute fires, and
default_exit to the normal reply. Two more cases route the turn:
- Empty input never fires. The slots get their fallback values (Choice
uncertain_choice, Booleanfalse, Score0.0000) and the node takesdefault. - Scoring fails. The slots get the same fallbacks, and the node follows
on_failure:Defaultfails open,Triggeredfails closed. UseTriggeredfor a guard.
using Tryll.Client;
var read = new TryllClassifyAttributesLLMParams
{
OutputName = "line",
NotifyClient = TryllNotifyClient.Always,
TriggeredExit = "react",
DefaultExit = "reply",
Attributes = new[]
{
new TryllClassifyAttributeDef
{
Name = "topic",
Kind = TryllAttributeKind.Choice,
SystemPrompt =
"You classify what a player says to a medieval innkeeper.\n\n" +
"Labels:\n{{intents_block}}\n\nRespond with a single letter. No other text.",
Choices = new[]
{
new TryllClassifyAttributeChoice { Id = "world", Prompt = "talk about the story, the town, or the people in it" },
new TryllClassifyAttributeChoice { Id = "meta", Prompt = "talk about the game, the AI, or real-world software", Triggers = true },
},
UncertainChoice = "world",
},
new TryllClassifyAttributeDef
{
Name = "threat",
Kind = TryllAttributeKind.Boolean,
Statement = "The player threatens to hurt someone.",
BooleanTrigger = TryllBooleanTrigger.True,
},
new TryllClassifyAttributeDef
{
Name = "politeness",
Kind = TryllAttributeKind.Score,
Choices = new[] // lowest level first
{
new TryllClassifyAttributeChoice { Id = "rude", Prompt = "rude or insulting" },
new TryllClassifyAttributeChoice { Id = "neutral", Prompt = "neutral" },
new TryllClassifyAttributeChoice { Id = "polite", Prompt = "polite and friendly" },
},
},
},
};
var graph = new TryllGraphBuilder()
.AddClassifyAttributesLLM("read", read)
.AddBranch("react", new TryllBranchParams
{
Test = TryllBranchTest.Equals,
Input = "line_threat",
Values = new[] { "true" },
ThenExit = "back_off",
ElseExit = "deflect",
})
.AddCannedResponse("back_off", new TryllCannedResponseParams
{
Lines = new[] { new TryllLocalizableText { Text = "Easy, friend. Put that away." } },
})
.AddCannedResponse("deflect", new TryllCannedResponseParams
{
Lines = new[] { new TryllLocalizableText { Text = "You talk strange, traveller. Ale?" } },
})
.AddGenerate("reply", new TryllGenerateParams
{
SystemPrompt = "You are Nessa, the innkeeper.",
Template = "How polite the player is, from 0 to 1: {{slot.line_politeness}}. Match their tone.",
})
.SetStartNode("read")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)")
.Build();
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
auto MakeChoice = [](const TCHAR* Id, const TCHAR* Prompt, bool bTriggers = false)
{
FTryllClassifyAttributeChoice Choice;
Choice.Id = Id;
Choice.Prompt = Prompt;
Choice.bTriggers = bTriggers;
return Choice;
};
FTryllClassifyAttributeDef Topic;
Topic.Name = TEXT("topic");
Topic.Kind = ETryllAttributeKind::Choice;
Topic.bOverrideSystemPrompt = true;
Topic.SystemPrompt = TEXT("You classify what a player says to a medieval innkeeper.\n\n"
"Labels:\n{{intents_block}}\n\nRespond with a single letter. No other text.");
Topic.Choices = {
MakeChoice(TEXT("world"), TEXT("talk about the story, the town, or the people in it")),
MakeChoice(TEXT("meta"), TEXT("talk about the game, the AI, or real-world software"), true),
};
Topic.UncertainChoice = TEXT("world");
FTryllClassifyAttributeDef Threat;
Threat.Name = TEXT("threat");
Threat.Kind = ETryllAttributeKind::Boolean;
Threat.Statement = TEXT("The player threatens to hurt someone.");
Threat.BooleanTrigger = ETryllBooleanTrigger::True_;
FTryllClassifyAttributeDef Politeness;
Politeness.Name = TEXT("politeness");
Politeness.Kind = ETryllAttributeKind::Score;
Politeness.Choices = { // lowest level first
MakeChoice(TEXT("rude"), TEXT("rude or insulting")),
MakeChoice(TEXT("neutral"), TEXT("neutral")),
MakeChoice(TEXT("polite"), TEXT("polite and friendly")),
};
UTryllClassifyAttributesLLMParams* ReadP = UTryllNodeParamsFactory::MakeClassifyAttributesLLMParams(this);
ReadP->bOverrideOutputName = true;
ReadP->OutputName = TEXT("line");
ReadP->NotifyClient = ETryllNotifyClient::Always;
ReadP->TriggeredExit = TEXT("react");
ReadP->DefaultExit = TEXT("reply");
ReadP->Attributes = { Topic, Threat, Politeness };
UTryllBranchParams* ReactP = UTryllNodeParamsFactory::MakeBranchParams(this);
ReactP->Test = ETryllBranchTest::Equals;
ReactP->bOverrideInput = true;
ReactP->Input = TEXT("line_threat");
ReactP->Values = { TEXT("true") };
ReactP->ThenExit = TEXT("back_off");
ReactP->ElseExit = TEXT("deflect");
UTryllCannedResponseParams* BackOffP = UTryllNodeParamsFactory::MakeCannedResponseParams(this);
FTryllLocalizableText BackOffLine;
BackOffLine.Text = TEXT("Easy, friend. Put that away.");
BackOffP->Lines = { BackOffLine };
UTryllCannedResponseParams* DeflectP = UTryllNodeParamsFactory::MakeCannedResponseParams(this);
FTryllLocalizableText DeflectLine;
DeflectLine.Text = TEXT("You talk strange, traveller. Ale?");
DeflectP->Lines = { DeflectLine };
UTryllGenerateParams* ReplyP = UTryllNodeParamsFactory::MakeGenerateParams(this);
ReplyP->bOverrideSystemPrompt = true;
ReplyP->SystemPrompt = TEXT("You are Nessa, the innkeeper.");
ReplyP->bOverrideTemplate = true;
ReplyP->Template = TEXT("How polite the player is, from 0 to 1: {{slot.line_politeness}}. Match their tone.");
FTryllGraphBuilder Builder;
AddClassifyAttributesLLMNode(Builder, TEXT("read"), ReadP);
AddBranchNode(Builder, TEXT("react"), ReactP);
AddCannedResponseNode(Builder, TEXT("back_off"), BackOffP);
AddCannedResponseNode(Builder, TEXT("deflect"), DeflectP);
AddGenerateNode(Builder, TEXT("reply"), ReplyP);
FTryllGraphDescription Graph = Builder
.SetStartNode(TEXT("read"))
.SetDefaultModelName(TEXT("Llama 3.2 3B Instruct (Q4_K_M)"))
.Build();
#include <tryll/TryllClient.h>
using namespace Tryll::Client;
using namespace Tryll::NodeParams;
auto choice = [](std::string id, std::string prompt, bool triggers = false)
{
auto c = std::make_unique<ClassifyAttributeChoiceT>();
c->id = std::move(id);
c->prompt = std::move(prompt);
c->triggers = triggers;
return c;
};
auto topic = std::make_unique<ClassifyAttributeDefT>();
topic->name = "topic";
topic->kind = ::Tryll::AttributeKind_Choice;
topic->system_prompt =
"You classify what a player says to a medieval innkeeper.\n\n"
"Labels:\n{{intents_block}}\n\nRespond with a single letter. No other text.";
topic->choices.push_back(choice("world", "talk about the story, the town, or the people in it"));
topic->choices.push_back(choice("meta", "talk about the game, the AI, or real-world software", true));
topic->uncertain_choice = "world";
auto threat = std::make_unique<ClassifyAttributeDefT>();
threat->name = "threat";
threat->kind = ::Tryll::AttributeKind_Boolean;
threat->statement = "The player threatens to hurt someone.";
threat->boolean_trigger = ::Tryll::BooleanTrigger_True;
auto politeness = std::make_unique<ClassifyAttributeDefT>();
politeness->name = "politeness";
politeness->kind = ::Tryll::AttributeKind_Score;
politeness->choices.push_back(choice("rude", "rude or insulting")); // lowest first
politeness->choices.push_back(choice("neutral", "neutral"));
politeness->choices.push_back(choice("polite", "polite and friendly"));
ClassifyAttributesLLMParamsT readP;
readP.output_name = "line";
readP.notify_client = ::Tryll::NotifyClient_Always;
readP.triggered_exit = "react";
readP.default_exit = "reply";
readP.attributes.push_back(std::move(topic));
readP.attributes.push_back(std::move(threat));
readP.attributes.push_back(std::move(politeness));
BranchParamsT reactP;
reactP.test = ::Tryll::BranchTest_Equals;
reactP.input = "line_threat";
reactP.values = { "true" };
reactP.then_exit = "back_off";
reactP.else_exit = "deflect";
auto line = [](std::string text)
{
CannedResponseParamsT p;
auto l = std::make_unique<LocalizableTextT>();
l->text = std::move(text);
p.lines.push_back(std::move(l));
return p;
};
GenerateParamsT replyP;
replyP.system_prompt = "You are Nessa, the innkeeper.";
replyP.template_ = "How polite the player is, from 0 to 1: {{slot.line_politeness}}. Match their tone.";
GraphDescription graph;
graph.AddClassifyAttributesLLM("read", std::move(readP))
.AddBranch("react", std::move(reactP))
.AddCannedResponse("back_off", line("Easy, friend. Put that away."))
.AddCannedResponse("deflect", line("You talk strange, traveller. Ale?"))
.AddGenerate("reply", std::move(replyP))
.SetStartNode("read")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)");
auto agent = client.CreateAgent(graph);
from tryll_client._generated.node_params import (
AttributeKind, BooleanTrigger, ClassifyAttributeChoice,
)
from tryll_client.graph import (
BranchParams, BranchTest, CannedResponseParams, ClassifyAttributeDef,
ClassifyAttributesLLMParams, GenerateParams, GraphDescription,
LocalizableText, NotifyClient,
)
TOPIC_PROMPT = (
"You classify what a player says to a medieval innkeeper.\n\n"
"Labels:\n{{intents_block}}\n\nRespond with a single letter. No other text."
)
read = ClassifyAttributesLLMParams(
output_name="line",
notify_client=NotifyClient.Always,
triggered_exit="react",
default_exit="reply",
attributes=[
ClassifyAttributeDef(
name="topic",
kind=AttributeKind.Choice,
system_prompt=TOPIC_PROMPT,
choices=[
ClassifyAttributeChoice(id="world", prompt="talk about the story, the town, or the people in it"),
ClassifyAttributeChoice(id="meta", prompt="talk about the game, the AI, or real-world software", triggers=True),
],
uncertain_choice="world",
),
ClassifyAttributeDef(
name="threat",
kind=AttributeKind.Boolean,
statement="The player threatens to hurt someone.",
boolean_trigger=BooleanTrigger.True_,
),
ClassifyAttributeDef(
name="politeness",
kind=AttributeKind.Score,
choices=[ # lowest level first
ClassifyAttributeChoice(id="rude", prompt="rude or insulting"),
ClassifyAttributeChoice(id="neutral", prompt="neutral"),
ClassifyAttributeChoice(id="polite", prompt="polite and friendly"),
],
),
],
)
graph = (
GraphDescription()
.add_node("read", read)
.add_node("react", BranchParams(
test=BranchTest.Equals, input="line_threat", values=["true"],
then_exit="back_off", else_exit="deflect",
))
.add_node("back_off", CannedResponseParams(
lines=[LocalizableText(text="Easy, friend. Put that away.")],
))
.add_node("deflect", CannedResponseParams(
lines=[LocalizableText(text="You talk strange, traveller. Ale?")],
))
.add_node("reply", GenerateParams(
system_prompt="You are Nessa, the innkeeper.",
template="How polite the player is, from 0 to 1: {{slot.line_politeness}}. Match their tone.",
))
.set_start_node("read")
.set_default_model_name("Llama 3.2 3B Instruct (Q4_K_M)")
)
agent = client.create_agent(graph)
5. Read the slots from game code¶
Each attribute writes the slot <output_name>_<name>. With output_name set
to line, the slots are line_topic, line_threat, and line_politeness.
With output_name empty, the prefix is the node's name. Later nodes read the
slots by name, as input or as {{slot.line_politeness}} in a template.
Slots stay on the server. To get the values in game code, set
notify_client on the node:
Alwayssends anattributes_classifiedevent on every scored turn.OnFoundsends it only when the node takestriggered.
The event is a flat string map. For each attribute it carries
<name>.value (the same text as the slot), <name>.kind, <name>.status,
<name>.triggered, and <name>.prob.<choice_id>. The node also adds exit,
status, and triggered_by (a comma-joined list of the attributes that
fired). The keys use the attribute name, not the slot name.
The event arrives mid-turn and does not wait for the game. The graph has
already moved on by the time you read it. If the game must decide the route
itself, put a Pause node after read, read the
event, and resume at the node you choose.
var (agent, error) = await TryllClient.Instance.RequestCreateAgentAsync(graph);
agent.NodeEvent += (nodeName, eventType, kvPairs) =>
{
if (eventType != "attributes_classified") return;
foreach (var pair in kvPairs)
{
if (pair.Key == "politeness.value")
npcMood.SetPoliteness(float.Parse(pair.Value, System.Globalization.CultureInfo.InvariantCulture));
else if (pair.Key == "topic.value" && pair.Value == "meta")
immersionMeter.Flag();
}
};
// Agent is the TSharedPtr<FTryllAgent> from RequestCreateAgent.
Agent->SetOnNodeEvent(
[this](const FString& NodeName, const FString& EventType,
const TArray<FTryllNodeEventKeyValue>& KvPairs)
{
if (EventType != TEXT("attributes_classified")) return;
for (const FTryllNodeEventKeyValue& Pair : KvPairs)
{
if (Pair.Key == TEXT("politeness.value"))
SetPoliteness(FCString::Atof(*Pair.Value));
}
});
On a UTryllAgentComponent, bind the On Node Event delegate in
Blueprint instead.
agent.SetOnNodeEvent(
[](std::string_view nodeName, std::string_view eventType,
const std::vector<AgentProxy::NodeEventKeyValue>& kvPairs)
{
if (eventType != "attributes_classified") return;
for (const auto& [key, value] : kvPairs)
{
if (key == "politeness.value")
std::cout << "politeness " << value << "\n";
}
});
6. Branch on one attribute¶
triggered tells you that some attribute fired, not which one. To route on a
single attribute, add a Branch that reads its
slot. In the graph above, react tests line_threat with test = Equals and
values = ["true"], so a threat takes then and a meta question takes
else. See Branch on a Slot for the full pattern.
Branch compares text, so use it on Choice and Boolean slots. A Score slot is a
number in text form, and Branch cannot compare it as a number. To act on a
Score, set the attribute's own score_trigger and score_threshold, or read
the value in game code.
Verify it worked¶
Send three lines: a threat, a question about the AI, and a friendly greeting.
With log_level set to "trace" in
server-config.json, the end line for
each node names the exit it took:
[trace] [NODE] end node=read exit=triggered # threat
[trace] [NODE] end node=react exit=then
[trace] [NODE] end node=read exit=triggered # meta question
[trace] [NODE] end node=react exit=else
[trace] [NODE] end node=read exit=default # greeting
For the probabilities behind each answer, enable diagnostics and read
output.classification.attributes[]. See
Turn Diagnostics JSON.
Common pitfalls¶
triggered_exitdoes not match the attributes. Set it when any attribute can fire, and leave it empty when none can. A mismatch fails agent creation.- Missing template tag. A custom prompt without
{{intents_block}}, or a Boolean prompt without{{statement}}, fails agent creation. - Score levels out of order. List them from lowest to highest. The score is computed from the order.
- Boolean threshold calibrated in the wrong order. The menu is always
A. True,B. False, and models have a letter bias. Calibratethresholdwith that order. - A changed
temperaturemoves every threshold. Refit the thresholds after you change it. - Renaming after creation. Attribute names, kinds, choice ids, and their count are fixed when the agent is created. Prompts, statements, thresholds, and trigger settings can change at runtime. See Change Agent Parameters at Runtime.