Send Multiple Answers in One Turn¶
A single turn can have more than one node that sends text to the client — two
characters speaking, a "thinking" channel alongside the real reply, or a
narrator plus dialogue. Every AnswerText frame carries the name of the
node that produced it, so your UI can tell the streams apart and route each
one to the right place.
Prerequisites
- You can already stream a single answer — see Stream answers to a UI.
- Read Slots and inter-node value passing
for the
sendmodel this recipe relies on.
Step 1 — author two sending nodes¶
Any node with send ∈ {Whole, Streamed} contributes to the wire. Put two of
them on the same turn's path and give them meaningful names — the name is the
attribution key the client sees. Here two Generate nodes each speak as a
different character:
var graph = new TryllGraphBuilder()
.AddGenerate("npc_a", new TryllGenerateParams
{
SystemPrompt = "You are Ada, a curt engineer. Reply in one sentence.",
Send = TryllSendAnswer.Streamed, // attributed as "npc_a"
DefaultExit = "npc_b",
})
.AddGenerate("npc_b", new TryllGenerateParams
{
SystemPrompt = "You are Bram, a cheerful bard. Reply in one sentence.",
Send = TryllSendAnswer.Streamed, // attributed as "npc_b"
// DefaultExit is "" (END) by default.
})
.SetStartNode("npc_a")
.SetDefaultModelName("My Local Model")
.Build();
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
UTryllGenerateParams* NpcA = UTryllNodeParamsFactory::MakeGenerateParams(this);
NpcA->bOverrideSystemPrompt = true;
NpcA->SystemPrompt = TEXT("You are Ada, a curt engineer. Reply in one sentence.");
NpcA->Send = ETryllSendAnswer::Streamed;
NpcA->DefaultExit = TEXT("npc_b");
UTryllGenerateParams* NpcB = UTryllNodeParamsFactory::MakeGenerateParams(this);
NpcB->bOverrideSystemPrompt = true;
NpcB->SystemPrompt = TEXT("You are Bram, a cheerful bard. Reply in one sentence.");
NpcB->Send = ETryllSendAnswer::Streamed;
// NpcB->DefaultExit is TEXT("") (END) by default.
FTryllGraphDescription Graph = FTryllGraphBuilder()
.AddNode(TEXT("npc_a"), NpcA)
.AddNode(TEXT("npc_b"), NpcB)
.SetStartNode(TEXT("npc_a"))
.SetDefaultModelName(TEXT("My Local Model"))
.Build();
In the editor, add two Generate nodes named npc_a and npc_b to a
UTryllWorkflowAsset and set npc_a's Default Exit to npc_b.
using namespace Tryll::NodeParams;
GenerateParamsT npcA;
npcA.system_prompt = "You are Ada, a curt engineer. Reply in one sentence.";
npcA.send = Tryll::SendAnswer_Streamed;
npcA.default_exit = "npc_b";
GenerateParamsT npcB;
npcB.system_prompt = "You are Bram, a cheerful bard. Reply in one sentence.";
npcB.send = Tryll::SendAnswer_Streamed;
// npcB.default_exit stays empty = END
Tryll::Client::GraphDescription graph;
graph.AddGenerate("npc_a", std::move(npcA))
.AddGenerate("npc_b", std::move(npcB))
.SetStartNode("npc_a")
.SetDefaultModelName("My Local Model");
from tryll_client.graph import GraphDescription
from tryll_client._generated.node_params import GenerateParams, SendAnswer
npc_a = GenerateParams(
system_prompt="You are Ada, a curt engineer. Reply in one sentence.",
send=SendAnswer.Streamed, # streams to the client, attributed as "npc_a"
default_exit="npc_b",
)
npc_b = GenerateParams(
system_prompt="You are Bram, a cheerful bard. Reply in one sentence.",
send=SendAnswer.Streamed, # streams to the client, attributed as "npc_b"
default_exit="", # END
)
graph = (
GraphDescription()
.add_node("npc_a", npc_a)
.add_node("npc_b", npc_b)
.set_start_node("npc_a")
.set_default_model_name("My Local Model")
)
The node name (npc_a, npc_b) is what arrives on each frame. If you set
output_name, that does not change attribution — node_name on the wire is
always the producing node's name, not its slot name.
Step 2 — route each stream by node name¶
Every answer callback receives the node name as its first argument. Switch on it to send each stream to the right widget.
OnAnswerText fires on the Unity main thread with
(nodeName, text, isDelta, isFinal):
On the UTryllAgentComponent, bind On Answer Text (NodeName: FString,
Text: FString, bIsFinal: bool) and branch on NodeName:
NodeName == "npc_a"→ appendTextto Ada'sUTextBlock.NodeName == "npc_b"→ appendTextto Bram'sUTextBlock.
The event fires on the game thread, so you can update UMG widgets
directly. (The lower-level FTryllAgent::SetOnAnswerText callback also
exposes an bIsDelta flag if you need it.)
SetOnAnswerText fires on the client library's reader thread with
(nodeName, text, isDelta, isFinal) — marshal to your UI thread:
set_on_answer_text fires on the reader thread with
(node_name, text, is_delta, is_final):
Ordering and is_final¶
Nodes execute one at a time, so the two streams never interleave: every
npc_a frame arrives before the first npc_b frame. With
send = Streamed, every text frame is a delta (is_delta = true), and each
node's stream ends with an empty terminator frame: is_delta = true,
is_final = true, text = "". So node_name + is_final together delimit
each answer:
npc_a "He" · npc_a "llo" · npc_a "." · npc_a "" (is_final) · npc_b "Hi" · npc_b "!" · npc_b "" (is_final)
A send = Whole node sends one frame instead, with is_delta = false,
is_final = true, and the full text.
is_final marks the end of one node's stream, not the end of the turn — a
later node may still send. Bind TurnComplete for "the whole turn is done".
Voice: the same attribution on audio¶
GenerateAndSpeak and Speak fan TTS audio out the same way — the audio
callbacks (OnTtsAudioFormat / the Unreal audio delegate) carry the same
producing-node name, so a multi-voice scene can route each character's audio
to its own speaker. See Add voice output to an agent.
Common pitfalls¶
- Keying UI on node names couples your game code to the graph. Renaming a
node in the editor propagates through the graph's own references, but it
cannot reach
nodeNamestring literals in your game code — keep the names you switch on stable, or centralise them in one constant. - A
send = Nonenode sends nothing. It still writes its slot and can feed downstream nodes, but it produces noAnswerTextand so never appears in these callbacks — see Add a hidden reasoning or draft step. - Don't rely on
is_finalfor "turn over". With multiple senders you get oneis_finalper node; onlyTurnCompletemeans the turn ended.