Parallel Tool Calls¶
This notebook demonstrates how a single LLM response can contain
multiple tool call requests, and how to execute them all before
returning the results in one continue_chat_with_tool_results() call.
We use the hailstone_step_func tool from Chapter 3 and ask the LLM
to apply it to several numbers at once. The LLM batches the calls
in a single response; we execute each one and send all results back
together.
Chapter 3 concept:
chat()returns an assistantChatMessagewhosetool_callsfield can hold any number of requests. Collecting all results before callingcontinue_chat_with_tool_results()is the correct pattern — and exactly whatLLMAgentautomates in ch04.
Setting up the LLM¶
I recommend running these notebooks on Ollama Cloud. It serves much larger models than most laptops can run. The default cloud model is picked by benchmarking candidates on these notebooks, and an automated run checks every notebook against it each week. The notebook status page shows the current model and results.
- Create an API key at https://ollama.com/settings/keys.
- Export it before launching Jupyter:
export OLLAMA_API_KEY=<your key>. - That's all. When a notebook builds its LLM, it detects the key and uses Ollama Cloud.
Prefer to run locally? Install Ollama from https://ollama.com/download
and pull the model with ollama pull qwen3:14b. If no key is set, the
notebook starts the Ollama service for you, so there is no need to run
ollama serve. Local models are smaller, so your outputs will differ from
the recorded ones.
Prefer OpenAI or Anthropic?
- Install the extra:
uv sync --extra openaioruv sync --extra anthropic. - Export
OPENAI_API_KEYorANTHROPIC_API_KEYbefore launching Jupyter. If you forget, you will be prompted for it, which keeps the key out of the saved notebook. - Set
LLM_PROVIDER=openai(oranthropic), or passprovider="openai"tomake_llm(). A key on its own never switches providers, so one exported for unrelated work cannot reroute you.
The switch applies wherever a notebook builds its LLM with make_llm().
A notebook that constructs OllamaLLM directly stays on Ollama. Output on
gpt-5 or Claude will differ from the recorded outputs, and prompt-sensitive
examples (the ch09 evaluator pattern, ch08 supervised trajectories) may
behave noticeably differently.
# Uncomment the line below to install `llm-agents-from-scratch` from PyPI
# !pip install llm-agents-from-scratch
from llm_agents_from_scratch.notebook_utils import make_llm
Defining the Tool¶
hailstone_step_func performs a single step of the
Collatz sequence:
halve the number if it is even, otherwise apply 3x + 1.
from llm_agents_from_scratch.tools import SimpleFunctionTool
def hailstone_step_func(x: int) -> int:
"""Perform a single step of the Hailstone (Collatz) sequence."""
if x % 2 == 0:
return x // 2
return 3 * x + 1
hailstone_tool = SimpleFunctionTool(func=hailstone_step_func)
Step 1 — Eliciting Parallel Tool Calls¶
We ask the LLM to apply the hailstone step to three numbers at once. A well-prompted model will return all three tool call requests in a single assistant message rather than one at a time.
llm = make_llm()
user_input = (
"Apply the hailstone_step_func to each of the following numbers: "
"10, 15, and 27. "
"Call the tool once for each number."
)
user_msg, assistant_msg = await llm.chat(
user_input,
tools=[hailstone_tool],
)
print(f"Tool calls returned: {len(assistant_msg.tool_calls)}")
for tc in assistant_msg.tool_calls:
print(f" → {tc.tool_name}({tc.arguments})")
✓ Using Ollama Cloud (kimi-k2.7-code:cloud)
Tool calls returned: 3
→ hailstone_step_func({'x': 10})
→ hailstone_step_func({'x': 15})
→ hailstone_step_func({'x': 27})
Step 2 — Executing All Tool Calls¶
We iterate over every ToolCall in the assistant message and execute
each one, collecting the ToolCallResult objects.
tool_call_results = [hailstone_tool(tc) for tc in assistant_msg.tool_calls]
for tc, result in zip(
assistant_msg.tool_calls,
tool_call_results,
strict=False,
):
print(
f" hailstone_step_func(x={tc.arguments['x']!r}) → {result.content}",
)
hailstone_step_func(x=10) → 5 hailstone_step_func(x=15) → 46 hailstone_step_func(x=27) → 82
Step 3 — Returning All Results in One Call¶
We pass the full list of ToolCallResult objects to
continue_chat_with_tool_results() in a single batch.
The LLM receives all three results at once and produces a final answer.
new_messages, final_response = await llm.continue_chat_with_tool_results(
tool_call_results=tool_call_results,
chat_history=[user_msg, assistant_msg],
tools=[hailstone_tool],
)
print(final_response.content)
Here are the results of applying `hailstone_step_func` to each number: - 10 → **5** - 15 → **46** - 27 → **82**
Full Conversation at a Glance¶
Printing the complete message sequence shows the structure the
LLMAgent manages automatically: user → parallel tool requests →
tool results (one per call) → final answer.
all_messages = [user_msg, assistant_msg, *new_messages, final_response]
for msg in all_messages:
role = msg.role.value
if msg.tool_calls:
calls = ", ".join(
f"{tc.tool_name}({tc.arguments})" for tc in msg.tool_calls
)
print(f"[{role:10s}] <tool calls> {calls}")
else:
preview = msg.content[:80].replace("\n", " ")
print(f"[{role:10s}] {preview}")
[user ] Apply the hailstone_step_func to each of the following numbers: 10, 15, and 27.
[assistant ] <tool calls> hailstone_step_func({'x': 10}), hailstone_step_func({'x': 15}), hailstone_step_func({'x': 27})
[tool ] { "tool_call_id": "a1b0015c-afa9-4664-849b-63dd4d93f177", "content": "5"
[tool ] { "tool_call_id": "a7ec38e6-c1da-4566-8f1f-261061766fea", "content": "46
[tool ] { "tool_call_id": "aba73254-ea17-4e3c-8ce8-d796cb3c86e8", "content": "82
[assistant ] Here are the results of applying `hailstone_step_func` to each number: - 10 → *