Tool Execution

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.

1from smallestai.atoms.crew.tools import ToolRegistry, function_tool
2
3class MyAgent(OutputCrewNode):
4 def __init__(self):
5 super().__init__(name="my-agent")
6 self.llm = OpenAIClient(model="gpt-4o-mini")
7
8 # Create registry and discover tools
9 self.tool_registry = ToolRegistry()
10 self.tool_registry.discover(self)
11
12 @function_tool()
13 def get_weather(self, location: str):
14 """Get weather for a location."""
15 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.

1response = await self.llm.chat(
2 messages=self.context.messages,
3 stream=True,
4 tools=self.tool_registry.get_schemas()
5)

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.

1from typing import List
2from smallestai.atoms.crew.clients.types import ToolCall
3
4MAX_TOOL_ROUNDS = 4 # safety cap; most turns complete in 1–2 rounds
5
6async def generate_response(self):
7 for _ in range(MAX_TOOL_ROUNDS):
8 response = await self.llm.chat(
9 messages=self.context.messages,
10 stream=True,
11 tools=self.tool_registry.get_schemas(),
12 )
13
14 text_parts: List[str] = []
15 tool_calls: List[ToolCall] = []
16
17 async for chunk in response:
18 if chunk.content:
19 text_parts.append(chunk.content)
20 yield chunk.content
21 if chunk.tool_calls:
22 tool_calls.extend(chunk.tool_calls)
23
24 # Record this turn's assistant message (text + any tool_calls it requested)
25 assistant_msg = {"role": "assistant", "content": "".join(text_parts)}
26 if tool_calls:
27 assistant_msg["tool_calls"] = [tc.to_dict() for tc in tool_calls]
28 self.context.add_message(assistant_msg)
29
30 # No tools requested → final response, we're done
31 if not tool_calls:
32 return
33
34 # Execute tools and feed each result back as a tool message
35 results = await self.tool_registry.execute(tool_calls=tool_calls, parallel=True)
36 for tc, result in zip(tool_calls, results):
37 self.context.add_message({
38 "role": "tool",
39 "tool_call_id": tc.id,
40 "content": result.content,
41 })
42
43 # 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:

1# Parallel (faster)
2results = await self.tool_registry.execute(tool_calls=tool_calls, parallel=True)
3
4# Sequential (if dependencies exist)
5results = 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.