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_byTwo 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- Single collection name (becomes[collection]).:collections- List of collection names. Takes precedence over:collection.
@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.