Skip to navigation

Tool Execution

Register tools and handle LLM tool calls.
View as Markdown

The ToolRegistry discovers your tools, generates schemas for the LLM, and executes tool calls when the LLM requests them.

Setup

Call ToolRegistry().discover(self) in __init__ to register all @function_tool methods.

from smallestai.atoms.crew.tools import ToolRegistry, function_tool
class MyAgent(OutputCrewNode):
def __init__(self):
super().__init__(name="my-agent")
self.llm = OpenAIClient(model="gpt-4o-mini")
# Create registry and discover tools
self.tool_registry = ToolRegistry()
self.tool_registry.discover(self)
@function_tool()
def get_weather(self, location: str):
"""Get weather for a location."""
return {"temp": 72, "conditions": "Sunny"}

discover(self) scans the agent for methods decorated with @function_tool().

Calling the LLM with Tools

Pass tool_registry.get_schemas() to give the LLM your tool definitions.

response = await self.llm.chat(
messages=self.context.messages,
stream=True,
tools=self.tool_registry.get_schemas()
)

The LLM may respond with text, tool calls, or both.

Handling Tool Calls

A turn may take multiple LLM calls: the model may call a tool, then - once it sees the result - decide to call another tool before producing the final spoken response. Wrap the LLM call in a loop and exit when the model stops requesting tools.

from typing import List
from smallestai.atoms.crew.clients.types import ToolCall
MAX_TOOL_ROUNDS = 4 # safety cap; most turns complete in 1–2 rounds
async def generate_response(self):
for _ in range(MAX_TOOL_ROUNDS):
response = await self.llm.chat(
messages=self.context.messages,
stream=True,
tools=self.tool_registry.get_schemas(),
)
text_parts: List[str] = []
tool_calls: List[ToolCall] = []
async for chunk in response:
if chunk.content:
text_parts.append(chunk.content)
yield chunk.content
if chunk.tool_calls:
tool_calls.extend(chunk.tool_calls)
# Record this turn's assistant message (text + any tool_calls it requested)
assistant_msg = {"role": "assistant", "content": "".join(text_parts)}
if tool_calls:
assistant_msg["tool_calls"] = [tc.to_dict() for tc in tool_calls]
self.context.add_message(assistant_msg)
# No tools requested → final response, we're done
if not tool_calls:
return
# Execute tools and feed each result back as a tool message
results = await self.tool_registry.execute(tool_calls=tool_calls, parallel=True)
for tc, result in zip(tool_calls, results):
self.context.add_message({
"role": "tool",
"tool_call_id": tc.id,
"content": result.content,
})
# Loop back: ask the LLM what to do given the new tool results

Key points:

  • tc.to_dict() returns the canonical OpenAI-format tool-call dict - no need to construct it field by field.
  • result.content is a string. The registry runs your @function_tool return value through json.dumps (for dicts, lists) or str() (for everything else) before populating it.
  • MAX_TOOL_ROUNDS caps the loop so a misbehaving LLM can’t run away calling tools forever. Four rounds covers every realistic conversation; raise it only if you have a concrete reason.

ToolRegistry API

MethodDescription
discover(obj)Scan object for @function_tool methods
get_schemas()Return OpenAI-format tool definitions
execute(tool_calls, parallel=True)Run tool calls and return results

Parallel Execution

By default, tools run in parallel when the LLM requests multiple:

# Parallel (faster)
results = await self.tool_registry.execute(tool_calls=tool_calls, parallel=True)
# Sequential (if dependencies exist)
results = await self.tool_registry.execute(tool_calls=tool_calls, parallel=False)

[!WARNING] If your tools have dependencies-e.g., get_user_id() returns a value needed by get_user_orders(user_id)-using parallel=True will break because both tools run simultaneously. Use parallel=False for dependent tools.


Tips

Unless your tools have dependencies on each other, parallel execution is faster.

The LLM doesn’t always call tools. Only run the execution code if tool_calls is non-empty.

Print tc.name and tc.arguments before execution to debug unexpected behavior.