Agentic RAG via an LLM-driven tool loop.
Where Arcana.Pipeline composes RAG steps that you decide ahead of time,
Arcana.Loop lets the LLM decide what to do each turn. The controller
picks tools (search, answer, give_up) until it has enough context
to answer the question or hits a safety limit.
This is the "Agentic RAG" pattern from Singh et al.'s 2025 survey: an LLM
driven loop with tool use, as opposed to the static "Modular RAG" pipeline
in Arcana.Pipeline. Both patterns are useful and they coexist.
Quick start
{:ok, ctx} =
Arcana.Loop.new("Find episodes where a Time Lord betrayed the Doctor",
repo: MyApp.Repo,
collection: "doctor-who"
)
|> Arcana.Loop.run(controller_llm: "openai:gpt-4o-mini")
ctx.answer
ctx.tool_history
ctx.terminated_byThe loop's LLM options differ from the rest of arcana
Arcana.ask/2, Arcana.Pipeline, graph extraction and community
summarization all accept a {module, function} LLM. The loop does not, and
passing one raises ArgumentError. Two things to know before adopting it.
req_llm is required either way. run/2 refuses to start without it, because
the messages and tools the loop builds are ReqLLM.Context and ReqLLM.Tool
structs. So the loop is the one part of arcana an app cannot use while leaving
req_llm out.
You can still route the model call through your own stack, using a
three-arity function rather than a {module, function} tuple:
Arcana.Loop.run(ctx,
controller_llm: fn messages, tools, opts ->
# messages are ReqLLM.Context entries, tools are ReqLLM.Tool structs.
# Return the shape ReqLLM.Response.classify/1 produces. The loop
# routes on :type and has no fallback clause, so it must be there:
#
# {:ok, %{type: :tool_calls, tool_calls: [...]}}
# {:ok, %{type: :final_answer, text: "..."}}
# {:error, reason}
MyApp.LLM.complete_with_tools(messages, tools, opts)
end
)That is the hook for your own tracing and cost accounting. What it cannot do is
live in config/runtime.exs as data: a captured function does not serialize
into a release's sys.config, which is exactly what {module, function} was
added for elsewhere. So the loop's LLM has to be wired where code runs, not in
config.
Why the tuple is refused rather than supported: a {module, function} LLM is
called as a plain completion - prompt in, text out - with nowhere to pass tools
or read tool calls back. The loop needs both, so accepting the shape would mean
a provider-agnostic tool-calling contract for arcana. If you want that, say so
on #164.
:answer_llm carries the same restriction. The answerer is passed no tools and
asked for prose, but both roles go through the same call path, so it takes a
model spec or a three-arity function too.
Its return contract is looser, though. The :type routing above is the
controller's: the answerer only needs {:ok, %{text: binary}} with a non-empty
string, and anything else - including an error - silently keeps the
controller's own draft rather than failing the run.
The max_iterations fallback synthesizer has no draft underneath it at all,
since it only runs when the controller never called answer, so failing it
costs you more. A failed synthesis leaves :answer as nil and the run still
comes back {:ok, ctx} with terminated_by: :max_iterations.
The non-empty part of that contract belongs to the default synthesizer rather
than to the loop. A custom :synthesizer has its {:ok, text} stored
verbatim, so {:ok, ""} leaves you an empty answer instead of nil. If you
supply your own, check that the answer is present rather than just non-nil.
Two models
You can configure separate models for the loop controller and the final
answer. When :answer_llm is omitted the controller is used for both.
Termination
The loop terminates on any of:
- The controller calls the
answertool.terminated_by: :answered. - The controller calls the
give_uptool.terminated_by: :gave_up. max_iterationsis reached.terminated_by: :max_iterations.- The controller LLM returns an error.
terminated_by: :error. - The controller returns a final answer with no tool calls.
terminated_by: :answered.
Configuration
Set defaults via app config. Per-call options override these.
config :arcana, loop: [
max_iterations: 10,
controller_llm: "openai:gpt-4o-mini",
chunk_cap: 30
]Telemetry
Each phase emits events under [:arcana, :loop, :*].
References
Summary
Functions
Runs a grounding analysis on the loop's answer against the accumulated
chunks and stores the result in ctx.grounding.
Builds a new Arcana.Loop.Context for run/2.
Runs the agent loop until it terminates.
Functions
@spec ground( Arcana.Loop.Context.t(), keyword() ) :: Arcana.Loop.Context.t()
Runs a grounding analysis on the loop's answer against the accumulated
chunks and stores the result in ctx.grounding.
Reuses the Arcana.Grounder behaviour. By default uses
Arcana.Grounder.Hallmark, which scores sentence-level
faithfulness via Vectara HHEM through Bumblebee.
Skipped cases
Grounding is a no-op (ctx.grounding stays nil) when:
- The loop terminated with
terminated_by: :error(ctx.erroris set) ctx.answerisnilctx.chunksis empty — there's nothing to ground against
Options
:grounder- Module implementingArcana.Grounderor a 3-arity function(answer, chunks, opts) -> {:ok, result} | {:error, reason}. Defaults toArcana.Grounder.Hallmark. UseArcana.Grounder.LLMJudgefor LLM-as-judge faithfulness instead of NLI scoring.
Any other options are passed through to the grounder, with :question
added automatically from ctx.question.
Example
{:ok, ctx} =
Arcana.Loop.new("What is a TARDIS?", repo: repo, collection: "doctor-who")
|> Arcana.Loop.run(controller_llm: llm)
ctx = Arcana.Loop.ground(ctx)
ctx.grounding.score # 0.0-1.0 faithfulness
ctx.grounding.hallucinated_spans # unsupported sentences
ctx.grounding.faithful_spans # supported sentences + chunk attributionErrors from the grounder are swallowed: if the grounder fails, ctx.grounding
stays nil and the rest of the context is unchanged. Grounding is a
nice-to-have annotation, not a fatal step.
@spec new( String.t(), keyword() ) :: Arcana.Loop.Context.t()
Builds a new Arcana.Loop.Context for run/2.
Options
:repo- Ecto repo for retrieval tools. Falls back to:repoglobal config.:collection- Collection scope as:all, a name, or a list of names.
@spec run( Arcana.Loop.Context.t(), keyword() ) :: {:ok, Arcana.Loop.Context.t()}
Runs the agent loop until it terminates.
Returns {:ok, ctx} with the final loop context. The context's
:terminated_by, :answer, :tool_history, and :chunks fields tell
you what happened.
Options
:tools- List ofReqLLM.Toolstructs. Defaults toTools.default/0.:max_iterations- Hard cap on controller turns. Default 10.:controller_llm- Model spec for the loop controller. Required. Either aReqLLMmodel string or a functionfn messages, tools, opts -> {:ok, classified} | {:error, reason} end.:answer_llm- Optional model spec for the answerer, separate from the controller. When set, the loop's controller picks tools as usual, but the user-facing answer text is produced by the answer_llm via a separate tool-less LLM call. Use this for the "cheap controller / strong answerer" pattern: a small fast model drives the loop, a stronger model writes the final answer. Triggers on theanswertool path (rewrites the controller's draft) and on themax_iterationssynthesis fallback (used as the default synthesizer). Does not rewritegive_up(which would just dress up failure in nicer prose). When unset, the controller's text is used asctx.answerdirectly.:system_prompt- Override the default system prompt. Either a string or a functionfn opts -> string end.:chunk_cap- Maximum chunks accumulated across iterations. Default 30.:search_fn- OverrideArcana.search/2for the built-insearchtool. Used in tests; receives(query, search_opts).:search_opts- Extra options forwarded to thesearchtool's call intoArcana.search/2.:fallback_synthesis- When the loop hitsmax_iterationswithoutanswerbeing called and chunks have been accumulated, do one final tool-less LLM call to synthesize an answer from those chunks. Defaults totrue. Set tofalseto leavectx.answeras nil on max_iterations.:synthesizer- Override the synthesis function used for themax_iterationsfallback path. Receives(messages, opts)and must return{:ok, text}or{:error, reason}. When unset, the default synthesizer calls:answer_llmif set, otherwise:controller_llm. Setting:synthesizerdirectly bypasses both. Note: this only affects the fallback path. Theanswertool rewrite always uses:answer_llmdirectly when set, regardless of:synthesizer.:answer_prompt- Override the instruction text appended to the conversation when:answer_llmrewrites the controller's draft answer. String (literal text) or(opts -> string)function. Use this to control answer style without replacing the whole:answer_llm. Example:answer_prompt: "Write a one-paragraph summary. No bullet points.":synthesis_prompt- Same as:answer_promptbut for themax_iterationsfallback synthesis path. The two paths take separate options because the framing is different ("you ran out of budget" vs "the controller committed").:temperature- Sampling temperature applied to all three LLM call sites (controller, answer rewrite, fallback synthesis). When omitted, the model's own default is used.:controller_temperature- Override:temperaturefor the controller call only. Useful when you want a low temperature (e.g. 0.0-0.2) for tool routing decisions but a higher one for answer prose.:answer_temperature- Override:temperaturefor the:answer_llmrewrite call only.:fallback_temperature- Override:temperaturefor the fallback synthesis call only.