Tool Calling With Actions

Copy Markdown View Source

You want model-selected tool calls with Jido.Action modules, plus deterministic terminal shapes for one-shot and multi-turn runs.

After this guide, you can use:

Define A Tool Action

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

Tool I/O Boundary

Tool actions may be pure or effectful. In ReAct-style loops, the model calls a tool because it needs the result before it can continue reasoning, so HTTP, LLM, database, file, or retrieval I/O inside run/2 is normal when that result is the tool output.

This direct I/O is separate from tool-emitted effects returned in the optional third tuple. effect_policy filters returned effects such as StateOp.SetState or Directive.Emit; it does not require every run/2 implementation to be pure.

Use directives, signals, or runtime integrations when the workflow has already decided on an outbound effect and wants the runtime to own delivery, retries, or observability.

Workflow stepUse
Fetch an account record, search an index, call a weather API, or ask another model so the current turn can continueEffectful tool action that returns the data from run/2
Send a final email, publish a webhook, emit an audit signal, or dispatch a notification after the decision is madeRuntime-owned directive, signal, or integration

One-Shot Tool Calling (CallWithTools)

One-shot mode returns a terminal turn map without executing tools automatically.

alias Jido.AI.Actions.ToolCalling.CallWithTools

params = %{
  prompt: "What is 6 * 7?",
  tools: ["multiply"]
}

context = %{
  tools: %{"multiply" => MyApp.Actions.Multiply}
}

{:ok, result} = Jido.Exec.run(CallWithTools, params, context)
# result.type == :tool_calls or :final_answer

Auto-Execute Tool Loop (CallWithTools)

Auto-execute mode runs tools and continues until a final answer or max-turn limit.

alias Jido.AI.Actions.ToolCalling.CallWithTools

params = %{
  prompt: "Use multiply to compute 6 * 7 and explain briefly.",
  tools: ["multiply"],
  auto_execute: true,
  max_turns: 5
}

context = %{
  tools: %{"multiply" => MyApp.Actions.Multiply}
}

{:ok, result} = Jido.Exec.run(CallWithTools, params, context)
# result.type == :final_answer when loop completes
# result.turns includes executed loop turns
# result.messages includes assistant/tool conversation messages

Deterministic terminal shapes:

  • completed loop: %{type: :final_answer, text: text, usage: usage, turns: turns, messages: messages, model: model}
  • max turns reached: %{type: :tool_calls, reason: :max_turns_reached, turns: max_turns, usage: usage, model: model}

Direct Tool Execution (ExecuteTool)

Use direct execution when your app already chose the tool and args.

alias Jido.AI.Actions.ToolCalling.ExecuteTool

params = %{
  tool_name: "multiply",
  params: %{a: 6, b: 7}
}

context = %{
  tools: %{"multiply" => MyApp.Actions.Multiply}
}

{:ok, result} = Jido.Exec.run(ExecuteTool, params, context)
# %{tool_name: "multiply", status: :success, result: %{product: 42}}

Tool Discovery And Security Filtering (ListTools)

ListTools defaults to excluding sensitive tool names (include_sensitive: false) and returns only public metadata (name, optional serialized schema).

alias Jido.AI.Actions.ToolCalling.ListTools

context = %{
  tools: %{
    "multiply" => MyApp.Actions.Multiply,
    "admin_delete_user" => MyApp.Actions.AdminDeleteUser
  }
}

{:ok, public_tools} = Jido.Exec.run(ListTools, %{}, context)
{:ok, all_tools} = Jido.Exec.run(ListTools, %{include_sensitive: true}, context)
{:ok, allowlisted} = Jido.Exec.run(ListTools, %{allowed_tools: ["multiply"]}, context)

Security filtering behavior:

  • default denylist filtering by sensitive name fragments (admin, delete, token, secret, etc.)
  • explicit override with include_sensitive: true
  • optional allowlist hard filter with allowed_tools: [...]

Tool Registry Precedence (Tool Map / Context / Plugin State)

CallWithTools, ExecuteTool, and ListTools resolve tool registries with this precedence:

  1. context[:tools]
  2. context[:tool_calling][:tools]
  3. context[:chat][:tools]
  4. context[:state][:tool_calling][:tools]
  5. context[:state][:chat][:tools]
  6. context[:agent][:state][:tool_calling][:tools]
  7. context[:agent][:state][:chat][:tools]
  8. context[:plugin_state][:tool_calling][:tools]
  9. context[:plugin_state][:chat][:tools]

First non-nil registry wins. This keeps behavior deterministic across direct action execution, plugin-routed calls, and fallback context paths.

Dynamic Registration On A Running Agent

{:ok, _agent} = Jido.AI.register_tool(agent_pid, MyApp.Actions.Multiply)
{:ok, true} = Jido.AI.has_tool?(agent_pid, "multiply")

Failure Mode: Tool Execution Returns :not_found

Symptom:

  • ExecuteTool returns tool-not-found error content

Fix:

  • pass a tools map in one of the registry precedence paths above
  • verify module.name/0 matches the requested tool_name

Defaults You Should Know

  • CallWithTools auto_execute default: false
  • CallWithTools max_turns default: 10 (hard-capped by validation)
  • ExecuteTool timeout default: 30_000ms
  • ListTools schema inclusion default: true
  • ListTools sensitive filtering default: enabled (include_sensitive: false)

Next