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:
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")
)
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
GraphDescription graph;
graph.AddGenerate("npc_a", std::move(npcA))
.AddGenerate("npc_b", std::move(npcB))
.SetStartNode("npc_a")
.SetDefaultModelName("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. Within each node's
stream the last frame has is_final = true, so node_name + is_final
together delimit each answer:
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.