Using Legion with Ash
View SourceAsh apps already route every read and write through actions, and policies decide who may call them. A Legion tool calls your domain's code interface with the actor it reads from Vault, so your policies apply to the agent exactly as they apply to a controller.
This guide assumes you have read Adding Legion to an existing app and have a domain along these lines:
defmodule MyApp.Blog do
use Ash.Domain
resources do
resource MyApp.Post do
define :list_posts, action: :read
define :create_post, action: :create, args: [:title, :body]
end
end
endWriting a tool
defmodule MyApp.Tools.PostsTool do
@moduledoc """
Posts the signed-in user can see. Creating a post needs a title and a body.
"""
use Legion.Tool
alias MyApp.Blog
alias MyApp.Post
@visible_fields Post
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(& &1.sensitive?)
|> Enum.map(& &1.name)
@doc "Posts the signed-in user can see, up to 50"
def list, do: Blog.list_posts!(query: [limit: 50], actor: actor()) |> visible()
@doc ~S(Searches posts with a filter table, for example `{title = {eq = "Hello"}}`)
def search(filter) do
Post
|> Ash.Query.filter_input(filter)
|> Ash.Query.limit(50)
|> Ash.read!(actor: actor())
|> visible()
end
@doc ~S(Creates a post from `{title = "...", body = "..."}`)
def create(attributes) do
Blog.create_post!(attributes["title"], attributes["body"], actor: actor()) |> visible()
end
defp actor, do: Vault.get(:current_user)
defp visible(records) when is_list(records), do: Enum.map(records, &visible/1)
defp visible(record), do: Map.take(record, @visible_fields)
endThe actor comes from the Vault hooks in Adding Legion to an existing
app, with one change: they store the whole user record
instead of a map with its id, since policies read actor fields beyond the id.
Change the existing calls rather than adding one, because a second
Vault.init in the same process raises:
# the on_mount hook
Vault.init(current_user: socket.assigns.current_user)
# the plug
Vault.init(current_user: conn.assigns.current_user)Then start an agent whose tools/0 includes PostsTool as usual:
{:ok, pid} = Legion.start_link(MyApp.WriterAgent)Every call in the tool passes actor: actor(). Tools run in the sandbox's
eval process and can read Vault. Generated code can't: it only reaches the
functions your tools expose, so it has no way to read the actor or pass a
different one. If nobody initialized Vault, the actor is nil and your
policies decide what that may do. If they require an actor, a forgotten
Vault.init fails closed.
The search filter goes through Ash.Query.filter_input/2. Lua tables arrive
in Elixir as string-keyed maps, the shape filter_input accepts, so no
conversion is needed. Ash rejects references to private or
non-filterable fields with an error the model can read, and field policies
replace forbidden references with nil.
If your app already has a scope struct implementing Ash.Scope.ToOpts,
store it in Vault instead of the bare user and pass scope: Vault.get(:scope)
to your actions. Actor, tenant and context then travel together. Without a
scope, keep the tenant in Vault and pass tenant: the same way as actor:.
Return maps, not records
The Lua sandbox converts structs with Map.from_struct. An Ash record passed
through as is brings __meta__, __metadata__, empty aggregates and
calculations maps, an Ash.NotLoaded for every unloaded relationship, and
every attribute, private and sensitive? ones included.
Ash redacts sensitive? fields only in inspect. The Lua bridge copies them
through, and in the Elixir sandbox generated code can read any field off the
struct. @visible_fields keeps public, non-sensitive attributes. If the
agent needs a relationship or calculation, load it and add its name to the
list.
Pitfalls
Don't allowlist Ash, Ash.Query or your domain in the Elixir sandbox.
Generated code that can call Ash.read! can pass any actor: it likes, or
authorize?: false. The Lua sandbox only exposes Legion.Tool modules, so
this can't happen there.
Keep show_policy_breakdowns? off in production. Policy breakdowns end
up in tool errors, tool errors go into the conversation, and the agent can
repeat them to the user.
Limit reads inside the tool. Legion truncates whatever a snippet returns
to max_message_length (40 KB by default), and a snippet often returns a
tool's result as is. A capped read gives the model complete rows instead of
a cut-off dump.
Errors
A tool that raises doesn't crash the agent. The exception message goes back
to the model, prefixed with the tool and function name: a failed create
shows up as PostsTool.create: followed by Ash's message, such as
attribute title is required. The model can then fix the call. Each failed
attempt costs a round trip, and the model gets three retries (max_retries)
before the run is cancelled, so state the rules in the moduledoc, as
PostsTool does.
Calling an agent from an action
A generic action can run an agent too:
action :summarize, :string do
argument :text, :string, allow_nil?: false
run fn input, context ->
case Legion.execute(MyApp.SummaryAgent, input.arguments.text,
vault: [current_user: context.actor]
) do
{:cancel, reason} -> {:error, reason}
ok -> ok
end
end
endAsh passes the actor in the action context, and Legion's tools look for it
in Vault. The vault: option bridges the two. Don't call Vault.init inside
the action instead: the action runs in the caller's process, and
Vault.init raises if that process or an ancestor already has a vault.
That is the case whenever a LiveView that ran Vault.init calls the action.
Store and rate limiter
Legion.Store.Postgres and Legion.RateLimiter.Postgres take an
Ecto.Repo. An AshPostgres.Repo is one, so point them at your existing
repo. Legion.Store.Postgres.Migration.up() sets up both; call it from a
regular Ecto migration next to the ones mix ash.codegen generates.