Define and Handle Tool Calls¶
Declare a tool the model can "call", ship it with a
ToolCall node, receive
tool_call events on the client, and either return a model-visible
result on resume (AwaitResult) or let the node's disposition
close the exchange (Acknowledge / RouteOnly / …).
Prerequisites
- A session connected, with a session created.
- A language model whose chat template supports tool calling
(Qwen 3.x, Granite 4.x, Llama-3.x, …). Tool prompting/parsing is
driven by the model's own template — there is no format to pick,
and
CreateAgentrejects a model whose template can't render tools. See the Tool Calling concept.
The pattern¶
flowchart LR
tc["ToolCall<br>detect"]
gen["Generate<br>answer"]
tc -- "tool_called" --> gen
tc -- "no_tool_called" --> gen
gen -- "default" --> END
disposition = AwaitResult— notify, pause after the call, client runs the tool, resumes withtool_results, thenGenerateanswers with the complete call/result pair in prompt.Acknowledge(default) /RouteOnly/ … — no client result needed; the server closes history itself. Use a notifying disposition (Notify,NotifyAndAcknowledge, …) when the game must react to the side effect.
Step 1 — declare the tools¶
Tools are declared per-node with typed tool definitions:
var setLight = new TryllToolDefinition {
Name = "set_light",
Description = "Turn a named light on or off.",
Parameters = new List<TryllToolParamDefinition> {
new() { Name = "name", Type = "string", Description = "Human-readable name of the light, e.g. 'porch'." },
new() { Name = "on", Type = "boolean", Description = "true to turn on, false to turn off." },
},
};
Author FTryllToolDefinition entries inside your
UTryllWorkflowAsset (Content Browser data asset) alongside the
graph. Or build them at runtime in C++ and assign to the
UTryllAgentComponent's graph.
from tryll_client.graph import ToolDefinition, ToolParamDefinition
set_light = ToolDefinition(
name="set_light",
description="Turn a named light on or off.",
parameters=[
ToolParamDefinition(name="name", type="string",
description="Human-readable name of the light, e.g. 'porch'."),
ToolParamDefinition(name="on", type="boolean",
description="true to turn on, false to turn off."),
],
)
Step 2 — build the graph with a ToolCall node¶
var graph = new TryllGraphBuilder()
.AddToolCall("detect", new TryllToolCallParams
{
Tools = new List<TryllToolDefinition> { setLight },
Disposition = TryllToolCallDisposition.AwaitResult,
Mode = TryllToolCallMode.DetectOnly,
SystemPrompt = "You are a smart-home controller.",
ToolCalledExit = "answer",
NoToolCalledExit = "answer",
})
.AddGenerate("answer", new TryllGenerateParams())
.SetStartNode("detect")
.SetDefaultModelName("Llama-3.2-3B-Instruct")
.Build();
var (agent, error) = await TryllClient.Instance.RequestCreateAgentAsync(graph);
using namespace Tryll::Client;
using namespace Tryll::NodeParams;
ToolCallParamsT detectParams;
detectParams.tools = {setLight};
detectParams.disposition = ::Tryll::ToolCallDisposition_AwaitResult;
detectParams.mode = ::Tryll::ToolCallMode_DetectOnly;
detectParams.system_prompt = "You are a smart-home controller.";
detectParams.tool_called_exit = "answer";
detectParams.no_tool_called_exit = "answer";
GraphDescription graph;
graph.AddToolCall("detect", std::move(detectParams))
.AddGenerate("answer", GenerateParamsT{})
.SetStartNode("detect")
.SetDefaultModelName("Llama-3.2-3B-Instruct");
auto agent = client.CreateAgent(graph);
from tryll_client.graph import (
GraphDescription, ToolCallParams, ToolCallMode, GenerateParams,
)
from tryll_client._generated.node_params import ToolCallDisposition
graph = (
GraphDescription()
.add_node("detect", ToolCallParams(
tools=[set_light],
disposition=ToolCallDisposition.AwaitResult,
mode=ToolCallMode.DetectOnly,
system_prompt="You are a smart-home controller.",
tool_called_exit="answer",
no_tool_called_exit="answer",
))
.add_node("answer", GenerateParams())
.set_start_node("detect")
.set_default_model_name("Llama-3.2-3B-Instruct")
)
agent = client.create_agent(graph)
See the full param list in ToolCall node reference.
Step 3 — receive the notification client-side¶
Register a callback before the first send_message / SendMessage call.
Prefer the call-id-aware form when you will resume with results — echo
that call_id in tool_results.
// Session-level; prefer WithId when returning results.
TryllClient.Instance.ToolCallNotificationWithId +=
(agentId, callId, toolName, argsJson) => {
if (toolName == "set_light")
Debug.Log($"[tool] id={callId} set_light args={argsJson}");
};
// Or RegisterTool sugar — auto-resumes a complete paused batch.
// RegisterTool handlers only run from the pausing path (HandlePaused),
// i.e. for Pause / PauseAndAcknowledge / AwaitResult. Non-pausing
// notifying dispositions (Notify / NotifyAndAcknowledge) never reach a
// registered handler — use ToolCallNotificationWithId (or the generic
// NodeEvent callback) for those. With disposition=AwaitResult the
// handler return becomes tool_results; Pause / PauseAndAcknowledge run
// the handler for side effects, then Resume() without tool_results
// (sending results would error 3016).
agentComp.RegisterTool("set_light", call => {
// call.CallId, call.ToolName, call.ArgumentsJson, call.Disposition
ApplyLight(call.ArgumentsJson);
return "{\"ok\":true}"; // model-visible only for AwaitResult
});
Bind On Tool Call With Id on UTryllSubsystem, or call
RegisterTool on the agent / component (auto-resume sugar). Same rule
as Unity: RegisterTool handlers only run for pausing dispositions
(Pause / PauseAndAcknowledge / AwaitResult); native tool_results
are sent only for AwaitResult. Use the typed notification event for
Notify / NotifyAndAcknowledge.
agent.SetOnToolCallWithId(
[&agent](std::string_view callId, std::string_view toolName,
std::string_view argsJson)
{
// Reader thread — keep non-blocking; resume async.
if (toolName == "set_light")
{
AgentProxy::ToolResult tr{std::string{callId}, "{\"ok\":true}"};
agent.ResumeWithToolResultAsync(tr);
}
});
Prefer SetOnToolCallEvent for new code: it delivers an owning
ToolCallEvent{callId, toolName, argumentsJson, nodeName, disposition}
struct (safe to capture beyond the callback), instead of the
string_views above that alias the current frame and must be copied
before any deferred use.
import json
from tryll_client.agent import ToolResult
def on_tool_call(call_id: str, tool_name: str, arguments_json: str) -> None:
args = json.loads(arguments_json)
if tool_name == "set_light":
# Fires on the reader thread — never call the blocking resume()
# here (it would deadlock the sole frame reader waiting on its
# own Ack). resume_async() is safe: it returns a Future and lets
# the reader thread keep consuming frames.
agent.resume_async(tool_results=[ToolResult(call_id, '{"ok":true}')])
agent.set_on_tool_call_with_id(on_tool_call)
Step 4 — feed the result back¶
Protocol v8+ carries results on ResumeAgentRequest.tool_results. For
AwaitResult, resume with a complete batch (every pending
call_id exactly once). Invalid batches return 3016 InvalidToolResults
and leave the turn paused for retry.
# From a reader-thread tool-call callback, use resume_async (see Step 3).
# From your own thread (e.g. after the callback queued work elsewhere),
# the blocking form is fine:
agent.resume(tool_results=[ToolResult(call_id, '{"ok":true}')])
# tuples also accepted: tool_results=[(call_id, '{"ok":true}')]
For side-effect tools that need no payload, keep the default
disposition = Acknowledge (optional acknowledge_text, empty ⇒
"ok") or switch to RouteOnly. No tool_results required; the projection
still closes the exchange so multi-turn templates stay well-formed.
Alternative: ChangeParams for game-only context
If the model does not need the tool payload but a downstream node's
prompt should mention it for diagnostics, you can still
ChangeParams while paused and
Resume() without tool_results. Prefer typed tool_results whenever
the model should condition on the answer.
Verify it worked¶
Send a message that should trigger the tool:
Your call-id-aware callback fires with something like:
After a successful ResumeWithToolResult(s) / resume(tool_results=...),
the downstream Generate projects the complete pair and streams the reply.
Common pitfalls¶
- Tool format is automatic. The legacy
tool_call_formatparam is deprecated and ignored. Use a model whose chat template supports tools. - Argument values are strings. Coerce booleans/numbers in your client.
- Model invents tools. Allow-list
tool_namebefore acting. - Non-notifying dispositions (
RouteOnly,Acknowledge) mean notool_callevent — callbacks never fire. AwaitResultrequires pause + complete results. Missing/unknowncall_ids yield3016and stay paused.mode=call_or_answer(the default) emits residual text when no tool was detected. Usedetect_onlywhen a separateGeneratehandlesno_tool_called. See Concept: Tool calling.