AI Tool Calling¶
RoomKit supports AI tool calling (function calling) with per-room tool definitions, streaming tool loops, access control via tool policies, and MCP integration. This guide covers the full tool calling system.
Quick Start¶
The recommended way to define tools is with the Tool protocol — each tool bundles its JSON schema definition with its handler in a single object. Pass tool objects directly to AIChannel(tools=[...]) and definitions + handlers are extracted automatically:
from __future__ import annotations
import json
from roomkit import RoomKit, Tool
from roomkit.channels import AIChannel
from roomkit.models.enums import ChannelCategory
from roomkit.providers.ai.anthropic import AnthropicAIProvider
class GetWeatherTool:
"""Implements the Tool protocol: definition + handler."""
@property
def definition(self) -> dict:
return {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
}
async def handler(self, name: str, arguments: dict) -> str:
city = arguments["city"]
return json.dumps({"temp": 22, "condition": "sunny", "city": city})
kit = RoomKit()
ai = AIChannel(
"ai-assistant",
provider=AnthropicAIProvider(model="claude-opus-5", api_key="..."),
system_prompt="You are a helpful assistant.",
tools=[GetWeatherTool()],
)
kit.register_channel(ai)
await kit.attach_channel("room-1", "ai-assistant", category=ChannelCategory.INTELLIGENCE)
No separate tool_handler or binding metadata "tools" list needed — the channel extracts both from the tool objects. When multiple tools are passed, their handlers are composed automatically with first-match-wins dispatch.
Defining Tools¶
AITool Model¶
from roomkit.providers.ai.base import AITool
tool = AITool(
name="get_weather",
description="Get current weather for a city",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
)
A tool's name has to suit the providers it reaches. AITool refuses a name
no vendor accepts: an empty one, or one with a character other than a letter,
a digit, _, ., : or -. Each provider checks its own vendor's rule when
it declares the turn's tools and raises a ProviderError naming the tool
before the request, rather than letting the vendor reject the turn:
| Provider | Tool names it accepts |
|---|---|
| OpenAI (its own endpoint), Anthropic | [A-Za-z0-9_-]{1,128} |
| Gemini | [A-Za-z_][A-Za-z0-9_.:-]{0,127}: a dot and a colon, no leading digit |
| Mistral | [A-Za-z0-9_.-]+: a dot, no colon |
A server behind a custom URL (base_url, Mistral's server_url) |
not checked: the server decides |
A realtime provider checks the same way when it declares a session's tools, at connection and at every reconfiguration. Where the endpoint hands the tools to another model, the rule is that model's vendor's (measured 2026-10-02):
| Realtime provider | Tool names it accepts |
|---|---|
OpenAI Realtime and GPT-Live with HostedReasoning, each on its own endpoint |
[A-Za-z0-9_-]{1,128} |
| Deepgram | its think provider's: open_ai and anthropic as above, google as Gemini; any other, or a think_endpoint, not checked |
| Gemini Live | as Gemini |
| xAI | not checked: it accepts any name |
GPT-Live with IntegratorReasoning, ElevenLabs |
not checked here: the reasoning backend's provider, or the agent's own configuration, decides |
So an MCP tool named files.read works on Gemini and Mistral and is refused
up front on OpenAI and Anthropic. A tool without parameters (parameters={}
or none) is declared as an object with no properties, which every provider
accepts, and a schema whose root has no type is declared an object's:
Anthropic and OpenAI refuse it untyped.
As Dicts in Binding Metadata¶
Tools can also be defined as plain dicts in channel binding metadata — they are automatically converted to AITool instances:
await kit.attach_channel("room-1", "ai-assistant", metadata={
"tools": [
{"name": "search", "description": "Search the knowledge base", "parameters": {...}},
{"name": "create_ticket", "description": "Create a support ticket", "parameters": {...}},
],
})
Hub Tools and Hoisted Arguments¶
A hub tool declares one tool per domain behind an {action, params} signature:
BOARDS_TOOL = {
"name": "boards",
"description": "Board operations.",
"parameters": {
"type": "object",
"additionalProperties": False,
"properties": {"action": {"type": "string"}, "params": {"type": "object"}},
"required": ["action"],
},
}
Models trained mostly on flat schemas (one tool = its arguments) routinely hoist the inner keys one level up — the smaller the model, the more often:
The schema is closed, so the argument gate would refuse board_id and the turn
would be spent on an error the model can only fix by re-issuing the call.
RoomKit folds the call back into shape instead, before validation, on both the
AI and realtime voice channels — the handler receives
{"action": "list_columns", "params": {"board_id": "board-1"}} and the round
does real work. Each fold is logged at INFO with the tool and the model id, so
the frequency of the case stays measurable per model.
The repair is deliberately narrow. It applies only when the schema closed
itself, declares a params property of type object that declares no
properties of its own, and carries at least one undeclared root key — and only
when params is absent or empty:
| Call | Outcome |
|---|---|
{"action": "x", "board_id": "1"} |
folded into params |
{"action": "x", "params": {}, "board_id": "1"} |
folded into params |
{"action": "x", "params": {"a": 1}, "board_id": "1"} |
refused — both forms at once is ambiguous; the error says to pass every argument inside params |
{"city": "Laval", "units": "metric"} on a flat tool |
refused — no container to fold into, so an unknown argument stays an error |
{"titel": "Q3"} on a tool whose params declares its own properties |
refused by name — that params is an options object, not a hub container |
any call against a schema without additionalProperties: false |
untouched — undeclared root keys are already legal there |
The shape condition matters as much as the name, because params is an ordinary
name for an ordinary options object:
"properties": {
"title": {"type": "string"},
"params": {"type": "object", "properties": {"width": {"type": "integer"}}},
}
A hub container cannot declare its properties — its shape varies with
action — so a declared shape means the tool is not a hub. Folding into it
would move a misspelt root property (titel) inside the container, where the
gate cannot see it: validation does not recurse into nested objects. The call
would then reach the tool with a bogus key and no title. Left alone, the same
call is refused as unknown argument 'titel' (this tool accepts: params, title)
— which is the one thing the model can act on.
Arguments rewritten by a BEFORE_TOOL_USE hook are validated but never folded:
a flat payload out of a hook is that hook's bug, and naming it beats reshaping
it silently.
Opening the schema (additionalProperties: true) would make the error go away
too — and make a genuine typo silent, handing the tool an argument nobody
reads. The schema stays closed.
Tool Handlers (Advanced)¶
For most use cases, the Tool protocol (shown above) is the recommended approach. The tool_handler parameter is available for advanced scenarios: MCP integration, custom auditing/logging wrappers, or dynamic dispatch logic that doesn't fit the per-tool-object model.
A tool handler is an async function that receives the tool name and arguments, and returns a string result:
from __future__ import annotations
import json
from roomkit import UnservedToolCallError
async def my_handler(name: str, arguments: dict) -> str:
if name == "get_weather":
city = arguments["city"]
# Call your weather API
return json.dumps({"temp": 22, "condition": "sunny"})
if name == "search":
query = arguments["query"]
# Search your knowledge base
return json.dumps({"results": ["result1", "result2"]})
raise UnservedToolCallError(f"{name} is not mine") # not this handler's tool
ai = AIChannel("ai", provider=provider, tool_handler=my_handler)
When both tools and tool_handler are provided, the channel merges them — Tool object handlers are tried first, then the explicit tool_handler.
channel.tool_handler is this handler, the host's: reading it returns it, and
assigning it replaces it alone. The tools the channel serves itself (skills,
Tool Search, read_stored_result, the planner, the sandbox) and the ones
orchestration sets up (a handoff, a delegation, a strategy's tools) are served
before it, whatever it is.
One tool per name¶
A name is served by one tool in a room, so a tool given under a name the channel already has is refused when it is given (RFC §21.1):
- two tools of the host under one name raise
ValueErrorwhen they are given (anAIChannelor aRealtimeVoiceChannelat construction, a realtimeconfigure(tools=), a conference'sConferenceRealtimeConfig): composing two MCP servers that both exposesearchwould otherwise declare one server's schema for a call the other serves; setup_handoff,setup_delegationor a strategy setting a tool up under a name one of the host's tools carries raiseroomkit.ToolNameCollisionError(aValueError): rename one of them. The same strategy installed again in a room replaces its own tools, and an install that sets up several tools sets up all of them or none.
A tool that arrives with the turn (binding metadata, a config_provider, a
BEFORE_AI_GENERATION hook) comes too late to be refused: under a name the
channel or orchestration serves it is left out, with a warning, and a name it
gives twice is declared once, with the later definition.
A handler answers with text, or with a list of content parts (text and images)
for a multimodal result. Anything else it returns (a dict, a list of values, a
number, None) reaches the model as its JSON serialization, the same on
AIChannel and RealtimeVoiceChannel: {"ok": True} reads as {"ok": true}
and None as null. A result an ON_TOOL_CALL hook supplies in its place is
read the same way.
The pre-execution gates — the declared-catalogue check, the tool policy, skill
gating, argument validation against the declared schema, and the
BEFORE_TOOL_USE hook, in that order — are a property of the channel, not of
the handler. They run before the call is routed, so a tool served by an
ON_TOOL_CALL hook on a RealtimeVoiceChannel with no tool_handler is gated
exactly like one served by a handler. Access comes before the arguments: a tool
the policy denies is refused without its arguments being read, so its refusal
never names what its schema requires.
A refusal reads the same text for the same cause on every door — an
AIChannel turn, a reasoning backend's turn, a RealtimeVoiceChannel session,
a conference:
| Cause | What the model reads |
|---|---|
| The tool policy denies it | Tool 'X' is not permitted by the agent's tool policy. |
| A skill keeps it closed | Tool 'X' is gated by a skill. Activate the skill first using activate_skill. (a reasoning backend, which cannot activate one: … gated by a skill the conversation has not activated.) |
| Nothing carries the name | Tool 'X' is not declared. (under Tool Search, text or realtime, call_tool and list_tools included: none exists, with a hint to search) |
A tool the policy denies and a skill gates reads the policy's text: activating the skill would not let it through.
A tool of the turn's catalogue that the round did not declare because the policy or a skill hides it reads its cause, not "not declared", so a model learns to activate the skill rather than give up.
A declared tool that no handler serves reaches the ON_TOOL_CALL sync hooks
with result=None: a hook may serve it by supplying the result (HookResult
with metadata={"result": ...}). If none does, the model reads
{"error": "No handler for tool <name>"} and the call is reported once, as
failed. The channel's own outcomes are refusals too: a repeat of the same call
with the same arguments that the channel stops, and a tool outside the turn's
toolset. HumanInputToolHandler refuses a request a human or an
ON_USER_INPUT_REQUIRED hook rejected; one nobody answered in time is a
failure.
A call one of those gates refuses never reaches the handler, and a handler that
raises never returns — but both are still reported. They fire ON_TOOL_CALL
with is_error=True, on the async observers only, on every door (an AI
channel's, an external handler's, ACP, realtime, a conference): an audit hook
sees the refusal, while a hook that could have served the call does not, so a
denial prevents the side effect instead of hiding it. A refusal is also marked
refused=True, a cancellation cancelled=True, and a failure neither. Read the
outcome from these markers rather than from the result text, which is written
for the model.
On a realtime channel the model can also abandon a call it issued: Gemini Live
sends tool_call_cancellation when the caller interrupts while the tool runs.
The channel cancels the handler (it sees asyncio.CancelledError at its next
await), sends nothing back, and fires the observers with cancelled=True
beside is_error=True. The provider-level callback is described in the
realtime providers guide.
An AI channel reports the same way every call its turn announced and nothing
else reported, whatever cut it: a stop while the calls were announced, a
transport that stopped reading (a voice barge-in), the turn cancelled in the
gate, in the handler or while ON_TOOL_CALL judged the call. Each is stored
cancelled and reaches the observers once, with cancelled=True. A call
whose outcome the model already read (one the provider ran, one an external
handler decided) keeps that
outcome when a cut interrupts its own report: the observers hear it once,
with that outcome, never a second time cancelled.
A handler that declines a call it owns raises ToolRefusedError. A refusal
returned as a body reads as work that was done, and any other exception reads
as {"error": "Tool '<name>' failed (<ExceptionClass>)"} on every channel:
its message never reaches the model, since it can hold anything the failing
code held (a connection string with its password), and goes to the log and to
ON_TOOL_CALL observers as event.error_detail. Raising ToolRefusedError
keeps both: the call is marked is_error, the observers fire (refused=True),
and the message reaches the model verbatim. A tool that ran and failed, and
has words for the model, raises ToolFailedError instead: the call is marked
failed, not refused (refused=False), the model reads the message verbatim
and the observers read it as error_detail, on every channel.
MCPToolProvider.as_tool_handler() raises ToolFailedError for a result that
says isError: the tool ran and failed.
A tool that is not the handler's own is a different case: raise
UnservedToolCallError, so a composed handler passes the call on (see below)
and the channel reads the call as served by nothing, on every path (RFC §21.4).
The {"error": "Unknown tool: ..."} answer an earlier convention returned is
still read the same way, as text or as a mapping.
from roomkit import ToolRefusedError
async def my_handler(name: str, arguments: dict) -> str:
if name == "delete_account" and not arguments.get("confirmed"):
raise ToolRefusedError("Refused: deleting an account needs confirmed=true.")
...
Tip
Raise UnservedToolCallError for a tool that is not yours. This is what tool handler composition reads (see below), and what lets an ON_TOOL_CALL hook still serve the call.
What a Handler Knows About the Call¶
The handler protocol is (name, arguments) -> str — no room, no speaker, no
toolset. That omission is deliberate: an AIChannel object is registered once
per channel_id and shared by every room it serves, so anything a handler
closed over when it was built describes whoever attached it, not the turn now
running. These accessors read the current turn from a contextvar instead:
| Accessor | Answers |
|---|---|
current_tool_room_id() |
Which room this turn belongs to |
current_tool_room() |
The turn's Room itself, the object RoomContext.room holds for the same turn: read its organization_id, metadata or status without a store round trip. It is the room as the store loaded it when the turn began, shared with the whole turn: a patch written to the store mid-turn is not in it, and the object itself must not be mutated (room changes go through the store); on a realtime tool call, which runs no turn, the room as loaded for that call |
current_tool_actor_id() |
Whose turn it is — the participant id of the event that woke the channel |
current_tool_allowed_names() |
Every tool name the turn resolved, and every tool a round declared beyond it (read_stored_result), nothing withdrawn, that its tool policy admits, so a call is validated against the live toolset rather than an attach-time snapshot (a tool a skill keeps closed stays in); on a realtime tool call, every tool the session declares that its policy admits (None when it declares no catalogue: no list, the gate still judges each call) |
current_tool_call() |
The per-call context: the call's id, its channel, and the structured_content reverse channel the handler may fill |
current_response_metadata() |
The turn's response-metadata record — what the reply's MESSAGE events will carry (see below) |
Contextvars propagate down the async call chain, so they work at any depth
without a signature change. The realtime voice channel and a conference
install the same context around each tool call they serve (the session's room
and participant as the turn's room and actor; a conference's mix names no
participant, so its actor is None), so one handler works on every path for
the room id, the Room, current_tool_call() and
current_tool_allowed_names() (there, every tool the session declares that
its policy admits: its catalogue, what orchestration set up, the channel's
own; None for a session that declares no catalogue: no list, its gate still
judges each call);
current_response_metadata() returns None there (no turn merges a record on
that path, so the if record is not None guard below skips a write nothing
would carry). Each returns None outside a
tool call (a direct call) — keep your own fallback there. A test that calls a
handler directly describes the turn with tool_turn_context
(see Testing a handler on its own).
from roomkit.tools import current_tool_actor_id, current_tool_room, current_tool_room_id
async def my_handler(name: str, arguments: dict) -> str:
room_id = current_tool_room_id()
actor_id = current_tool_actor_id()
room = current_tool_room() # the same object the turn's RoomContext.room holds
tenant = room.metadata.get("tenant") if room is not None else None
...
What a Handler Can Tell the Turn¶
A handler's return value is what the model reads. Two things it learns belong to the turn instead, and both travel through the per-call context:
current_response_metadata()is the turn's oneResponseMetadatarecord (roomkit.models): a dict-like mapping created with the turn and merged into every MESSAGE event the turn produces, as it stands when each event is created. A memory provider writes it while the context is built, aBEFORE_AI_GENERATIONhook writesevent.ai_context.response_metadata, and a tool handler or aBEFORE_TOOL_USEhook writes here: all of them reach the same object, the oneInboundResult.response_metadatahands the caller, whether the turn answered or failed. A document the tool read mid-loop can therefore be named as a source of the reply, and a host can count the calls a turn started.current_tool_call().structured_contentis the structured copy of an MCP result that the tool-call events persist verbatim for UI surfaces. A handler that rewrites the text before the model reads it (a provider's private address turned into a relay link, say) rewrites the copy here too. The copy reachesON_TOOL_CALLthe same way on an AI channel, a realtime session and a conference: a SYNC hook sees it and may replace or clear it, and the observers receive what the chain left.
from roomkit.tools import current_response_metadata
async def my_handler(name: str, arguments: dict) -> str:
result = await call_my_tool(name, arguments)
record = current_response_metadata()
if record is not None:
record.setdefault("cited", []).append({"tool": name, "id": result["id"]})
return result["text"]
Segments streamed before a tool round carry what was known then; the answer,
persisted after it, carries what the handler wrote. None outside a turn.
The Actor Names the Turn, It Does Not Authenticate It¶
current_tool_actor_id() returns a room Participant.id. The inbound pipeline
substitutes the resolved Identity.id for it only once identification
succeeds — a sender still pending, ambiguous or unknown keeps whatever the
channel supplied, or a synthetic pending-…, and reads back just as
non-None. Reaching a person's rows with the raw value trades one wrong
principal (whoever attached the handler) for another (whoever the channel
claimed). Resolve it against the roster first:
import json
from roomkit.models.enums import IdentificationStatus
from roomkit.tools import current_tool_actor_id, current_tool_room_id
async def my_handler(name: str, arguments: dict) -> str:
room_id = current_tool_room_id()
actor_id = current_tool_actor_id()
if room_id is None or actor_id is None:
return json.dumps({"error": "No turn to act for"})
participant = await kit.store.get_participant(room_id, actor_id)
if participant is None or participant.identification is not IdentificationStatus.IDENTIFIED:
return json.dumps({"error": "Sender not identified"})
return await fetch_rows_for(participant.identity_id)
The author need not be human, either: in a multi-agent room the waking event may
be another agent's, and its participant id reads back the same way — compare
participant.role against ParticipantRole.AGENT when that matters.
Warning
None is an answer, not a missing value. A system injection, a webhook or a
scheduled run has no author; falling back to whoever spoke last is how a
tool answers one person with another person's data. Refuse, or use a
principal you configured on purpose.
See Identity Resolution for how a sender becomes an
identified participant, and examples/tool_call_context.py for a runnable
two-speaker room.
Per-Room Tool Binding¶
Tools, system prompts, and temperature can be configured per-room via binding metadata:
# Room 1: Weather assistant
await kit.attach_channel("room-1", "ai-assistant", metadata={
"system_prompt": "You are a weather assistant.",
"temperature": 0.3,
"tools": [weather_tool_dict],
})
# Room 2: Support assistant with different tools
await kit.attach_channel("room-2", "ai-assistant", metadata={
"system_prompt": "You are a support agent.",
"temperature": 0.7,
"max_tokens": 2048,
"thinking_budget": 5000,
"tools": [search_tool_dict, ticket_tool_dict],
})
| Metadata Key | Type | Description |
|---|---|---|
tools |
list[dict] |
Tool definitions (JSON Schema format) |
system_prompt |
str |
Override the channel's default system prompt |
temperature |
float |
Override the channel's default temperature |
max_tokens |
int |
Override max output tokens |
thinking_budget |
int |
Override thinking budget tokens |
Tool Policy (Access Control)¶
Control which tools are available to which roles:
from __future__ import annotations
from roomkit.channels import AIChannel
from roomkit.tools.policy import RoleOverride, ToolPolicy
policy = ToolPolicy(
allow=["get_weather", "search_*"], # Glob patterns
deny=["delete_*"], # Always blocked
role_overrides={
"supervisor": RoleOverride(
allow=["delete_*"], # Supervisors can delete
mode="replace", # Fully override base policy
),
"intern": RoleOverride(
allow=["search_*"], # Interns can only search
mode="restrict", # Intersect with base (default)
),
},
)
ai = AIChannel("ai", provider=provider, tools=[weather_tool, search_tool], tool_policy=policy)
Resolution Rules¶
- Empty allow AND empty deny → permit all (backward compatible)
- If tool matches any deny pattern → blocked
- If allow is non-empty and tool matches NO allow pattern → blocked
- Otherwise → permitted
Override Modes¶
| Mode | Behavior |
|---|---|
restrict (default) |
Deny lists union, allow lists intersect (dual-constraint) |
replace |
Override completely replaces the base policy |
Patterns use fnmatch glob syntax: search_*, mcp_*, tool_?.
What the policy covers
The policy governs every tool the channel offers, including the ones it injects itself: sandbox commands (sandbox_*), run_skill_script and plan_tasks must be allowed like any host tool. Six tools only read or unlock and are never filtered when the channel serves them itself: activate_skill, read_skill_reference, read_stored_result, find_tools, list_tools, and call_tool, which only carries a call on a fixed-declaration realtime provider: the policy judges the tool it names. A provider's native tool without a name ({"google_search": {}}) is never filtered nor hidden by Tool Search: a policy names the tools it governs. A tool of yours or of an MCP server under one of these names is filtered like any other; better, don't use them: a tool passed to the channel's tools= under a name the channel serves raises ValueError at construction (pass tool_search=False to free find_tools and list_tools), as does a realtime voice channel's tool under a name its reasoning backend's agent serves itself, and one that arrives later (a binding's tools, orchestration) is not declared, with a warning. No name is declared twice. The same rule decides what the model is offered and what it may call, and Tool Search's find_tools / list_tools never name a tool the policy denies or a skill gates. A call under an MCP alias (mcp__<server>__<tool>, which MCPToolProvider.as_tool_handler() runs as <tool>) is judged under both names, on every gate (a channel's, PolicyExternalToolHandler's): with deny=["delete_*"], mcp__crm__delete_records is refused as delete_records is. Patterns judge the tool's own name: an allow-list written in alias form (mcp__crm__*) refuses the alias calls too.
MCP Tool Provider¶
Integrate tools from an MCP (Model Context Protocol) server:
from __future__ import annotations
from roomkit.channels import AIChannel
from roomkit.tools.mcp import MCPToolProvider
async with MCPToolProvider.from_url("http://localhost:8000/mcp") as mcp:
tools = mcp.get_tools() # list[AITool]
handler = mcp.as_tool_handler() # ToolHandler
ai = AIChannel("ai", provider=provider, tool_handler=handler)
# Bind tools to a room
await kit.attach_channel("room-1", "ai", metadata={
"tools": mcp.get_tools_as_dicts(),
})
A tool the server lists under a name no provider accepts (see the AITool model) is skipped with a warning; the server's other tools stay available.
MCPToolProvider Options¶
MCPToolProvider(
url="http://localhost:8000/mcp",
transport="streamable_http", # or "sse"
tool_filter=lambda name: not name.startswith("internal_"),
headers={"Authorization": "Bearer ..."},
)
Composing Multiple Handlers¶
When you pass multiple Tool objects to tools=[...], their handlers are composed automatically — no manual composition needed.
For advanced cases where you have raw ToolHandler callables (e.g., MCP handlers, custom dispatchers), use compose_tool_handlers to chain them with first-match-wins dispatch:
from __future__ import annotations
from roomkit.tools.compose import compose_tool_handlers
local_handler = my_local_handler
mcp_handler = mcp.as_tool_handler()
combined = compose_tool_handlers(local_handler, mcp_handler)
# local_handler is tried first; if it declines the call, mcp_handler is tried
A handler declines a call by raising UnservedToolCallError, or with the earlier {"error": "Unknown tool: ..."} answer. Any other response (including other errors) is treated as a valid result and returned immediately. The last handler's answer is the composition's, a decline included: the channel then reads the call as served by nothing.
Streaming Tool Calls¶
When streaming=True (default), tool calls are processed through the streaming tool loop:
ai = AIChannel(
"ai",
provider=provider,
tools=[my_tool], # or tool_handler=handler for advanced use
streaming=True, # Default — enables streaming tool loop
)
The streaming loop emits StreamEvent objects: StreamTextDelta, StreamThinkingDelta, StreamToolCallDelta, StreamToolCall, and StreamDone. Tools are executed concurrently via asyncio.gather().
StreamToolCallDelta carries one fragment of a call's arguments as the model composes them, so a long composition is observable while it happens; StreamToolCall still follows and remains the unit of execution and persistence.
Tool Call Events¶
AIChannel automatically publishes ephemeral TOOL_CALL_DELTA, TOOL_CALL_START and TOOL_CALL_END events that you can subscribe to:
from __future__ import annotations
from roomkit.realtime import EphemeralEvent, EphemeralEventType
async def on_tool_event(event: EphemeralEvent) -> None:
if event.type == EphemeralEventType.TOOL_CALL_DELTA:
# The call being composed: its name and how far along, never the
# argument content. See the realtime-features guide.
call = event.data["tool_calls"][0]
print(f"Composing {call['name']}: {call['arguments_chars']} chars")
elif event.type == EphemeralEventType.TOOL_CALL_START:
tools = event.data["tool_calls"]
print(f"Calling: {[t['name'] for t in tools]}")
elif event.type == EphemeralEventType.TOOL_CALL_END:
# status is "completed" or "failed": a refused, failed, blocked,
# unserved or cancelled call ends "failed".
failed = [t["name"] for t in event.data["tool_calls"] if t["status"] == "failed"]
print(f"Completed in {event.data.get('duration_ms')}ms, failed: {failed}")
sub_id = await kit.subscribe_room("room-1", on_tool_event)
The streaming-only TOOL_CALL_DELTA reports the tool name and cumulative
argument size, never the argument content. An empty tool_calls terminal frame
closes every composition attempt, including cancellation, provider failure,
retry, or fallback. Counts restart when a retry begins; the complete arguments
still arrive once in TOOL_CALL_START.
Cross-turn tool memory¶
The AI context rebuilt for each turn contains MESSAGE events only — tool-call events are filtered out (providers track tool context within a turn, not across turns). Left alone, the model would lose all trace of the tools it invoked from one turn to the next: it couldn't tell which tool or source it already used, and under Tool Search it couldn't re-call a tool it used a moment ago because the catalogue is re-hidden every turn.
AIChannel closes both gaps automatically with a per-room, in-memory record of
tool usage — no configuration needed:
- A "what you did" digest — recent tool calls (name + arguments) ride
the turn's input, after the user's words and marked as the runtime's notes,
so the model knows what it already did and what it got. They are not in the
system prompt: it changes after every turn that calls a tool, and a system
prompt that changes invalidates a provider's cache of the whole history
(RFC §6.4). The room's plan (
enable_planning) travels the same way. The three most recent keep their result, up to 6,000 characters each and never more than the eviction threshold (evict_threshold_tokens) lets through: a follow-up question ("and the fifteenth board?") is answered from the data instead of invented. Each result sits in a<tool_result>block the model is told to read as data, never as instructions, since it comes from a tool and not from the prompt's author. A longer result is cut and marked so, telling the model to call the tool again rather than guess, and not to take back what it said from a result it read whole; older calls shrink to a short preview, set apart as data too. In a live conversation the result kept is what the tool returned, even when eviction gave the model a placeholder for it. Bounded by recent calls. - Images tools returned — for a model that sees, the two latest images the
agent's own tool calls in the room returned (a drawing, a screenshot) ride
each later turn's input, after the notes, each after the call that returned
it and framed as data, never instructions: the digest says what a result
showed, never what it looks like. They go in the turn's input, never an
assistant's message, which providers refuse an image in. Only an image every
vision provider takes is kept: inline (a
data:URI; a link may expire or be out of the vendor's reach), PNG, JPEG, GIF or WebP, up to 2 MB, from a live result. A turn the provider refuses (an error not retried) replays none after it. Return an image sized for the model to read (about 768 px on its long side is plenty): each costs a thousand tokens and more on every turn, counted before the history gets its share. Bounded by the latest images. - Sticky re-exposure — the distinct tool names called recently are re-revealed each turn, so a tool used once stays callable even while Tool Search hides the rest of the catalogue. Bounded by recent distinct tools — the conversation's working set — since this is the part that costs full tool schemas.
On a provider that can hold a tool declared but unseen (Anthropic, AIProvider.supports_deferred_tools), the hidden catalogue is declared that way (defer_loading) from the first round, and find_tools makes its matches callable by reference: the tool list does not change within the turn, so the provider's prompt cache holds across the reveal (RFC §6.4). A skill's gated tools are held the same way until activate_skill opens them. The declaration holds from one turn to the next as well: the tools a room's first turn showed stay the ones shown, and a tool an earlier turn opened (revealed, used, unlocked) stays held. Its reference rode a tool result the next turn's history does not replay, so the turn starts with a short exchange of its own, placed after the history and before the input: a find_tools call (or, without Tool Search, an activate_skill call for each active skill that gates one) whose result references the tools it reopens. The exchange is the turn's context only, never stored, delivered or counted as a call, a standalone instruction neither reads nor keeps the room's declaration, a fallback_provider that cannot hold tools gets them declared instead of the exchange, and the history stays in the cache where a new tool list would rewrite it all. Anthropic behind a base_url (a proxy or a gateway) holds nothing, since what it forwards to may not accept the form, and a fallback_provider that cannot hold a tool receives the tools the turn made callable, declared plainly.
Tool Search never hides a tool orchestration injected: a handoff
(handoff_conversation), a delegation (delegate_task, a supervisor's tools)
or a delegation's submit_result stays declared as a pinned tool does, is not
named by find_tools, and does not count toward the catalogue that switches
Tool Search on. The agent is told to call these tools, not to go and find them.
A BEFORE_AI_GENERATION hook sees the whole catalogue under Tool Search,
deferred tools included, so it can withdraw one: find_tools and list_tools
do not name it and a call to it is refused. An AFTER_TOOL_ROUND hook, between
two rounds, withdraws with the same guarantees, and its event names what
BEFORE_AI_GENERATION is shown (tools): the whole catalogue the policy and
skill gating let the turn reach, nothing withdrawn. A tool the hook adds is declared at
every round of the turn, as a pinned tool is, and find_tools does not name it.
A find_tools reveal counts once its call is served, as a skill activation
does (RFC §6.4): ON_TOOL_CALL judges the call before the model reads its
result, on a text turn and a realtime session alike. A SYNC hook that blocks
it reveals nothing, neither for the next round nor for the next turns, and
one that replaces its result is what the model reads. A search that finds
nothing reveals nothing, so what an earlier search revealed stays.
A hidden tool the model calls by its exact name, skipping find_tools, is
recovered for that call, and stays revealed only once the tool answered it
(served, failed, or its result withheld by an ON_TOOL_CALL hook), as the
sticky re-exposure above keeps any tool used. A call refused before the tool
ran (a BEFORE_TOOL_USE block, the handler's ToolRefusedError, arguments
that do not fit its schema), or one nothing served, reveals nothing, neither
for the next round nor for the next turns, and leaves every other reveal of
its round as it was.
The record is scoped per room and kept in memory; after a process restart it is rebuilt once per room from the channel's own persisted tool-call events, so a conversation that outlives its channel object keeps its tool memory. Another agent's calls in the room are not part of it, whatever that agent let it see, and each call keeps the arguments of its own turn. Those events hold what the model was given: a result that had been evicted comes back as a short preview, not as the stored id.
Tool Loop Configuration¶
ai = AIChannel(
"ai",
provider=provider,
tools=[my_tool],
max_tool_rounds=50, # Max iterations (default: 50)
tool_loop_timeout_seconds=300, # Hard timeout in seconds (default: 300)
tool_timeout_seconds=30, # One call's bound in seconds (default: 30)
tool_loop_warn_after=25, # Soft warning threshold (default: 25)
turn_budget_usd=0.05, # What a turn may cost (default: no budget)
)
| Parameter | Default | Description |
|---|---|---|
max_tool_rounds |
50 |
Maximum tool loop iterations before forced stop |
tool_loop_timeout_seconds |
300.0 |
Hard timeout for entire loop. None disables |
tool_loop_warn_after |
25 |
Log warning at this round count |
tool_timeout_seconds |
30.0 |
How long one tool call may take. None disables |
tool_timeouts |
None |
A bound per tool name, above the default (None for no bound) |
turn_budget_tokens |
None |
Billed tokens a turn may spend, cache included |
turn_budget_usd |
None |
What a turn may cost at the model's catalogue price |
A turn can also be capped by what it spends: turn_budget_tokens counts every token the provider bills for the turn (input, cache reads and writes, output), turn_budget_usd prices each generation at the model's catalogue rate. At the first round boundary where the turn has reached either, the loop ends budget_exceeded: the calls that round asked for do not run and no further generation is asked for, so the turn overshoots by one generation at most. Both are off by default and can be set per room (binding metadata) or per turn (AIChannelTurnConfig). A budget that is not a positive number, or a cost budget on a model with no catalogue price, raises ValueError: on the channel when it is built, otherwise in the turn that reads it. A generation the fallback_provider serves is priced at the primary provider's rate; a fallback priced otherwise is logged once (RFC §6.4).
Note
A tool result over evict_threshold_tokens (5,000 by default) is stored and replaced by a preview the model can page back with read_stored_result, or search with its query for the lines that contain one line of text: a search reads every line of the result whole, so no match means the text is absent (RFC §21.5).
When the provider refuses a round's context as too long, the channel compacts it once and replays the round. The turn's input and its notes (plan, tools already used, speakers) stay whole. When the input falls in the older half of the messages, the history before it is summarized and the long results of the turn's older tool rounds are stored like an evicted result, a short preview in their place, so the model can page them back with read_stored_result; a skill's instructions and a page already read back stay whole. Otherwise the older half is summarized. Every call keeps its result, a summary joins the user message that follows it instead of forming a second one in a row (RFC §6.4), and a context with nothing left to shorten before the input fails the round rather than cutting the input.
Tool call timeout¶
The loop's timeout is read between rounds, so it cannot stop a handler that never answers. Every call has its own bound for that (RFC §21.6): past tool_timeout_seconds, the handler is cancelled and the call fails like one whose handler raised, the model reading {"error": "Tool 'x' failed (ToolTimeoutError)"} and the observers the detail, and the turn goes on.
ai = AIChannel(
"ai",
provider=provider,
tool_handler=handler,
tool_timeout_seconds=30, # every call (default: 30)
tool_timeouts={"export_report": 120, "train": None}, # a tool's own bound
)
The same two settings exist on RealtimeVoiceChannel and ConferenceRealtimeConfig, with 10 s by default: a person waits in silence for the answer. A cascade voice agent answers through an AIChannel and takes its 30 s unless you lower it. A tool that keeps a bound of its own is not subject to the default: one that waits on another agent or a person by design (a delegation, a supervisor's or a loop's strategy tool, a HumanInputToolHandler's tools) and sandbox_bash, whose timeout argument your SandboxExecutor enforces. A bound set in tool_timeouts still applies to them. A pipeline agent's own tools are yours and bounded like any other. A TimeoutError your handler raises itself is its own failure, not an expired bound. A bound that is not a positive number, nor None, raises ValueError when the channel or the config is built.
Concurrent Tool Execution¶
When the AI requests multiple tool calls in a single round, they are executed concurrently via asyncio.gather():
# If the AI calls get_weather("Paris") and get_weather("London") simultaneously:
# Both execute in parallel, results returned together
Each tool call is independently subject to:
1. Policy check — blocked tools return an error message
2. Skill gating — tools from unactivated skills are blocked
3. Telemetry — each call gets its own SpanKind.LLM_TOOL_CALL span
Testing¶
Use MockAIProvider for deterministic tool calling tests:
from __future__ import annotations
from roomkit.channels import AIChannel
from roomkit.providers.ai.mock import MockAIProvider
# MockAIProvider can return tool calls and then final responses
provider = MockAIProvider(responses=["The weather in Paris is 22C and sunny."])
ai = AIChannel("ai", provider=provider, tools=[GetWeatherTool()])
Testing a handler on its own¶
A handler that reads the turn answers None everywhere when a test calls it
directly. tool_turn_context installs the context a tool loop would, from the
arguments you give it, and restores the previous one when the block exits, an
exception included:
from roomkit.providers.ai.base import AITool
from roomkit.tools import (
ToolCallContext,
current_response_metadata,
current_tool_actor_id,
tool_turn_context,
)
async def whoami(name: str, arguments: dict) -> str:
record = current_response_metadata()
if record is not None:
record["sources"] = ["roster"] # what the handler tells the turn
return f"you are {current_tool_actor_id()}"
async def test_the_handler_answers_the_speaker() -> None:
call = ToolCallContext(room_id="r1", tool_call_id="tc1")
with tool_turn_context(
room_id="r1",
actor_id="alice",
tools=[AITool(name="whoami", description="Say who is asking")],
call=call,
):
result = await whoami("whoami", {})
record = current_response_metadata()
assert result == "you are alice"
assert record is not None and record["sources"] == ["roster"]
| Argument | What the handler reads |
|---|---|
room_id |
current_tool_room_id() |
room |
current_tool_room(), and its id as the room id (both given must agree, or ValueError) |
actor_id |
current_tool_actor_id(); leave it out for a turn with no author |
tools |
current_tool_allowed_names(); None (the default) for a toolset not resolved, [] for an empty one |
chain_depth |
The depth a result delivered later on the turn's behalf inherits |
call |
current_tool_call(); a handler writing structured_content writes this object. Its room_id is the turn's (ValueError otherwise) |
The block always carries a response-metadata record, as an AI channel's turn does; the realtime path, which carries none, is not what it describes.