# Turn And Tool Results
You want to normalize raw LLM responses, classify them, execute tool calls, and project messages for follow-up LLM turns.
After this guide, you can:
- Build a `Jido.AI.Turn` from any provider response
- Check whether a turn requests tool execution
- Execute all requested tools and collect results
- Project assistant + tool messages for multi-turn LLM loops
- Execute tools directly without an LLM response
- Extract text from diverse provider response shapes
- Subscribe to tool execution telemetry events
## Define A Tool Action
```elixir
defmodule MyApp.Actions.Multiply do
use Jido.Action,
name: "multiply",
schema: Zoi.object(%{a: Zoi.integer(), b: Zoi.integer()})
@impl true
def run(%{a: a, b: b}, _context), do: {:ok, %{product: a * b}}
end
```
## Build A Turn From A Raw LLM Response
`from_response/2` normalizes any `ReqLLM.Response`, raw provider map, or existing turn into a canonical `%Jido.AI.Turn{}`.
```elixir
alias Jido.AI.Turn
# From a ReqLLM response returned by Jido.AI.generate_text/2
{:ok, response} = Jido.AI.generate_text(messages, model: "anthropic:claude-sonnet-4-20250514")
turn = Turn.from_response(response)
# Override the model field
turn = Turn.from_response(response, model: "my-custom-tag")
```
The turn struct contains:
- `type` — `:tool_calls` or `:final_answer`
- `text` — extracted text content
- `thinking_content` — extended thinking output (or `nil`)
- `tool_calls` — normalized list of tool call maps
- `usage` — token usage metadata
- `model` — model identifier
- `tool_results` — populated after tool execution
You can also build from an already-classified map:
```elixir
turn = Turn.from_result_map(%{type: :final_answer, text: "42", usage: %{input_tokens: 10}})
```
## Check If Tools Are Needed
```elixir
if Turn.needs_tools?(turn) do
# turn.type == :tool_calls or turn.tool_calls is non-empty
IO.puts("LLM wants to call #{length(turn.tool_calls)} tool(s)")
else
IO.puts("Final answer: #{turn.text}")
end
```
## Run All Requested Tools
`run_tools/3` executes every tool call in the turn and returns an updated turn with `tool_results` attached.
```elixir
tools = Turn.build_tools_map([MyApp.Actions.Multiply])
context = %{tools: tools}
{:ok, updated_turn} = Turn.run_tools(turn, context)
# Each tool result has this shape:
# %{
# id: "call_abc",
# name: "multiply",
# content: "{\"product\":42}",
# raw_result: {:ok, %{product: 42}, []}
# }
```
You can also pass tools via opts:
```elixir
{:ok, updated_turn} = Turn.run_tools(turn, %{}, tools: tools, timeout: 10_000)
```
## Project Messages For Follow-Up LLM Calls
After running tools, project the assistant message and tool result messages back into the conversation:
```elixir
assistant_msg = Turn.assistant_message(updated_turn)
# %{role: :assistant, content: "...", tool_calls: [...]}
tool_msgs = Turn.tool_messages(updated_turn)
# [%{role: :tool, tool_call_id: "call_abc", name: "multiply", content: "{\"product\":42}"}]
```
Append both to your message history for the next LLM call.
## Complete Custom Tool-Calling Loop
This loop calls the LLM, normalizes to a Turn, executes tools, projects messages, and calls the LLM again until a final answer is reached.
```elixir
alias Jido.AI.Turn
defmodule MyApp.ToolLoop do
@max_iterations 5
def run(initial_messages, tools_map) do
loop(initial_messages, tools_map, 0)
end
defp loop(_messages, _tools_map, @max_iterations) do
{:error, :max_iterations_reached}
end
defp loop(messages, tools_map, iteration) do
# 1. Call the LLM
{:ok, response} =
Jido.AI.generate_text(
messages,
model: "anthropic:claude-sonnet-4-20250514",
tools: Map.keys(tools_map)
)
# 2. Normalize to a Turn
turn = Turn.from_response(response)
# 3. Check if the LLM wants tools
if Turn.needs_tools?(turn) do
# 4. Execute all requested tools
{:ok, executed_turn} = Turn.run_tools(turn, %{tools: tools_map})
# 5. Project assistant + tool messages
assistant_msg = Turn.assistant_message(executed_turn)
tool_msgs = Turn.tool_messages(executed_turn)
# 6. Append to history and loop
updated_messages = messages ++ [assistant_msg | tool_msgs]
loop(updated_messages, tools_map, iteration + 1)
else
# Final answer — return the turn
{:ok, turn}
end
end
end
# Usage:
tools_map = Turn.build_tools_map([MyApp.Actions.Multiply])
messages = [
%{role: :system, content: "You are a calculator. Use the multiply tool."},
%{role: :user, content: "What is 6 * 7?"}
]
{:ok, final_turn} = MyApp.ToolLoop.run(messages, tools_map)
IO.puts(final_turn.text)
```
## Direct Tool Execution
Use `execute/4` when you know the tool name and want to call it outside an LLM loop:
```elixir
tools = Turn.build_tools_map([MyApp.Actions.Multiply])
{:ok, result, effects} = Turn.execute("multiply", %{"a" => 6, "b" => 7}, %{}, tools: tools)
# result == %{product: 42}
# effects == []
```
Use `execute_module/4` when you have the module reference directly:
```elixir
{:ok, result, effects} = Turn.execute_module(MyApp.Actions.Multiply, %{a: 6, b: 7}, %{})
# result == %{product: 42}
# effects == []
```
Both functions normalize parameters against the action schema automatically, so string-keyed maps from LLM JSON output work without manual conversion.
## Result Envelope Contract
Tool execution envelopes are canonical triples:
- `{:ok, result, effects}`
- `{:error, reason, effects}`
Legacy 2-tuples (`{:ok, result}` / `{:error, reason}`) are normalized at runtime boundaries.
Use triple pattern-matching in new code.
## ReAct Agent Tool Results
`Jido.AI.Turn.tool_results` is the low-level surface used when you build a
custom tool loop yourself. When `Jido.AI.Agent` manages the ReAct loop for you,
inspect completed tool outputs through the agent snapshot:
```elixir
{:ok, status} = Jido.AgentServer.status(pid)
tool_results = status.snapshot.details[:tool_results] || []
```
`status.snapshot.result` remains the final assistant answer. Tool result
entries keep the normalized action envelope under `:result`:
```elixir
%{
id: "call_abc",
name: "multiply",
arguments: %{"a" => 6, "b" => 7},
result: {:ok, %{product: 42}, []}
}
```
Use `snapshot.details[:conversation]` for restoring message history, not for
recovering structured tool payloads.
## Effect Policy And Ordering
- `Turn.execute/4` and `Turn.execute_module/4` filter tool-emitted effects through `context[:effect_policy]` when provided.
- Disallowed effects are dropped; allowed effects remain in the returned `effects` list.
- Tool call execution order in `run_tools/3` follows the order of `turn.tool_calls`.
- Tool actions may read runtime state snapshots from `context[:state]` (canonical, core-aligned).
- ReAct/ToT strategy orchestration injects this snapshot key automatically; user-provided values for this key are overridden.
## Text Extraction
`extract_text/1` normalizes diverse provider response shapes into a plain string:
```elixir
Turn.extract_text("hello")
# "hello"
Turn.extract_text(%{message: %{content: "hello"}})
# "hello"
Turn.extract_text(%{choices: [%{message: %{content: "hello"}}]})
# "hello"
Turn.extract_text(nil)
# ""
```
Use `extract_from_content/1` when you already have the content value (not wrapped in a response envelope):
```elixir
Turn.extract_from_content([%{type: :text, text: "part 1"}, %{type: :text, text: "part 2"}])
# "part 1\npart 2"
```
## Telemetry Events
Tool execution emits `:telemetry` events via `Jido.AI.Observe`:
| Event | Path | Measurements | Key Metadata |
|---|---|---|---|
| start | `[:jido, :ai, :tool, :execute, :start]` | `system_time` | `tool_name`, `params`, `call_id`, `run_id`, `agent_id`, `iteration` |
| stop | `[:jido, :ai, :tool, :execute, :stop]` | `duration_ms`, `duration` | `tool_name`, `result`, `call_id`, `run_id`, `agent_id`, `thread_id` |
| exception | `[:jido, :ai, :tool, :execute, :exception]` | `duration_ms`, `duration` | `tool_name`, `reason`, `call_id`, `run_id`, `agent_id`, `thread_id` |
Subscribe example:
```elixir
:telemetry.attach(
"my-tool-timer",
Jido.AI.Observe.tool_execute(:stop),
fn _event, measurements, metadata, _config ->
IO.puts("#{metadata.tool_name} took #{measurements.duration_ms}ms")
end,
nil
)
```
Sensitive parameters are sanitized via `Observe.sanitize_sensitive/1` before emission.
## Failure Mode: Tool Not Found
Symptom:
- `execute/4` or `run_tools/3` returns an error with `type: :not_found`
Fix:
- verify `module.name/0` matches the tool name the LLM requested
- pass the tools map via `context[:tools]`, `opts[:tools]`, or `context[:tool_calling][:tools]`
- inspect with `Turn.build_tools_map([YourModule])` to see registered names
## Failure Mode: Tool Execution Timeout
Symptom:
- tool result contains `type: :timeout` error
Fix:
- increase timeout: `Turn.run_tools(turn, context, timeout: 60_000)`
- check that the action's `run/2` completes within the configured timeout
## Defaults You Should Know
- Tool execution timeout: `30_000ms`
- `from_response/2` defaults `type` to `:final_answer` when no tool calls are present
- `from_response/2` defaults `text` to `""` when content is nil
- `tool_results` starts as `[]` — populated only after `run_tools/3` or `with_tool_results/2`
- `run_tools/3` on a turn with no tool calls returns `{:ok, turn}` unchanged
- `needs_tools?/1` checks both `type == :tool_calls` and non-empty `tool_calls` list
- tool execution result envelopes always include an effects list (`{:ok|:error, payload, effects}`)
## When To Use / Not Use
Use `Jido.AI.Turn` when:
- you need a custom tool-calling loop with full control over iteration
- you are building a strategy or directive that processes LLM responses
- you need to project assistant + tool messages into conversation history
Do not use `Jido.AI.Turn` when:
- `CallWithTools` with `auto_execute: true` already handles your loop — use that instead
- you only need text from a response — `Jido.AI.ask/2` returns it directly
## Next
- [Tool Calling With Actions](tool_calling_with_actions.md)
- [Context And Message Projection](thread_context_and_message_projection.md)
- [Observability Basics](observability_basics.md)