Powered by AppSignal & Oban Pro

Imp 04: Tools, Agents, MCP, And RLM

livebooks/04_tools_agents_mcp_rlm.livemd

Imp 04: Tools, Agents, MCP, And RLM

imp_checkout? = fn path ->
  is_binary(path) and File.regular?(Path.join(path, "mix.exs")) and
    File.regular?(Path.join(path, "lib/imp.ex"))
end

explicit_repo = System.get_env("IMP_PATH")

if explicit_repo && not imp_checkout?.(Path.expand(explicit_repo)) do
  raise "IMP_PATH does not point to an Imp source checkout or unpacked package"
end

repo =
  [explicit_repo, Path.expand("..", __DIR__), File.cwd!()]
  |> Enum.reject(&is_nil/1)
  |> Enum.map(&Path.expand/1)
  |> Enum.find(imp_checkout?)

if repo do
  # Prefer the notebook's own Imp checkout even when Livebook was launched
  # from an unrelated Mix project. Unpacked package archives may omit
  # mix.lock, so pin it only when the source checkout actually provides it.
  install_opts =
    if File.regular?(Path.join(repo, "mix.lock")),
      do: [lockfile: Path.join(repo, "mix.lock")],
      else: []

  Mix.install([{:imp, path: repo}], install_opts)
else
  # Standalone notebook: install the tagged Git source release.
  Mix.install([{:imp, github: "deepfates/imp", tag: "v0.4.0"}])
end
live_provider_enabled? = System.get_env("LIVE_PROVIDER") == "1"

Tools

Livebook 03 handled improvement loops. This chapter adds action boundaries: tools, ReAct, MCP imports, and RLM all let an Imp program do controlled work outside a single LM completion.

Tools are ordinary named functions with metadata. ReAct and RLM use the same tool struct, which keeps policy and tracing consistent.

lookup =
  Imp.tool(:lookup, "lookup a fact", fn
    %{query: "capital-france"} -> "Paris"
    %{"query" => "capital-france"} -> "Paris"
    %{query: query} when is_binary(query) -> "Paris"
    %{"query" => query} when is_binary(query) -> "Paris"
    _other -> "Paris"
  end)

Imp.Tool.call(lookup, %{query: "capital-france"})

Owning The Loop Yourself

The packaged agent surface is the react-family spectrum above. When you want a loop the framework does not ship, compose the same pieces in ordinary Elixir: tools are values, and calling one is just a function call your own supervised process can orchestrate.

Imp.Tool.call(lookup, %{query: "capital-france"})

Policy lives where the model chooses actions: any react-family program takes tool_policy:, and a tool outside the policy becomes a recorded denial instead of an execution. The ReAct section below shows the action loop; use a deny policy when you want to exercise this boundary directly.

MCP Catalog Import

Catalog import turns external tool descriptions into Imp tools so the rest of the system does not care whether a tool was handwritten or discovered.

Schemas use the MCP spec dialect: camelCase "inputSchema" with an optional "description" (snake_case :input_schema remains an in-process fallback).

catalog =
  Imp.MCP.Catalog.new([
    %{
      "name" => "remote_lookup",
      "description" => "lookup through an imported catalog",
      "inputSchema" => %{"required" => ["key"]},
      "run" => fn %{key: "capital"} -> %{value: "Paris"} end
    }
  ])

[tool] = Imp.MCP.import_tools(catalog)
Imp.Tool.call(tool, %{key: "capital"})

ReAct

ReAct is the provider-tool-call path: the LM proposes tool calls and then submits a final signature-shaped answer through the reserved submit tool.

lm =
  Imp.LM.Static.new(
    handler: fn _messages, _opts ->
      %{
        tool_calls: [
          %{name: :lookup, arguments: %{query: "capital-france"}},
          %{name: :submit, arguments: %{answer: "Paris"}}
        ]
      }
    end
  )

react = Imp.react("question -> answer", [lookup], lm: lm, max_iters: 2, tool_policy: [:lookup, :submit])
{:ok, pred} = Imp.call(react, %{question: "Capital of France?"})
Imp.to_map(pred)

Run ReAct With A Live Provider

This cell proves the provider tool-call path with a real LM only when LIVE_PROVIDER=1 and provider credentials are present.

live_lm = fn opts ->
  if live_provider_enabled? && System.get_env("OPENAI_API_KEY") && System.get_env("OPENAI_MODEL") do
    {:ok,
     Imp.req_llm("openai:#{System.fetch_env!("OPENAI_MODEL")}",
       Keyword.merge(
         [
           api_key: System.fetch_env!("OPENAI_API_KEY"),
           temperature: 0,
           max_completion_tokens: 180
         ],
         opts
       )
     )}
  else
    {:skip, "Set LIVE_PROVIDER=1, OPENAI_API_KEY, and OPENAI_MODEL to run the live provider cells."}
  end
end
case live_lm.(max_completion_tokens: 180) do
  {:ok, lm} ->
    live_react =
      Imp.react(
        Imp.signature(
          "question -> answer",
          """
          Use the lookup tool first with query "capital-france".
          If the history already contains a lookup result of Paris, stop calling lookup and call submit with answer "Paris".
          Do not answer directly without using lookup.
          """
        ),
        [lookup],
        lm: lm,
        tool_policy: [:lookup, :submit],
        max_iters: 4
      )

    {:ok, live_prediction} =
      Enum.reduce_while(1..3, {:error, :not_run}, fn _attempt, _last ->
        case Imp.call(live_react, %{question: "What is the capital of France?"}) do
          {:ok, prediction} -> {:halt, {:ok, prediction}}
          {:error, _reason} = error -> {:cont, error}
        end
      end)
    answer = Imp.get(live_prediction, :answer)
    history = Imp.get(live_prediction, :history)

    unless answer == "Paris" and Enum.any?(history, &(&1.tool == :lookup)) do
      raise "live ReAct call returned an invalid result: #{inspect(Imp.to_map(live_prediction))}"
    end

    Imp.to_map(live_prediction)

  skip ->
    skip
