Arcana.Loop (Arcana v2.0.1)

Copy Markdown View Source

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_by

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:

  1. The controller calls the answer tool. terminated_by: :answered.
  2. The controller calls the give_up tool. terminated_by: :gave_up.
  3. max_iterations is reached. terminated_by: :max_iterations.
  4. The controller LLM returns an error. terminated_by: :error.
  5. 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.

Runs the agent loop until it terminates.

Functions

ground(ctx, opts \\ [])

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.error is set)
  • ctx.answer is nil
  • ctx.chunks is empty — there's nothing to ground against

Options

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 attribution

Errors 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.

new(question, opts \\ [])

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

run(ctx, opts \\ [])

@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 of ReqLLM.Tool structs. Defaults to Tools.default/0.
  • :max_iterations - Hard cap on controller turns. Default 10.
  • :controller_llm - Model spec for the loop controller. Required. Either a ReqLLM model string or a function fn 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 the answer tool path (rewrites the controller's draft) and on the max_iterations synthesis fallback (used as the default synthesizer). Does not rewrite give_up (which would just dress up failure in nicer prose). When unset, the controller's text is used as ctx.answer directly.
  • :system_prompt - Override the default system prompt. Either a string or a function fn opts -> string end.
  • :chunk_cap - Maximum chunks accumulated across iterations. Default 30.
  • :search_fn - Override Arcana.search/2 for the built-in search tool. Used in tests; receives (query, search_opts).
  • :search_opts - Extra options forwarded to the search tool's call into Arcana.search/2.
  • :fallback_synthesis - When the loop hits max_iterations without answer being called and chunks have been accumulated, do one final tool-less LLM call to synthesize an answer from those chunks. Defaults to true. Set to false to leave ctx.answer as nil on max_iterations.
  • :synthesizer - Override the synthesis function used for the max_iterations fallback path. Receives (messages, opts) and must return {:ok, text} or {:error, reason}. When unset, the default synthesizer calls :answer_llm if set, otherwise :controller_llm. Setting :synthesizer directly bypasses both. Note: this only affects the fallback path. The answer tool rewrite always uses :answer_llm directly when set, regardless of :synthesizer.
  • :answer_prompt - Override the instruction text appended to the conversation when :answer_llm rewrites 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_prompt but for the max_iterations fallback 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 :temperature for 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 :temperature for the :answer_llm rewrite call only.
  • :fallback_temperature - Override :temperature for the fallback synthesis call only.