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.