end

RLM

RLM explores large or awkward context through persistent, constrained Elixir code rather than stuffing the entire context into one prompt.

actions = [
  %{reasoning: "Keep an exact symbolic value.", code: ~S|scratch = "Paris"|},
  %{
    reasoning: "Exercise the registered tool inside the environment.",
    code: ~S|fact = lookup(%{"query" => "capital-france"})|
  },
  %{reasoning: "Submit the persisted value.", code: ~S|submit(%{answer: scratch})|}
]

controller_lm =
  Imp.LM.Static.new(
    handler: fn _messages, _opts ->
      [action | rest] = Process.get(:rlm_actions)
      Process.put(:rlm_actions, rest)
      action
    end
  )

Process.put(:rlm_actions, actions)

rlm =
  Imp.rlm("context, question -> answer",
    lm: controller_lm,
    tools: [lookup],
    max_iterations: 5,
    max_llm_calls: 5,
    max_preview_chars: 20
  )

{:ok, pred} =
  Imp.call(rlm, %{
    context: String.duplicate("large context ", 200),
    question: "What is the capital?"
  })

Process.delete(:rlm_actions)

{Imp.to_map(pred), pred.metadata.rlm_trace}

RLM Lazy Context Loading

Use a serializable handle when a value is too large or expensive to show in the first controller prompt. The controller loads it only if needed.

lazy_context =
  Imp.rlm_serializable(:context, fn ->
    "large private context"
  end,
    metadata: %{source: "demo"}
  )

Process.put(:rlm_lazy_actions, [
  %{reasoning: "Materialize the lazy handle.", code: ~S|context = load("context")|},
  %{reasoning: "Inspect it without copying it into controller JSON.", code: "print(String.length(context))"},
  %{reasoning: "Submit.", code: ~S|submit(%{answer: "loaded"})|}
])

lazy_controller =
  Imp.LM.Static.new(
    handler: fn _messages, _opts ->
      [action | rest] = Process.get(:rlm_lazy_actions)
      Process.put(:rlm_lazy_actions, rest)
      action
    end
  )

lazy_rlm = Imp.rlm("context, question -> answer", lm: lazy_controller)
{:ok, lazy_pred} = Imp.call(lazy_rlm, %{context: lazy_context, question: "q"})
Process.delete(:rlm_lazy_actions)

{Imp.to_map(lazy_pred), lazy_pred.metadata.rlm_trace}

RLM Batched Subqueries

RLM code can ask a sub-LM several ordered questions and retain the results as ordinary environment values. Each batch item counts against max_llm_calls.

parent = self()

batch_controller =
  Imp.LM.Static.new(
    handler: fn _messages, _opts ->
      [action | rest] = Process.get(:rlm_batch_actions)
      Process.put(:rlm_batch_actions, rest)
      action
    end
  )

batch_sub_lm =
  Imp.LM.Static.new(
    handler: fn messages, _opts ->
      prompt = Enum.map_join(messages, "\n", & &1.content)
      send(parent, {:rlm_batch_prompt, prompt})
      %{answer: if(prompt =~ "first", do: "one", else: "two")}
    end
  )

Process.put(:rlm_batch_actions, [
  %{
    reasoning: "Run two semantic calls from inside the environment.",
    code: ~S|results = llm_query_batched(["first", "second"])|
  },
  %{reasoning: "Results persist for later computation.", code: ~S|submit(%{answer: "batched"})|}
])

batch_rlm =
  Imp.rlm("question -> answer",
    lm: batch_controller,
    sub_lm: batch_sub_lm,
    max_iterations: 3,
    max_llm_calls: 2
  )

{:ok, batch_pred} = Imp.call(batch_rlm, %{question: "parent"})
Process.delete(:rlm_batch_actions)

{Imp.to_map(batch_pred), batch_pred.metadata.rlm_trace}

RLM Budget Failure

loop_lm =
  Imp.LM.Static.new(
    handler: fn messages, _opts ->
      if Enum.map_join(messages, "\n", & &1.content) =~ "RLM extract pass" do
        %{answer: "recovered by extract"}
      else
        %{reasoning: "Inspect without submitting.", code: "1 + 1"}
      end
    end
  )

rlm = Imp.rlm("question -> answer", lm: loop_lm, max_iterations: 1)
Imp.call(rlm, %{question: "loop?"})

Run RLM With A Live Provider

This example asks the controller to emit constrained Elixir and submit from the persistent environment.

case live_lm.(max_completion_tokens: 100, response_format: %{type: "json_object"}) do
  {:ok, lm} ->
    live_rlm =
      Imp.rlm(
        Imp.signature(
          "question -> answer",
          "Return reasoning and Elixir code that assigns Paris to a variable and submits it as answer."
        ),
        lm: lm,
        max_iterations: 2,
        max_llm_calls: 2
      )

    {:ok, live_prediction} =
      Imp.call(live_rlm, %{
        question: "Capital of France?"
      })

    unless Imp.get(live_prediction, :answer) == "Paris" do
      raise "live RLM call returned an invalid result: #{inspect(Imp.to_map(live_prediction))}"
    end

    {Imp.to_map(live_prediction), live_prediction.metadata.rlm_trace}

  skip ->
    skip
end

Next: open livebooks/05_operate_and_live_checks.livemd to configure live providers, redaction, persistence, and runtime limits.