Tools¶
Tool system for AI function calling. See the Tool Calling & Policies guide and MCP Tool Provider guide for usage examples.
ToolPolicy ¶
Bases: BaseModel
Per-agent allow/deny rules for tool access.
Rules use :func:fnmatch.fnmatch glob patterns (e.g. "mcp_*",
"search_*").
Resolution order:
- Empty allow and empty deny → permit all (backward compatible).
- If the tool name matches any deny pattern → blocked.
- If allow is non-empty and the tool name matches no allow pattern → blocked.
- Otherwise → permitted.
In short: deny always wins, and a non-empty allow list is a whitelist.
Role overrides ~~~~~~~~~~~~~~
role_overrides maps :class:~roomkit.models.enums.ParticipantRole string
values (e.g. "observer", "member") to :class:RoleOverride instances.
Call :meth:resolve with a role to obtain an effective ToolPolicy that
merges the base rules with the role-specific override.
resolve ¶
Return an effective ToolPolicy for the given role.
If role is None or has no override entry, returns self
unchanged (backward compatible).
RoleOverride ¶
Bases: BaseModel
Per-role tool policy override.
mode controls how the override combines with the base policy:
"restrict"(default): deny lists are unioned, allow lists are intersected (a tool must pass both the base and override allow lists)."replace": the override completely replaces the base policy.
MCPToolProvider ¶
Discover and invoke tools from an MCP server.
Supports both streamable_http (default) and sse transports.
Usage::
async with MCPToolProvider.from_url("http://localhost:8000/mcp") as mcp:
tools = mcp.get_tools() # list[AITool]
handler = mcp.as_tool_handler() # ToolHandler for AIChannel
from_url
classmethod
¶
Create an MCPToolProvider for the given URL.
The provider is not connected until used as an async context manager.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
MCP server URL. |
required |
transport
|
str
|
|
'streamable_http'
|
tool_filter
|
Callable[[str], bool] | None
|
Optional predicate to include only matching tool names. |
None
|
headers
|
dict[str, str] | None
|
Optional HTTP headers sent with every request. |
None
|
Returns:
| Type | Description |
|---|---|
MCPToolProvider
|
An MCPToolProvider instance (not yet connected). |
get_tools_as_dicts ¶
Return discovered tools as plain dicts (for binding metadata).
call_tool
async
¶
Call a tool on the MCP server and return the result as a string.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Tool name. |
required |
arguments
|
dict[str, Any]
|
Tool arguments dict. |
required |
timeout
|
float
|
Maximum seconds to wait for a response. |
30.0
|
Returns:
| Type | Description |
|---|---|
str
|
Result string. Single TextContent → plain text; multi-part → JSON array; |
str
|
error results → |
as_tool_handler ¶
Return a ToolHandler suitable for AIChannel(tool_handler=...).
Unknown tools (not from this MCP server) return
{"error": "Unknown tool: <name>"}, which allows composition
via compose_tool_handlers.
compose_tool_handlers ¶
Chain multiple ToolHandlers so the first one that handles a tool wins.
Each handler is tried in order. If a handler returns a JSON object with
{"error": "Unknown tool: ..."} the next handler is tried. The last
handler's result is always returned as-is (even if it's an unknown-tool
error).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*handlers
|
ToolHandler
|
Two or more ToolHandler callables. |
()
|
Returns:
| Type | Description |
|---|---|
ToolHandler
|
A single ToolHandler that dispatches to the first matching handler. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fewer than two handlers are provided. |
Per-Call Context¶
A tool handler receives only (name, arguments). These accessors read the turn
it is running under from a contextvar — see
What a handler knows about the call.
current_tool_room_id ¶
Room id of the tool loop the caller is executing under.
Returns None when called outside a tool loop.
current_tool_actor_id ¶
Participant id of whoever's turn the caller is executing under.
The author of the event that woke the channel this round. Read it rather than the identity a handler captured when it was built — one channel object serves every room and every speaker, so a captured identity is whoever happened to attach it.
It names the turn; it does not authenticate it. The value is a room
Participant.id, and the inbound pipeline only substitutes the resolved
Identity.id for it once identification succeeds — a turn still pending,
ambiguous or unknown carries whatever the channel supplied, or a synthetic
pending-…, and reads back just as non-None. A handler that reaches
a person's data with it resolves it first: load the participant, require
Participant.identification to be IDENTIFIED, and take
Participant.identity_id as the principal.
The author need not be human, either. In a multi-agent room the waking
event may be another agent's, whose participant id reads back the same
way — compare the participant's role against ParticipantRole.AGENT
when that distinction matters.
None outside a tool loop, and None when the turn has no
participant behind it (a system injection, a webhook, a scheduled run).
A caller that needs a person then decides for itself — refuse, or fall
back to a principal it configured on purpose — rather than borrow whoever
spoke last.
current_tool_allowed_names ¶
Names of every tool in the current turn's resolved toolset.
_build_context stamps the turn's full toolset (config-provider
result plus channel-injected tools) into the loop context; a host's
tool handler can validate an incoming call against it instead of an
attach-time snapshot that goes stale on shared channels. Includes
skill-gated tools whose visibility is filtered per round — gating
is presentation, not an execution boundary.
Returns None outside a tool loop or before context build, so
hosts can fall back to their own allowlist.
current_tool_call ¶
The per-call context of the tool call the caller is executing under.
What _run_one set before invoking the handler — the call's id, its
room, its channel — and the reverse channel the handler may fill:
structured_content, the MCP structured result the tool-call events
carry verbatim for UI surfaces. A host that rewrites a result before the
model reads it (a provider's private address turned into its own relay
link, say) reaches the structured copy here, so the persisted event does
not keep what the text no longer says.
None outside a tool call.
ToolCallContext
dataclass
¶
Contextvar payload carrying tool-call metadata.
The ToolHandler protocol is (name, arguments) → str — it does not
receive room_id, tool_call_id or channel_id. This payload
bridges the gap: _ai_tools._run_one() sets it before calling the
handler, and a handler that needs the call's origin reads it. Safe
with :func:asyncio.gather, which creates Tasks with copied contexts.
structured_content is the reverse channel: the ToolHandler contract
returns only a string, but MCP tools can produce a structured result
(CallToolResult.structuredContent) that UI surfaces need verbatim —
the LLM-facing string may be truncated/evicted when large. A handler
that has one sets it here; _run_one() reads it back after the call
and carries it on the tool-call events untouched by eviction.
current_response_metadata ¶
The response-metadata record of the turn the caller is executing under.
The one mapping RoomKit merges into every MESSAGE event the turn produces
(see :mod:roomkit.models.response_metadata): a memory provider writing it
during context build, a BEFORE_AI_GENERATION hook writing
event.ai_context.response_metadata and a tool handler writing here all
reach the same object. A tool handler is the case this exists for — the
ToolHandler protocol hands it nothing but (name, arguments), and a
document it read is a fact about the turn, not about the tool's string
result.
Returns None when no loop context is set (a realtime pipeline, a direct
call): the caller then has nothing to attribute to, and writes nothing. A
loop started without a turn — no handle_event above it — carries a
record of its own that no MESSAGE event is built from; writes to it are
harmless and go nowhere.
Turn Response Metadata¶
The record every writer of a turn shares — see What a handler can tell the turn.
ResponseMetadata ¶
Bases: MutableMapping[str, Any]
Turn-level metadata merged into every MESSAGE event the turn produces.
Behaves as a dict for every reader and writer ([...], .get,
.update, **, dict(...), ==); the only thing it adds is that
Pydantic keeps the instance instead of copying it, so one turn has one
record. A bare mapping passed where this type is expected is wrapped — the
caller's dict is then a snapshot, which is what passing a literal means.
Each MESSAGE event carries the record as it stands when the event is created: a streamed segment persisted before a tool round shows what was known then, the final answer shows everything the turn learned; the non-streaming path builds all its events at the end, so they read alike.
coerce
classmethod
¶
The instance itself, or a bare mapping wrapped — anything else is refused.
Human-in-the-Loop¶
HumanInputHandler ¶
Manages pending human input requests.
Core lifecycle::
pending = await handler.create("AskUser", args, room_id="r1", ...)
# → request is answerable from here on; the
# ON_USER_INPUT_REQUIRED notification runs alongside
result = await handler.wait(pending.pending_id, timeout=300)
# → blocks until resolve() / reject() / timeout
Two invariants the caller can rely on:
- The notification never gates the answer.
create()arms the request and returns; the_on_input_requiredcallback runs in a background task. A human who answers while that callback is still running — a slow WebSocket broadcast, a hook burning its 30 s budget — is answering a request that is already listening. A denial coming back from the callback rejects the request, andwait()reports it. - A recorded outcome stays readable. A request settling — answered,
rejected, timed out — is kept in a bounded retention (retention
entries, newest kept), and
wait()replays it once the request has left the active set. Only a genuinely unknown id raisesValueError, so neither a second read nor a host that keeps its own bookkeeping can turn an answer that arrived into a hard failure. - A channel scope belongs to a channel object, not to its id. A host
that rebuilds the channel serving an id — the same agent re-attached to
a second room — hands the same shared handler a succession of owners.
Registering re-opens the scope, and the departing owner's
close()is a no-op once a newer one has taken over, so a predecessor being torn down cannot silence its live successor.
The _on_input_required callback is injected by the framework
(via register_channel hook builder) or set by the application
directly.
close
async
¶
Stop notifications and settle requests owned by one channel.
When channel_id is omitted, all work owned by this handler is
stopped. Channel-scoped closing lets a handler be shared safely by
multiple :class:~roomkit.channels.ai.AIChannel instances.
registration is the token :meth:_set_on_input_required handed
the closing channel. Passing it makes the close belong to that channel
object rather than to the id it used: a channel displaced from the
registry and torn down later closes nothing, because the id is already
serving its replacement. Omitting it closes the scope unconditionally,
which is what a lone owner and a manual host call both want.
create
async
¶
create(tool_name, arguments, *, room_id='', tool_call_id='', channel_id='', channel_type=AI, actor_id=None)
Register a new pending input request and schedule the callback.
Returns as soon as the request is answerable — the
_on_input_required callback runs in a background task and a
denial from it rejects the request wherever wait() has got to.
wait() owns this request's cleanup; for a request no one will
wait on, use :meth:create_detached.
actor_id names whose turn raised the request, so a notification
layer can ask that person rather than the whole room. The native
:class:HumanInputToolHandler fills it from the tool loop; a caller
driving its own loop passes what it knows.
create_detached
async
¶
create_detached(tool_name, arguments, *, room_id='', tool_call_id='', channel_id='', channel_type=AI, actor_id=None)
Register a pending request that no one will :meth:wait on.
For runtimes that own their own tool loop — a Claude Code sandbox,
say — where create() exists to raise the request and the answer
travels back another way. Nothing here retires the request, so its
creator MUST call :meth:release when done with it; otherwise the
entry lives as long as the handler.
wait
async
¶
Block until the request is resolved, rejected, or times out.
An outcome already reached and consumed is replayed from the retention, so waiting twice — or waiting after someone else dropped their own record of the request — reports what happened rather than an error.
Returns:
| Type | Description |
|---|---|
str
|
The result string on resolution. |
Raises:
| Type | Description |
|---|---|
TimeoutError
|
If the timeout expires, or if a retained request had timed out. |
RuntimeError
|
If the request was rejected. |
ValueError
|
If pending_id is unknown — never seen, or retired long enough ago to have been evicted from the retention. |
release ¶
Drop a request whose cleanup the caller owns.
The counterpart of :meth:create_detached. A request still
unanswered is rejected on the way out, so a stray waiter unblocks
instead of hanging; the outcome goes to the retention either way and
stays readable by :meth:wait.
Returns True if an active request was dropped.
resolve ¶
Resolve a pending request with a result.
Returns True if the request was found and resolved.
reject ¶
Reject a pending request.
Returns True if the request was found and rejected.
HumanInputToolHandler ¶
ToolHandler wrapper that blocks on human input for specified tools.
Composes with other handlers via
:func:~roomkit.tools.compose.compose_tool_handlers. Falls through
(returns "Unknown tool" error) for non-matching tool names so
the compose chain continues to the next handler.
Pass this to :class:~roomkit.channels.ai.AIChannel via the
human_input_handler parameter — the channel auto-composes it
and the framework injects the ON_USER_INPUT_REQUIRED hook
callback at registration time.
PendingInput
dataclass
¶
PendingInput(pending_id, tool_name, arguments, room_id, tool_call_id, channel_id, status=PENDING, result=None, reject_reason=None, detached=False, created_at=_utcnow(), actor_id=None, _event=Event())
A pending human input request.
Mutable — transitions from PENDING to RESOLVED/REJECTED/TIMED_OUT
when the application calls :meth:HumanInputHandler.resolve or
:meth:HumanInputHandler.reject.
detached
class-attribute
instance-attribute
¶
No one will call wait() on this request — its creator frees it with
:meth:HumanInputHandler.release. wait() owns the cleanup of every
other request.
actor_id
class-attribute
instance-attribute
¶
Participant whose turn raised this request, when the tool loop knew one.
A request that names nobody is a request a notification layer has to
broadcast, and an answer it cannot attribute. None when the turn had no
author (a system injection, a webhook, a scheduled run) or when the creator
runs its own tool loop and did not supply one. It names the turn without
authenticating it — resolve it against the room's roster before treating it
as a principal, as current_tool_actor_id() documents.
PendingInputEvent
dataclass
¶
PendingInputEvent(pending_id, tool_name, arguments, room_id, tool_call_id, channel_id, channel_type, timestamp=_utcnow(), actor_id=None)
Event fired through ON_USER_INPUT_REQUIRED hooks.
Carries the pending request details so notification layers (WebSocket, REST, etc.) can inform the user.
timestamp
class-attribute
instance-attribute
¶
When the pending request was created.
actor_id
class-attribute
instance-attribute
¶
Participant whose turn raised the request, when the tool loop knew one.
What lets a notification layer ask the person who asked rather than
everyone in the room. Appended rather than grouped with the origin fields
above so existing positional construction keeps working. None when the
turn had no author.
PendingInputStatus ¶
Bases: StrEnum
Status of a pending human input request.