Client tools.
Local tool handlers the agent can invoke mid-turn, wired into the roundtrip loop.
Client tools are handlers you register that the agent can invoke mid-turn. The SDK handles the entire roundtrip loop: detecting tool calls, executing your handler, feeding the result back to the agent, and repeating until the agent stops calling tools or the roundtrip limit is reached.
Tool execution loop
┌─────────────────────────────────────────────────────────┐
│ Turn 0 │
│ 1. Send messages + client_tools to server │
│ 2. Stream events — agent responds, may call tools │
│ 3. If tool calls detected: │
│ a. client.tool.requested → client.tool.executing │
│ b. Execute handler from tool_handlers │
│ c. client.tool.result │
│ d. Append result to messages as a user message │
│ e. client.tool.roundtrip (turn + 1 / max_turns) │
│ f. Go to Turn 1 │
│ 4. If no tool calls: run is done │
│ 5. If turn >= max_tool_roundtrips: truncated │
└─────────────────────────────────────────────────────────┘Declaring and handling tools
import asyncio
import json
from matilda_agent_sdk import ToolResult, stream
# 1. Declare the tools so the server knows they exist
client_tools = [
{"name": "get_weather", "description": "Get current weather for a city", "parameters": {"type": "object"}},
{"name": "calculate", "description": "Evaluate a math expression", "parameters": {"type": "object"}},
]
# 2. Register handlers — the SDK calls these when the agent invokes a tool
async def get_weather(args, ctx):
city = args.get("city", "unknown")
return ToolResult(content=json.dumps({"city": city, "temp": 22, "condition": "sunny"}))
async def calculate(args, ctx):
try:
# NOTE: eval is a stand-in for a real expression parser — never eval
# untrusted input in production.
result = eval(str(args["expression"]), {"__builtins__": {}}, {})
return ToolResult(content=str(result))
except Exception:
return ToolResult(content="Invalid expression", is_error=True)
tool_handlers = {"get_weather": get_weather, "calculate": calculate}
# 3. Pass both to stream() or run()
async def main():
async for event in stream(
{"name": "assistant", "instructions": "Use the available tools to answer questions."},
"What is the weather in Sydney, and what is 15 * 23?",
tool_handlers=tool_handlers,
client_tools=client_tools,
):
if event.type == "client.tool.requested":
print(f"→ Agent requested: {event.name}({json.dumps(event.args)})")
if event.type == "client.tool.executing":
print(f"⚙ Executing: {event.name}")
if event.type == "client.tool.result":
suffix = " (error)" if event.is_error else ""
print(f"← Result: {event.result}{suffix}")
if event.type == "client.tool.roundtrip":
print(f" Roundtrip {event.turn}/{event.max_turns}")
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
asyncio.run(main())ToolHandler
ToolHandler = Callable[[dict, ToolExecutionContext], Awaitable[ToolResult]]ToolExecutionContext
@dataclass(frozen=True)
class ToolExecutionContext:
tool_call_id: str | None = NoneToolResult
@dataclass(frozen=True)
class ToolResult:
content: str
is_error: bool = FalseToolHandlers
ToolHandlers = dict[str, ToolHandler]max_tool_roundtrips
Controls how many back-and-forth tool cycles the SDK allows before stopping. Default is 25 (DEFAULT_MAX_TOOL_ROUNDTRIPS). Lower it to prevent infinite loops or control cost.
result = await runner.run(
{"name": "tool-heavy-agent", "instructions": "Use tools to gather information."},
"Do a task that needs tools",
max_tool_roundtrips=5,
tool_handlers={"search": lambda args, ctx: search_handler(args)},
client_tools=[{"name": "search", "description": "Search", "parameters": {"type": "object"}}],
)
roundtrips = [e for e in result.events if e.type == "client.tool.roundtrip"]
print(f"Roundtrips used: {len(roundtrips)} (max was 5)")Advertised-tool guard
The SDK enforces that the agent can only call tools that were advertised for the current turn. If the model calls a tool that wasn't in client_tools, the SDK returns an error result instead of executing a handler:
Tool not offered this turn: <name>This prevents a model from talking the runner into invoking a handler that was never offered — tool output is untrusted input.
DSML tool-call interception
Some models emit tool calls as text tokens wrapped in DSML markup (<|DSML|tool_call>{...}</|DSML|tool_call>) instead of using native function calling. The SDK automatically intercepts these text tokens, parses the JSON payload, and surfaces them as native ClientToolRequested events — the consumer never sees the raw markup.
This interception is fully automatic and applies to all streaming paths (the tag constants are exported as DSML_TOOL_CALL_OPEN / DSML_TOOL_CALL_CLOSE).
Human-in-the-loop
The tool execution loop makes human-in-the-loop trivial: a tool handler is just an async callable, so it can block on stdin, a UI prompt, or any other input source.
import asyncio
from matilda_agent_sdk import ToolResult
async def ask_user(args, ctx):
answer = await asyncio.to_thread(input, f"\n Agent asks: {args['question']}\n > ")
return ToolResult(content=answer.strip())
tool_handlers = {"ask_user": ask_user}
client_tools = [
{"name": "ask_user", "description": "Ask the user a question", "parameters": {"type": "object"}},
]
result = await runner.run(
{"name": "clarifier", "instructions": "Ask the user for clarification when needed."},
"Help me plan a trip",
tool_handlers=tool_handlers,
client_tools=client_tools,
)