From bf5c00ad852eeaa58fdc5910cbadd029f22b2e4e Mon Sep 17 00:00:00 2001 From: Oliver Severin Mulelid-Tynes Date: Mon, 29 Jun 2026 16:09:46 +0200 Subject: [PATCH] feat: expose generic eval runtime --- .formatter.exs | 2 + documentation/dsls/DSL-AshLua.Domain.md | 11 +- documentation/dsls/DSL-AshLua.EvalActions.md | 33 +- documentation/how_to/integrate-with-ash-ai.md | 86 +++- .../how_to/use-ash-lua-as-a-runtime.md | 130 ++++++ lib/ash_lua.ex | 17 +- lib/ash_lua/domain.ex | 31 +- lib/ash_lua/domain/action.ex | 3 +- lib/ash_lua/domain/namespace.ex | 3 +- lib/ash_lua/encoder.ex | 23 +- lib/ash_lua/eval.ex | 384 ++++++++++++++++++ lib/ash_lua/eval_actions.ex | 36 +- lib/ash_lua/eval_actions/info.ex | 5 + lib/ash_lua/eval_actions/run/docs.ex | 58 +-- lib/ash_lua/eval_actions/run/eval.ex | 120 +----- lib/ash_lua/runtime.ex | 15 +- lib/ash_lua/surface.ex | 182 ++++++++- mix.exs | 4 +- test/ash_lua/eval_actions_test.exs | 5 +- test/ash_lua/eval_test.exs | 97 +++++ test/ash_lua/surface_test.exs | 49 +++ test/ash_lua_test.exs | 18 + test/support/fixtures/surface.ex | 15 +- test/support/fixtures/surface_mcp_actions.ex | 2 +- 24 files changed, 1079 insertions(+), 250 deletions(-) create mode 100644 documentation/how_to/use-ash-lua-as-a-runtime.md create mode 100644 lib/ash_lua/eval.ex create mode 100644 test/ash_lua/eval_test.exs diff --git a/.formatter.exs b/.formatter.exs index bb9cc52..ba78663 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -12,9 +12,11 @@ spark_locals_without_parens = [ expose?: 1, field_names: 1, forbidden_fields: 1, + labels: 1, name: 1, namespace: 1, namespace: 2, + namespace: 3, otp_app: 1, resource: 1, resource: 2 diff --git a/documentation/dsls/DSL-AshLua.Domain.md b/documentation/dsls/DSL-AshLua.Domain.md index a531cee..d751255 100644 --- a/documentation/dsls/DSL-AshLua.Domain.md +++ b/documentation/dsls/DSL-AshLua.Domain.md @@ -36,7 +36,7 @@ end ### lua.namespace ```elixir -namespace name +namespace name, labels \\ [] ``` @@ -56,7 +56,7 @@ end ``` namespace "storefronts.pages" do - action :list, MyApp.StorefrontPage, :list_for_storefront + action :list, MyApp.StorefrontPage, :list_for_storefront, labels: [:public, :read_model] end ``` @@ -68,6 +68,7 @@ end | Name | Type | Default | Docs | |------|------|---------|------| | [`name`](#lua-namespace-name){: #lua-namespace-name .spark-required} | `String.t \| list(String.t)` | | The public Lua namespace. Dotted strings are split into nested Lua tables, so "storefronts.pages" exposes `storefronts.pages.*`. | +| [`labels`](#lua-namespace-labels){: #lua-namespace-labels } | `list(atom) \| [labels: list(atom)]` | `[]` | Labels inherited by every mapped action in this namespace. Prefer action-level labels when individual actions need different eval surfaces. | @@ -84,7 +85,7 @@ Expose an Ash action at a public Lua function name inside a namespace. ### Examples ``` namespace "pages" do - action :list, MyApp.StorefrontPage, :list_for_storefront + action :list, MyApp.StorefrontPage, :list_for_storefront, labels: [:public, :read_model] end ``` @@ -98,7 +99,11 @@ end | [`name`](#lua-namespace-action-name){: #lua-namespace-action-name .spark-required} | `atom` | | The Lua function name inside the namespace. | | [`resource`](#lua-namespace-action-resource){: #lua-namespace-action-resource .spark-required} | `module` | | The Ash resource that owns the action. | | [`action`](#lua-namespace-action-action){: #lua-namespace-action-action .spark-required} | `atom` | | The internal Ash action to call. | +### Options +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`labels`](#lua-namespace-action-labels){: #lua-namespace-action-labels } | `list(atom)` | `[]` | Labels that describe this mapped Lua action. `AshLua.EvalActions` can use these labels to expose individual actions. | diff --git a/documentation/dsls/DSL-AshLua.EvalActions.md b/documentation/dsls/DSL-AshLua.EvalActions.md index 3d732fb..0112651 100644 --- a/documentation/dsls/DSL-AshLua.EvalActions.md +++ b/documentation/dsls/DSL-AshLua.EvalActions.md @@ -11,26 +11,27 @@ defmodule MyApp.Agents.MCPActions do use Ash.Resource, extensions: [AshLua.EvalActions] eval_actions do - resource MyApp.Posts.Post, actions: [:read, :get_statistics] - resource MyApp.Posts.Comment, actions: [:read] + labels [:public] end end ``` -The synthesized actions inherit the caller's actor / tenant / context — both +The synthesized actions inherit the caller's actor / tenant / context. Both the script body and the documentation rendering are constrained to the -configured `(resource, action)` pairs, and every Ash call inside the Lua -script flows through the standard authorization machinery. +configured action labels, and every Ash call inside the Lua script flows +through the standard authorization machinery. ## eval_actions Configures the Lua surface exposed to the synthesized `:eval` and `:docs` actions. -Each `resource` entry pairs a resource module with the set of action names -that the script (and the generated docs) is allowed to see. Use this to -apply principle-of-least-privilege per agent surface — only the listed -actions become callable from Lua and only those entrypoints appear in -`:docs` output. +Prefer `labels` to expose mapped Lua actions declared on the domain with +`lua do namespace ... action ..., labels: [...] end`. This keeps the eval +surface tied to the same public Lua surface used everywhere else. + +The legacy `resource` entries remain supported for derived surfaces and +fine-grained compatibility. When both `labels` and `resource` entries are +configured, the resource/action list narrows the labelled action surface. ### Nested DSLs @@ -40,8 +41,15 @@ actions become callable from Lua and only those entrypoints appear in ### Examples ``` eval_actions do - resource MyApp.Posts.Post, actions: [:read, :get_statistics] - resource MyApp.Posts.Comment, actions: [:read] + labels [:public] +end + +``` + +``` +eval_actions do + labels [:public] + resource MyApp.Posts.Post, actions: [:read] end ``` @@ -53,6 +61,7 @@ end | Name | Type | Default | Docs | |------|------|---------|------| +| [`labels`](#eval_actions-labels){: #eval_actions-labels } | `list(atom)` | `[]` | Mapped action labels to expose to the eval/docs actions. An action is included only when it has all requested labels. | | [`eval_action_name`](#eval_actions-eval_action_name){: #eval_actions-eval_action_name } | `atom` | `:eval` | Name of the synthesized eval action. Defaults to `:eval`. | | [`docs_action_name`](#eval_actions-docs_action_name){: #eval_actions-docs_action_name } | `atom` | `:docs` | Name of the synthesized docs action. Defaults to `:docs`. | | [`otp_app`](#eval_actions-otp_app){: #eval_actions-otp_app } | `atom` | | OTP app to scan when building the manifest. Defaults to the agent resource's domain's `:otp_app`. | diff --git a/documentation/how_to/integrate-with-ash-ai.md b/documentation/how_to/integrate-with-ash-ai.md index f398653..8e1e241 100644 --- a/documentation/how_to/integrate-with-ash-ai.md +++ b/documentation/how_to/integrate-with-ash-ai.md @@ -20,18 +20,39 @@ and tenant attached. ## The shape -Define one resource per "agent surface" you want to expose: +Declare the public Lua namespaces on your Ash domains, then define one +resource per "agent surface" you want to expose: ```elixir +defmodule MyApp.Content do + use Ash.Domain, + otp_app: :my_app, + extensions: [AshLua.Domain] + + lua do + namespace "posts" do + action :list, MyApp.Posts.Post, :read, labels: [:public, :read_model] + action :stats, MyApp.Posts.Post, :get_statistics, labels: [:public, :read_model] + end + + namespace "comments" do + action :list, MyApp.Posts.Comment, :read, labels: [:public, :read_model] + end + + namespace "accounts" do + action :create_user, MyApp.Accounts.User, :create, labels: [:public, :writes] + action :list_users, MyApp.Accounts.User, :read, labels: [:public] + end + end +end + defmodule MyApp.Agents.MCPActions do use Ash.Resource, domain: MyApp.Agents, extensions: [AshLua.EvalActions] eval_actions do - resource MyApp.Posts.Post, actions: [:read, :get_statistics] - resource MyApp.Posts.Comment, actions: [:read] - resource MyApp.Accounts.User, actions: [:read, :create] + labels [:public] end end ``` @@ -40,14 +61,16 @@ That's the whole declaration. The extension synthesizes two generic actions on `MCPActions`: * **`:eval`** — takes a Lua `script` and runs it through `AshLua.eval!/2`, - scoped to *only* the listed `(resource, action)` pairs. Returns + scoped to *only* mapped actions matching the configured labels. Returns `%{result, error}` (mirroring the in-script `(result, err)` convention). * **`:docs`** — returns markdown documentation for the same scoped surface, in one of three modes: - * no arguments → the full rendered page (`AshLua.Docs.full_doc/1`); + * no arguments → the compact index (`AshLua.Docs.index_doc/1`); * `name: "..."` → the focused page for that callable, type, or topic; * `search: "..."` → a ranked list of matching ids, intended as a discovery aid (then follow up with the same action using `name`). + Use `name: "full"` to request the full rendered page + (`AshLua.Docs.full_doc/1`). `name` and `search` are mutually exclusive. @@ -65,7 +88,7 @@ eval_actions do eval_action_name :run_lua docs_action_name :describe_lua - resource MyApp.Posts.Post, actions: [:read] + labels [:public, :read_model] end ``` @@ -87,14 +110,22 @@ actions to advertise as MCP tools. ## Scoping the surface -`eval_actions` is the source of truth for which operations the script (and -generated docs) can see. The script can only call `..` -paths that correspond to a listed `(resource, action)` pair; everything else -is invisible (no entry in the docs, no callable in the Lua environment). +Domain namespaces are the source of truth for the public Lua surface. +`eval_actions` selects which mapped actions the script and generated docs can +see. The script can only call public paths from actions whose labels match the +configured `labels`; everything else is invisible (no entry in the docs, no +callable in the Lua environment). + +When multiple labels are listed, a mapped action must have all of them to be +included. A namespace can also declare `labels: [...]`; those labels are +inherited by every action inside it, but action-level labels are what give you +individual resolution. The legacy `resource ..., actions: ...` selector still +works for derived surfaces and can be combined with labels to narrow a labelled +surface by underlying Ash action. This is the natural place to apply "principle of least privilege" — expose -only the actions that are safe and useful for the agent you're building, and -omit anything destructive or expensive. You can run multiple agent resources +only actions that are safe and useful for the agent you're building, and omit +anything destructive or expensive. You can run multiple agent resources side-by-side, each with its own scope: ```elixir @@ -104,8 +135,7 @@ defmodule MyApp.Agents.ReadOnlyMCP do extensions: [AshLua.EvalActions] eval_actions do - resource MyApp.Posts.Post, actions: [:read] - resource MyApp.Posts.Comment, actions: [:read] + labels [:read_model] end end @@ -115,8 +145,7 @@ defmodule MyApp.Agents.SupportMCP do extensions: [AshLua.EvalActions] eval_actions do - resource MyApp.Support.Ticket, actions: [:read, :create, :reassign] - resource MyApp.Accounts.User, actions: [:read] + labels [:support_agent] end end ``` @@ -158,6 +187,24 @@ configured for the MCP session — `ash_ai` threads those through into the generic action's context, and from there into every Ash call the Lua script performs. +## Optional: preload eval manifests + +By default, the scoped eval manifest is built lazily when an `:eval` or `:docs` +action runs. For applications that need quicker first-invocation times in +production, preload every eval manifest during application boot: + +```elixir +AshLua.preload_eval_manifests!(:my_app) +``` + +This stores the immutable scoped manifests in `:persistent_term`. Runtime eval +calls still build a fresh Lua VM and still receive the current actor, tenant, +and context; only the reusable surface specification is cached. Once preloaded, +checking the cache is a microsecond-level persistent-term read. + +In development or test, lazy generation is usually simpler because code reloads +and changing DSL configuration require clearing or rebuilding the cache. + ## A walkthrough The user asks: _"How many overdue, high-priority todos do I have, and what's @@ -229,9 +276,10 @@ for details. `:docs` operates in three modes depending on its arguments: - * **No arguments** — returns the full markdown page from - `AshLua.Docs.full_doc/1`, restricted to the scoped surface. + * **No arguments** — returns a compact index from `AshLua.Docs.index_doc/1`, + restricted to the scoped surface. * **`name: "..."`** — returns a single focused page: + * `"full"` → the full markdown page from `AshLua.Docs.full_doc/1`; * a callable path like `"work.todo.read"` → the per-operation page from `AshLua.Docs.callable_doc/2`; * a record-type path like `"work.todo"` or a named type like `"Status"` diff --git a/documentation/how_to/use-ash-lua-as-a-runtime.md b/documentation/how_to/use-ash-lua-as-a-runtime.md new file mode 100644 index 0000000..4e7fd74 --- /dev/null +++ b/documentation/how_to/use-ash-lua-as-a-runtime.md @@ -0,0 +1,130 @@ + + +# Use AshLua as a runtime + +`AshLua.EvalActions` is convenient when another Ash-based integration should +expose `:docs` and `:eval` as ordinary Ash actions. For custom transports or +in-process LLM loops, use `AshLua.Eval` directly instead. + +The runtime API has the same shape as the generated actions: + + * resolve a scoped manifest; + * expose documentation for that manifest; + * run Lua against a fresh VM with the caller's actor, tenant, and context. + +## Define the surface + +Declare the public Lua namespace on your domains. Labels let each consuming +runtime pick the subset it should expose. + +```elixir +defmodule MyApp.Content do + use Ash.Domain, + otp_app: :my_app, + extensions: [AshLua.Domain] + + lua do + namespace "posts" do + action :list, MyApp.Posts.Post, :read, labels: [:public, :read_model] + action :create, MyApp.Posts.Post, :create, labels: [:public, :writes] + end + end +end +``` + +You can either define an `AshLua.EvalActions` resource and reuse its scope: + +```elixir +defmodule MyApp.AgentSurface do + use Ash.Resource, + domain: MyApp.Agents, + extensions: [AshLua.EvalActions] + + eval_actions do + labels [:public] + end +end +``` + +or build the scope directly from the OTP app and labels: + +```elixir +{:ok, manifest} = AshLua.Eval.manifest(otp_app: :my_app, labels: [:public]) +``` + +## Run scripts directly + +Resolve or preload the manifest once, then pass it into each invocation: + +```elixir +manifest = AshLua.Eval.manifest!(eval_resource: MyApp.AgentSurface) + +{:ok, %{result: result, error: nil, print_output: prints}} = + AshLua.Eval.run( + """ + local posts = assert(posts.list({ fields = { "id", "title" }, limit = 10 })) + print("loaded " .. #posts .. " posts") + return posts + """, + manifest: manifest, + actor: current_user, + tenant: tenant, + context: %{request_id: request_id} + ) +``` + +Each call builds a fresh Lua VM. State that should survive between calls must +live in your application through the exposed Ash actions. + +## Serve docs + +`AshLua.Eval.docs/2` returns the same markdown as the generated `:docs` action: + +```elixir +{:ok, index} = AshLua.Eval.docs(manifest) +{:ok, page} = AshLua.Eval.docs(manifest, name: "posts.list") +{:ok, matches} = AshLua.Eval.docs(manifest, search: "published posts") +``` + +These functions are transport-agnostic. A custom MCP tool, HTTP endpoint, job, +or internal LLM loop can use the same functions and adapt the return value to +its own protocol. + +## Bound the Lua VM + +For untrusted or model-generated scripts, pass `lua_options:` through to +`Lua.new/1`: + +```elixir +AshLua.Eval.run(script, + manifest: manifest, + actor: current_user, + lua_options: [ + max_instructions: 100_000, + max_call_depth: 100, + max_string_bytes: 1_000_000 + ] +) +``` + +The Lua package's default sandbox remains in effect unless you pass a custom +prebuilt `:lua` VM. Prefer manifest caching over VM caching: the manifest is +immutable surface data, while the VM carries request-specific actor, tenant, +context, private state, and captured output. + +## Preload in production + +If you use `AshLua.EvalActions` resources to define scopes, preload their +manifests during application boot: + +```elixir +AshLua.preload_eval_manifests!(:my_app) +``` + +After preload, `AshLua.Eval.manifest(eval_resource: MyApp.AgentSurface)` and +the generated actions use the cached manifest. Each script invocation still +gets a fresh Lua VM. diff --git a/lib/ash_lua.ex b/lib/ash_lua.ex index 11c9db5..666c549 100644 --- a/lib/ash_lua.ex +++ b/lib/ash_lua.ex @@ -56,10 +56,12 @@ defmodule AshLua do * `:actor`, `:tenant`, `:context` — host-supplied; merged into every Ash call. * `:manifest` — a pre-built `%Ash.Info.Manifest{}` to skip regeneration. * `:lua` — a pre-built `%Lua{}` to install bindings on (e.g. with extra `Lua.set!/3` callbacks). + * `:lua_options` — options passed to `Lua.new/1` when `:lua` is not supplied. * `:forbidden_fields` — `:hide` (default) strips fields hidden by authorization from results; `:display` renders them as the opaque marker `%{"opaque" => "forbidden"}` so the consumer can tell a forbidden field apart from an absent one. * `:decode` — forwarded to `Lua.eval!/3`; defaults to `true`. + * `:source` — forwarded to `Lua.eval!/3`; labels runtime errors with a script name. """ @spec eval!(String.t(), keyword()) :: {list(), Lua.t()} def eval!(script, opts) when is_binary(script) and is_list(opts) do @@ -70,10 +72,23 @@ defmodule AshLua do Builds a `%Lua{}` VM with Ash bindings installed, ready for repeated `Lua.eval!/2` calls. Accepts the same `:otp_app` / `:actor` / `:tenant` / `:context` / `:manifest` / `:lua` / - `:forbidden_fields` options as `eval!/2`. + `:lua_options` / `:forbidden_fields` options as `eval!/2`. """ @spec new(keyword()) :: Lua.t() def new(opts \\ []) do AshLua.Runtime.build(opts) end + + @doc """ + Preloads cached eval manifests for every `AshLua.EvalActions` resource in an OTP app. + + This is an optional production boot-time optimization for applications that + want to avoid rebuilding the scoped manifest on the first eval/docs call. + Each invocation still gets a fresh Lua VM with its own actor, tenant, and + context. + """ + @spec preload_eval_manifests!(atom()) :: [module()] + def preload_eval_manifests!(otp_app) when is_atom(otp_app) do + AshLua.Surface.preload_eval_manifests!(otp_app) + end end diff --git a/lib/ash_lua/domain.ex b/lib/ash_lua/domain.ex index 42d37f5..fd93c47 100644 --- a/lib/ash_lua/domain.ex +++ b/lib/ash_lua/domain.ex @@ -11,7 +11,7 @@ defmodule AshLua.Domain do examples: [ """ namespace "pages" do - action :list, MyApp.StorefrontPage, :list_for_storefront + action :list, MyApp.StorefrontPage, :list_for_storefront, labels: [:public, :read_model] end """ ], @@ -30,6 +30,12 @@ defmodule AshLua.Domain do type: :atom, required: true, doc: "The internal Ash action to call." + ], + labels: [ + type: {:list, :atom}, + default: [], + doc: + "Labels that describe this mapped Lua action. `AshLua.EvalActions` can use these labels to expose individual actions." ] ] } @@ -37,7 +43,8 @@ defmodule AshLua.Domain do @namespace %Spark.Dsl.Entity{ name: :namespace, target: AshLua.Domain.Namespace, - args: [:name], + transform: {__MODULE__, :normalize_namespace_labels, []}, + args: [:name, {:optional, :labels, []}], describe: "Defines a public Lua namespace for actions.", examples: [ """ @@ -47,7 +54,7 @@ defmodule AshLua.Domain do """, """ namespace "storefronts.pages" do - action :list, MyApp.StorefrontPage, :list_for_storefront + action :list, MyApp.StorefrontPage, :list_for_storefront, labels: [:public, :read_model] end """ ], @@ -57,6 +64,17 @@ defmodule AshLua.Domain do required: true, doc: "The public Lua namespace. Dotted strings are split into nested Lua tables, so \"storefronts.pages\" exposes `storefronts.pages.*`." + ], + labels: [ + type: + {:or, + [ + {:list, :atom}, + {:keyword_list, [labels: [type: {:list, :atom}, required: true]]} + ]}, + default: [], + doc: + "Labels inherited by every mapped action in this namespace. Prefer action-level labels when individual actions need different eval surfaces." ] ], entities: [ @@ -93,4 +111,11 @@ defmodule AshLua.Domain do use Spark.Dsl.Extension, sections: [@lua], verifiers: [AshLua.Domain.Verifiers.VerifySurface] + + @doc false + def normalize_namespace_labels(%AshLua.Domain.Namespace{labels: [labels: labels]} = namespace) do + {:ok, %{namespace | labels: labels}} + end + + def normalize_namespace_labels(%AshLua.Domain.Namespace{} = namespace), do: {:ok, namespace} end diff --git a/lib/ash_lua/domain/action.ex b/lib/ash_lua/domain/action.ex index f88001e..d9c18ee 100644 --- a/lib/ash_lua/domain/action.ex +++ b/lib/ash_lua/domain/action.ex @@ -11,8 +11,9 @@ defmodule AshLua.Domain.Action do name: atom(), resource: module(), action: atom(), + labels: [atom()], __spark_metadata__: term() } - defstruct [:name, :resource, :action, __spark_metadata__: nil] + defstruct [:name, :resource, :action, labels: [], __spark_metadata__: nil] end diff --git a/lib/ash_lua/domain/namespace.ex b/lib/ash_lua/domain/namespace.ex index 8f57a94..43f592c 100644 --- a/lib/ash_lua/domain/namespace.ex +++ b/lib/ash_lua/domain/namespace.ex @@ -9,9 +9,10 @@ defmodule AshLua.Domain.Namespace do @type t :: %__MODULE__{ name: String.t() | [String.t()], + labels: [atom()], actions: [AshLua.Domain.Action.t()], __spark_metadata__: term() } - defstruct [:name, actions: [], __spark_metadata__: nil] + defstruct [:name, labels: [], actions: [], __spark_metadata__: nil] end diff --git a/lib/ash_lua/encoder.ex b/lib/ash_lua/encoder.ex index 8401b97..263c3f6 100644 --- a/lib/ash_lua/encoder.ex +++ b/lib/ash_lua/encoder.ex @@ -4,8 +4,8 @@ defmodule AshLua.Encoder do @moduledoc """ - Conversions between Elixir/Ash values and the plain shapes that `:luerl` (via the `:lua` package) - can encode as Lua tables. + Conversions between Elixir/Ash values and the plain shapes that the Lua VM can encode as Lua + tables. Lua doesn't have atoms or sigils — atoms are rendered as strings, `Decimal`/`Date`/`DateTime`/ `NaiveDateTime`/`Time` as their canonical string forms, and structs as plain attribute maps @@ -28,7 +28,7 @@ defmodule AshLua.Encoder do @doc """ Decodes a Lua-side input value into the shape Ash actions expect for `params`/arguments. - Luerl decodes Lua tables as a list of two-tuples — keyed by integers for sequences and by + Lua tables decode as a list of two-tuples — keyed by integers for sequences and by strings for maps. We normalize: * integer-keyed (sequence) tables → plain lists, sorted by index @@ -144,7 +144,7 @@ defmodule AshLua.Encoder do } end - # Luerl decodes Lua tables as keyword-list-of-2-tuples — integer-keyed for + # Lua tables decode as keyword-list-of-2-tuples — integer-keyed for # sequences, string-keyed for maps. Flatten the same way `decode_input/1` # does on the input side, so a script's returned table comes through as a # plain list or map instead of a verbose nested `{type:"tuple", values:[...]}` @@ -177,10 +177,10 @@ defmodule AshLua.Encoder do def encode_result({:erl_func, _}), do: %{"opaque" => "function"} def encode_result({:erl_mfa, _, _, _}), do: %{"opaque" => "function"} def encode_result({:tref, _}), do: %{"opaque" => "table_reference"} - def encode_result({:usdref, _}), do: %{"opaque" => "userdata"} def encode_result({:udref, _}), do: %{"opaque" => "userdata"} - # Luerl also returns the decoded shape `{module, function, arity_or_undefined}` - # for built-in callables like `print` (module=:luerl_lib_basic). + def encode_result({:usdref, _}), do: %{"opaque" => "userdata"} + # The Lua VM can also return the decoded shape `{module, function, arity_or_undefined}` + # for built-in callables. def encode_result({m, f, a}) when is_atom(m) and is_atom(f) and (is_integer(a) or is_atom(a)), do: %{"opaque" => "function"} @@ -209,15 +209,16 @@ defmodule AshLua.Encoder do %{"type" => Atom.to_string(member), "value" => encode_result(inner)} end - # A field hidden by authorization. In `:hide` mode it's dropped (nil); in - # `:display` mode it surfaces as the opaque "forbidden" marker. Must precede - # the generic `%_struct{}` clause so the struct's internals never leak. - def encode_result(%Ash.ForbiddenField{}), do: forbidden_value(nil) def encode_result(%Lua.VM.Display.NativeFunc{}), do: %{"opaque" => "function"} def encode_result(%Lua.VM.Display.Closure{}), do: %{"opaque" => "function"} def encode_result(%Lua.VM.Display.Userdata{}), do: %{"opaque" => "userdata"} def encode_result(%Lua.VM.Display.Table{}), do: %{"opaque" => "table_reference"} + # A field hidden by authorization. In `:hide` mode it's dropped (nil); in + # `:display` mode it surfaces as the opaque "forbidden" marker. Must precede + # the generic `%_struct{}` clause so the struct's internals never leak. + def encode_result(%Ash.ForbiddenField{}), do: forbidden_value(nil) + def encode_result(%_struct{} = record) do record |> Map.from_struct() diff --git a/lib/ash_lua/eval.ex b/lib/ash_lua/eval.ex new file mode 100644 index 0000000..76d43d3 --- /dev/null +++ b/lib/ash_lua/eval.ex @@ -0,0 +1,384 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Eval do + @moduledoc """ + Public runtime helpers for evaluating scripts against a scoped AshLua surface. + + This module is the adapter-free layer underneath `AshLua.EvalActions`. Use it + when a custom transport, MCP server, or in-process LLM loop wants the same + scoped Lua runtime without going through a synthesized Ash action. + + Each `run/2` call builds a fresh Lua VM. Reuse should happen at the manifest + level: resolve or preload a manifest once, then pass it as `manifest: manifest` + for subsequent invocations. + """ + + alias Ash.Info.Manifest + + @type run_result :: %{ + required(:result) => term(), + required(:error) => map() | nil, + required(:print_output) => [String.t()] + } + + @type manifest_opts :: [ + manifest: Manifest.t(), + eval_resource: module(), + otp_app: atom(), + labels: [atom()], + action_entrypoints: [{module(), atom()}] + ] + + @type run_opts :: [ + {:actor, term()} + | {:tenant, term()} + | {:context, map()} + | {:forbidden_fields, :hide | :display} + | {:lua, Lua.t()} + | {:lua_options, keyword()} + | {:source, String.t()} + | {:manifest, Manifest.t()} + | {:eval_resource, module()} + | {:otp_app, atom()} + | {:labels, [atom()]} + | {:action_entrypoints, [{module(), atom()}]} + ] + + @doc """ + Resolves a scoped AshLua manifest. + + Accepted inputs: + + * `manifest: manifest` or a `%Ash.Info.Manifest{}` value — reuse an already + resolved manifest. + * `eval_resource: MyApp.AgentSurface` — use an `AshLua.EvalActions` + resource's scope. + * `otp_app: :my_app` plus optional `labels:` / `action_entrypoints:` — + build a scoped surface directly from domain DSL metadata. + """ + @spec manifest(Manifest.t() | manifest_opts()) :: {:ok, Manifest.t()} | {:error, term()} + def manifest(%Manifest{} = manifest), do: {:ok, AshLua.Surface.for_manifest(manifest)} + + def manifest(opts) when is_list(opts) do + cond do + Keyword.has_key?(opts, :manifest) -> + case Keyword.fetch!(opts, :manifest) do + %Manifest{} = manifest -> + {:ok, AshLua.Surface.for_manifest(manifest)} + + other -> + {:error, + ArgumentError.exception( + "expected :manifest to be an Ash.Info.Manifest, got: #{inspect(other)}" + )} + end + + resource = Keyword.get(opts, :eval_resource) -> + AshLua.Surface.for_eval_resource(resource) + + otp_app = Keyword.get(opts, :otp_app) -> + AshLua.Surface.for_otp_app(otp_app, + labels: Keyword.get(opts, :labels, []), + action_entrypoints: Keyword.get(opts, :action_entrypoints) + ) + + true -> + {:error, + ArgumentError.exception( + "expected one of :manifest, :eval_resource, or :otp_app when resolving an AshLua eval surface" + )} + end + end + + @doc """ + Resolves a scoped AshLua manifest or raises. + """ + @spec manifest!(Manifest.t() | manifest_opts()) :: Manifest.t() + def manifest!(manifest_or_opts) do + case manifest(manifest_or_opts) do + {:ok, %Manifest{} = manifest} -> manifest + {:error, reason} -> raise reason + end + end + + @doc """ + Evaluates `script` against a scoped AshLua runtime. + + Returns the same stable shape as the synthesized `:eval` action: + `%{result: term, error: map | nil, print_output: [String.t()]}`. + + The surface is resolved with `manifest/1`. Runtime options include + `:actor`, `:tenant`, `:context`, `:forbidden_fields`, `:source`, `:lua`, and + `:lua_options`. `:lua_options` is forwarded to `Lua.new/1` when a prebuilt + `:lua` VM is not supplied. + """ + @spec run(String.t(), run_opts()) :: {:ok, run_result()} | {:error, term()} + def run(script, opts) when is_binary(script) and is_list(opts) do + forbidden_fields = Keyword.get(opts, :forbidden_fields, :hide) + + with {:ok, %Manifest{} = manifest} <- manifest(opts) do + do_run(script, build_eval_opts(opts, manifest, forbidden_fields)) + end + end + + @doc """ + Returns markdown documentation for a scoped AshLua surface. + + Pass neither `:name` nor `:search` for the compact index. Pass + `name: "full"` for the full page, a callable/type/topic id for a focused + page, or `search: term` for ranked search results. + """ + @spec docs(Manifest.t() | manifest_opts(), keyword() | map()) :: + {:ok, String.t()} | {:error, term()} + def docs(manifest_or_opts, args \\ []) + + def docs(%Manifest{} = manifest, args) do + dispatch_docs(AshLua.Surface.for_manifest(manifest), normalize_doc_args(args)) + end + + def docs(opts, []) when is_list(opts) do + args = Keyword.take(opts, [:name, :search]) + + with {:ok, %Manifest{} = manifest} <- manifest(opts) do + dispatch_docs(manifest, normalize_doc_args(args)) + end + end + + def docs(opts, args) when is_list(opts) do + with {:ok, %Manifest{} = manifest} <- manifest(opts) do + dispatch_docs(manifest, normalize_doc_args(args)) + end + end + + defp build_eval_opts(opts, manifest, forbidden_fields) do + [ + manifest: manifest, + actor: Keyword.get(opts, :actor), + tenant: Keyword.get(opts, :tenant), + context: Keyword.get(opts, :context, %{}) || %{}, + forbidden_fields: forbidden_fields + ] + |> maybe_put(:lua, Keyword.get(opts, :lua)) + |> maybe_put(:lua_options, Keyword.get(opts, :lua_options)) + |> maybe_put(:source, Keyword.get(opts, :source)) + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp do_run(script, eval_opts) do + {values, lua} = AshLua.eval!(script, eval_opts) + {result, error} = split_lua_return(values) + + {:ok, + %{ + result: AshLua.Encoder.encode_result(result), + error: error, + print_output: AshLua.Runtime.print_output(lua) + }} + rescue + e in [Lua.CompilerException, Lua.RuntimeException] -> + {:ok, + %{ + result: nil, + error: + extract_structured_error(e) || + format_lua_error(e, script, Keyword.get(eval_opts, :source)), + print_output: [] + }} + end + + defp extract_structured_error(%Lua.RuntimeException{original: original, state: state}) do + case {raised_value(original), lua_state(original, state)} do + {nil, _state} -> nil + {_value, nil} -> nil + {value, lua_state} -> value |> safe_decode(lua_state) |> normalize_error_table() + end + end + + defp extract_structured_error(_), do: nil + + defp raised_value({:assert_error, value}), do: value + defp raised_value({:error_call, [value | _]}), do: value + defp raised_value(error) when is_struct(error), do: Map.get(error, :value) + defp raised_value(_), do: nil + + defp lua_state(original, nil) when is_struct(original), do: Map.get(original, :state) + defp lua_state(_original, state), do: state + + defp safe_decode(value, state) do + Lua.decode!(%Lua{state: state}, value) + rescue + _ -> nil + end + + defp normalize_error_table(list) when is_list(list) do + case AshLua.Encoder.decode_input(list) do + %{"class" => _, "errors" => _} = err -> err + _ -> nil + end + end + + defp normalize_error_table(_), do: nil + + defp split_lua_return([]), do: {nil, nil} + defp split_lua_return([value]), do: {value, nil} + defp split_lua_return([value, nil | _]), do: {value, nil} + defp split_lua_return([nil, err | _]), do: {nil, normalize_error(err)} + defp split_lua_return([value, err | _]), do: {value, normalize_error(err)} + + defp normalize_error(err) when is_list(err), do: Map.new(err) + defp normalize_error(err) when is_map(err), do: err + defp normalize_error(err), do: %{"message" => inspect(err)} + + defp format_lua_error(%Lua.CompilerException{} = e, script, source) do + case Lua.Parser.parse_structured(script) do + {:error, [parse_error | _]} -> + parse_error + |> Lua.Parser.Error.to_map(script) + |> json_safe() + |> maybe_put_lua_source(source) + |> lua_error_envelope() + + _ -> + lua_error_envelope(Exception.message(e), %{}) + end + end + + defp format_lua_error(%Lua.RuntimeException{original: original} = e, script, source) do + vars = structured_runtime_error(original, script, source) + lua_error_envelope(Exception.message(e), vars) + end + + defp structured_runtime_error(%{__struct__: module} = original, script, source) do + if function_exported?(module, :to_map, 2) do + original + |> module.to_map(source_code: script) + |> json_safe() + |> maybe_put_lua_source(source) + else + %{} + end + rescue + _ -> %{} + end + + defp structured_runtime_error(_original, _script, _source), do: %{} + + defp maybe_put_lua_source(vars, nil), do: vars + defp maybe_put_lua_source(%{"source" => nil} = vars, source), do: %{vars | "source" => source} + defp maybe_put_lua_source(%{"source" => ""} = vars, source), do: %{vars | "source" => source} + defp maybe_put_lua_source(vars, _source), do: vars + + defp lua_error_envelope(%{"message" => message} = vars) when is_binary(message) do + lua_error_envelope(message, vars) + end + + defp lua_error_envelope(message, vars) do + %{ + "message" => message, + "errors" => [ + %{ + "message" => message, + "short_message" => "lua_error", + "code" => "lua_error", + "fields" => [], + "vars" => vars + } + ] + } + end + + defp json_safe(%{} = map) do + Map.new(map, fn {key, value} -> {json_key(key), json_safe(value)} end) + end + + defp json_safe(list) when is_list(list), do: Enum.map(list, &json_safe/1) + + defp json_safe(value) when is_atom(value) and value not in [nil, true, false], + do: Atom.to_string(value) + + defp json_safe(value), do: value + + defp json_key(:highlight?), do: "highlight" + defp json_key(key) when is_atom(key), do: Atom.to_string(key) + defp json_key(key), do: to_string(key) + + defp normalize_doc_args(args) when is_map(args) do + %{ + name: Map.get(args, :name) || Map.get(args, "name"), + search: Map.get(args, :search) || Map.get(args, "search") + } + end + + defp normalize_doc_args(args) when is_list(args) do + %{ + name: keyword_or_string(args, :name), + search: keyword_or_string(args, :search) + } + end + + defp keyword_or_string(args, key) do + case Keyword.fetch(args, key) do + {:ok, value} -> + value + + :error -> + case List.keyfind(args, Atom.to_string(key), 0) do + {_key, value} -> value + nil -> nil + end + end + end + + defp dispatch_docs(manifest, %{name: name, search: search}) do + if present?(name) and present?(search) do + {:error, + Ash.Error.Action.InvalidArgument.exception( + field: :search, + message: "`name` and `search` are mutually exclusive — pass at most one" + )} + else + dispatch_docs(manifest, name, search) + end + end + + defp dispatch_docs(manifest, _name, search) when is_binary(search) and search != "" do + {:ok, AshLua.Docs.search(manifest, search)} + end + + defp dispatch_docs(manifest, name, _search) when name in [nil, ""] do + {:ok, AshLua.Docs.index_doc(manifest)} + end + + defp dispatch_docs(manifest, "full", _search) do + {:ok, AshLua.Docs.full_doc(manifest)} + end + + defp dispatch_docs(manifest, name, _search) when is_binary(name) do + cond do + name in AshLua.Docs.list_callables(manifest) -> + AshLua.Docs.callable_doc(manifest, name) + + name in AshLua.Docs.list_types(manifest) -> + AshLua.Docs.type_doc(manifest, name) + + name in AshLua.Docs.topics(manifest) -> + AshLua.Docs.topic_doc(manifest, name) + + true -> + {:error, + Ash.Error.Action.InvalidArgument.exception( + field: :name, + message: "no callable, type, or topic named #{inspect(name)} in the exposed surface" + )} + end + end + + defp present?(nil), do: false + defp present?(""), do: false + defp present?(value) when is_binary(value), do: true + defp present?(_), do: false +end diff --git a/lib/ash_lua/eval_actions.ex b/lib/ash_lua/eval_actions.ex index 8d64643..326e018 100644 --- a/lib/ash_lua/eval_actions.ex +++ b/lib/ash_lua/eval_actions.ex @@ -31,21 +31,34 @@ defmodule AshLua.EvalActions do describe: """ Configures the Lua surface exposed to the synthesized `:eval` and `:docs` actions. - Each `resource` entry pairs a resource module with the set of action names - that the script (and the generated docs) is allowed to see. Use this to - apply principle-of-least-privilege per agent surface — only the listed - actions become callable from Lua and only those entrypoints appear in - `:docs` output. + Prefer `labels` to expose mapped Lua actions declared on the domain with + `lua do namespace ... action ..., labels: [...] end`. This keeps the eval + surface tied to the same public Lua surface used everywhere else. + + The legacy `resource` entries remain supported for derived surfaces and + fine-grained compatibility. When both `labels` and `resource` entries are + configured, the resource/action list narrows the labelled action surface. """, examples: [ """ eval_actions do - resource MyApp.Posts.Post, actions: [:read, :get_statistics] - resource MyApp.Posts.Comment, actions: [:read] + labels [:public] + end + """, + """ + eval_actions do + labels [:public] + resource MyApp.Posts.Post, actions: [:read] end """ ], schema: [ + labels: [ + type: {:list, :atom}, + default: [], + doc: + "Mapped action labels to expose to the eval/docs actions. An action is included only when it has all requested labels." + ], eval_action_name: [ type: :atom, default: :eval, @@ -80,16 +93,15 @@ defmodule AshLua.EvalActions do use Ash.Resource, extensions: [AshLua.EvalActions] eval_actions do - resource MyApp.Posts.Post, actions: [:read, :get_statistics] - resource MyApp.Posts.Comment, actions: [:read] + labels [:public] end end ``` - The synthesized actions inherit the caller's actor / tenant / context — both + The synthesized actions inherit the caller's actor / tenant / context. Both the script body and the documentation rendering are constrained to the - configured `(resource, action)` pairs, and every Ash call inside the Lua - script flows through the standard authorization machinery. + configured action labels, and every Ash call inside the Lua script flows + through the standard authorization machinery. """ use Spark.Dsl.Extension, diff --git a/lib/ash_lua/eval_actions/info.ex b/lib/ash_lua/eval_actions/info.ex index f08d02e..3b2f3c4 100644 --- a/lib/ash_lua/eval_actions/info.ex +++ b/lib/ash_lua/eval_actions/info.ex @@ -12,6 +12,11 @@ defmodule AshLua.EvalActions.Info do @spec exposes(Ash.Resource.t() | Spark.Dsl.t()) :: [Expose.t()] def exposes(resource), do: Extension.get_entities(resource, [:eval_actions]) || [] + @doc "Mapped action labels exposed to the synthesized eval/docs actions." + @spec labels(Ash.Resource.t() | Spark.Dsl.t()) :: [atom()] + def labels(resource), + do: Extension.get_opt(resource, [:eval_actions], :labels, []) + @doc "Name of the synthesized eval action. Defaults to `:eval`." @spec eval_action_name(Ash.Resource.t() | Spark.Dsl.t()) :: atom() def eval_action_name(resource), diff --git a/lib/ash_lua/eval_actions/run/docs.ex b/lib/ash_lua/eval_actions/run/docs.ex index 0fcd0c2..d2babf0 100644 --- a/lib/ash_lua/eval_actions/run/docs.ex +++ b/lib/ash_lua/eval_actions/run/docs.ex @@ -25,58 +25,10 @@ defmodule AshLua.EvalActions.Run.Docs do @impl true def run(input, _opts, _context) do - resource = input.resource - - name = Map.get(input.arguments, :name) - search = Map.get(input.arguments, :search) - - if present?(name) and present?(search) do - {:error, - Ash.Error.Action.InvalidArgument.exception( - field: :search, - message: "`name` and `search` are mutually exclusive — pass at most one" - )} - else - with {:ok, manifest} <- AshLua.Surface.for_eval_resource(resource) do - dispatch(manifest, name, search) - end - end - end - - defp dispatch(manifest, _name, search) when is_binary(search) and search != "" do - {:ok, AshLua.Docs.search(manifest, search)} - end - - defp dispatch(manifest, name, _search) when name in [nil, ""] do - {:ok, AshLua.Docs.index_doc(manifest)} - end - - defp dispatch(manifest, "full", _search) do - {:ok, AshLua.Docs.full_doc(manifest)} + AshLua.Eval.docs( + [eval_resource: input.resource], + name: Map.get(input.arguments, :name), + search: Map.get(input.arguments, :search) + ) end - - defp dispatch(manifest, name, _search) when is_binary(name) do - cond do - name in AshLua.Docs.list_callables(manifest) -> - AshLua.Docs.callable_doc(manifest, name) - - name in AshLua.Docs.list_types(manifest) -> - AshLua.Docs.type_doc(manifest, name) - - name in AshLua.Docs.topics(manifest) -> - AshLua.Docs.topic_doc(manifest, name) - - true -> - {:error, - Ash.Error.Action.InvalidArgument.exception( - field: :name, - message: "no callable, type, or topic named #{inspect(name)} in the exposed surface" - )} - end - end - - defp present?(nil), do: false - defp present?(""), do: false - defp present?(value) when is_binary(value), do: true - defp present?(_), do: false end diff --git a/lib/ash_lua/eval_actions/run/eval.ex b/lib/ash_lua/eval_actions/run/eval.ex index f38a29d..2468d98 100644 --- a/lib/ash_lua/eval_actions/run/eval.ex +++ b/lib/ash_lua/eval_actions/run/eval.ex @@ -19,116 +19,24 @@ defmodule AshLua.EvalActions.Run.Eval do @impl true def run(input, _opts, context) do - script = input.arguments.script resource = input.resource + script = Map.get(input.arguments, :script) + source_context = Map.get(context, :source_context) - with {:ok, manifest} <- AshLua.Surface.for_eval_resource(resource) do - do_run(script, manifest, AshLua.EvalActions.Info.forbidden_fields(resource), context) - end - end - - defp do_run(script, manifest, forbidden_fields, context) do - eval_opts = - [ - manifest: manifest, + if is_binary(script) do + AshLua.Eval.run(script, + eval_resource: resource, actor: context.actor, tenant: context.tenant, - context: Map.get(context, :source_context, %{}) || %{}, - forbidden_fields: forbidden_fields - ] - - try do - {values, lua} = AshLua.eval!(script, eval_opts) - {result, error} = split_lua_return(values) - # Pass the script's return value through the encoder so any Luerl - # reference records (function/userdata/table refs from e.g. `return - # loop.item`) become opaque markers rather than crashing Jason in the - # downstream MCP serializer. - {:ok, - %{ - result: AshLua.Encoder.encode_result(result), - error: error, - print_output: AshLua.Runtime.print_output(lua) - }} - rescue - e in [Lua.CompilerException, Lua.RuntimeException] -> - # On Lua-side raise we lost the post-call state, so we can't surface - # the prints from this particular run. Future improvement: thread - # `lua` out via the exception so partial print output survives. - {:ok, - %{ - result: nil, - error: extract_structured_error(e) || format_lua_error(e), - print_output: [] - }} - end - end - - # When a Lua script does `assert(action_call())` and `action_call()` returns - # `(nil, err_table)`, Lua raises with the err_table as the error object. The - # default `Lua.RuntimeException` message is the opaque "error object is a - # table!" — useless for diagnosing `invalid_fields` / `not_found` / etc. - # Detect that case and decode the original error table back out so the - # action's `error` slot carries the same structured shape the script would - # have seen on the unwrapped path. - defp extract_structured_error(%Lua.RuntimeException{original: original, state: state}) do - case {raised_value(original), lua_state(original, state)} do - {nil, _state} -> nil - {_value, nil} -> nil - {value, lua_state} -> value |> safe_decode(lua_state) |> normalize_error_table() - end - end - - defp extract_structured_error(_), do: nil - - # Pull the actual raised Lua value out of the VM error shape. Older Luerl - # paths raise `{:assert_error, err}` / `{:error_call, [err]}`; the native VM - # raises error structs with the Lua value in `:value`. - defp raised_value({:assert_error, value}), do: value - defp raised_value({:error_call, [value | _]}), do: value - defp raised_value(error) when is_struct(error), do: Map.get(error, :value) - defp raised_value(_), do: nil - - defp lua_state(original, nil) when is_struct(original), do: Map.get(original, :state) - defp lua_state(_original, state), do: state - - defp safe_decode(value, state) do - Lua.decode!(%Lua{state: state}, value) - rescue - _ -> nil - end - - defp normalize_error_table(list) when is_list(list) do - case AshLua.Encoder.decode_input(list) do - %{"class" => _, "errors" => _} = err -> err - _ -> nil + context: if(is_map(source_context), do: source_context, else: %{}), + forbidden_fields: AshLua.EvalActions.Info.forbidden_fields(resource) + ) + else + {:error, + Ash.Error.Action.InvalidArgument.exception( + field: :script, + message: "must be a string" + )} end end - - defp normalize_error_table(_), do: nil - - defp split_lua_return([]), do: {nil, nil} - defp split_lua_return([value]), do: {value, nil} - defp split_lua_return([value, nil | _]), do: {value, nil} - defp split_lua_return([nil, err | _]), do: {nil, normalize_error(err)} - defp split_lua_return([value, err | _]), do: {value, normalize_error(err)} - - defp normalize_error(err) when is_list(err), do: Map.new(err) - defp normalize_error(err) when is_map(err), do: err - defp normalize_error(err), do: %{"message" => inspect(err)} - - defp format_lua_error(e) do - %{ - "message" => Exception.message(e), - "errors" => [ - %{ - "message" => Exception.message(e), - "short_message" => "lua_error", - "code" => "lua_error", - "fields" => [], - "vars" => %{} - } - ] - } - end end diff --git a/lib/ash_lua/runtime.ex b/lib/ash_lua/runtime.ex index 93baad4..37b1d9a 100644 --- a/lib/ash_lua/runtime.ex +++ b/lib/ash_lua/runtime.ex @@ -33,7 +33,8 @@ defmodule AshLua.Runtime do * `:otp_app` (required) — passed through to `Ash.Info.Manifest.generate/1`. * `:actor`, `:tenant`, `:context` — host-supplied; merged into every Ash call. * `:manifest` — pre-built `%Ash.Info.Manifest{}` (skips regeneration). - * `:lua` — pre-built `%Lua{}` to install bindings on. Defaults to `Lua.new/0`. + * `:lua` — pre-built `%Lua{}` to install bindings on. Defaults to `Lua.new/1`. + * `:lua_options` — options passed to `Lua.new/1` when `:lua` is not supplied. * `:forbidden_fields` — `:hide` (default) strips fields hidden by authorization; `:display` renders them as the opaque marker `%{"opaque" => "forbidden"}`. """ @@ -47,6 +48,7 @@ defmodule AshLua.Runtime do :context, :manifest, :lua, + :lua_options, forbidden_fields: :hide ]) @@ -61,7 +63,7 @@ defmodule AshLua.Runtime do m end - lua = Keyword.get(opts, :lua) || Lua.new() + lua = Keyword.get(opts, :lua) || Lua.new(Keyword.get(opts, :lua_options, [])) private = %{ actor: Keyword.get(opts, :actor), @@ -158,7 +160,7 @@ defmodule AshLua.Runtime do """ @spec eval!(String.t(), keyword()) :: {list(), Lua.t()} def eval!(script, opts) when is_binary(script) do - {script_opts, build_opts} = Keyword.split(opts, [:decode]) + {script_opts, build_opts} = Keyword.split(opts, [:decode, :source]) lua = build(build_opts) Lua.eval!(lua, script, script_opts) end @@ -869,6 +871,7 @@ defmodule AshLua.Runtime do {sort, input} = Map.pop(input, "sort") {limit, input} = Map.pop(input, "limit") {offset, input} = Map.pop(input, "offset") + input = AshLua.FieldNames.to_internal_action_input(resource, action, input) query = resource @@ -893,6 +896,8 @@ defmodule AshLua.Runtime do end defp dispatch(resource, %{type: :create} = action, input, opts, select, load) do + input = AshLua.FieldNames.to_internal_action_input(resource, action, input) + resource |> Ash.Changeset.for_create(action.name, input, opts) |> changeset_select(select) @@ -901,6 +906,7 @@ defmodule AshLua.Runtime do end defp dispatch(resource, %{type: :update} = action, input, opts, select, load) do + input = AshLua.FieldNames.to_internal_action_input(resource, action, input) {filter, input} = split_primary_key_filter(resource, input) bulk_extras = @@ -915,6 +921,7 @@ defmodule AshLua.Runtime do end defp dispatch(resource, %{type: :destroy} = action, input, opts, select, load) do + input = AshLua.FieldNames.to_internal_action_input(resource, action, input) {filter, input} = split_primary_key_filter(resource, input) bulk_extras = @@ -929,6 +936,8 @@ defmodule AshLua.Runtime do end defp dispatch(resource, %{type: :action} = action, input, opts, _select, _load) do + input = AshLua.FieldNames.to_internal_action_input(resource, action, input) + resource |> Ash.ActionInput.for_action(action.name, input, opts) |> Ash.run_action(opts) diff --git a/lib/ash_lua/surface.ex b/lib/ash_lua/surface.ex index 64c882d..5fec5d0 100644 --- a/lib/ash_lua/surface.ex +++ b/lib/ash_lua/surface.ex @@ -14,9 +14,13 @@ defmodule AshLua.Surface do alias AshLua.Domain.{Action, Namespace} @config_key :ash_lua + @eval_manifest_cache_key {__MODULE__, :eval_manifest_cache} + @eval_manifest_cache_enabled_key {__MODULE__, :eval_manifest_cache_enabled} + @cache_miss {:ash_lua, :eval_manifest_cache_miss} @type surface_config :: %{ required(:domain) => module(), + required(:labels) => [atom()], required(:lua_action) => String.t(), required(:path) => [String.t()], required(:path_string) => String.t(), @@ -27,7 +31,8 @@ defmodule AshLua.Surface do @spec for_otp_app(atom(), keyword()) :: {:ok, Manifest.t()} def for_otp_app(otp_app, opts \\ []) do {filter, opts} = Keyword.pop(opts, :action_entrypoints) - action_entrypoints = action_entrypoints_for_otp_app(otp_app, filter) + {labels, opts} = Keyword.pop(opts, :labels, []) + action_entrypoints = action_entrypoints_for_otp_app(otp_app, filter, labels) opts |> Keyword.put(:otp_app, otp_app) @@ -38,13 +43,111 @@ defmodule AshLua.Surface do @doc "Generates the scoped Lua manifest for an `AshLua.EvalActions` resource." @spec for_eval_resource(module()) :: {:ok, Manifest.t()} def for_eval_resource(resource) do + if eval_manifest_cache_enabled?() do + case cached_eval_manifest(resource) do + {:ok, %Manifest{} = manifest} -> {:ok, manifest} + :error -> cache_eval_manifest(resource) + end + else + build_eval_resource_manifest(resource) + end + end + + @doc """ + Preloads and caches scoped Lua manifests for every `AshLua.EvalActions` + resource in an OTP app. + + The cache stores only immutable manifest data. Each eval invocation still + builds a fresh Lua VM with its caller-specific actor, tenant, and context. + """ + @spec preload_eval_manifests!(atom()) :: [module()] + def preload_eval_manifests!(otp_app) when is_atom(otp_app) do + resources = eval_resources(otp_app) + + entries = + Enum.map(resources, fn resource -> + {resource, build_eval_resource_manifest!(resource)} + end) + + cache = + @eval_manifest_cache_key + |> :persistent_term.get(%{}) + |> Map.merge(Map.new(entries)) + + :persistent_term.put(@eval_manifest_cache_key, cache) + :persistent_term.put(@eval_manifest_cache_enabled_key, true) + + resources + end + + @doc "Clears the eval manifest cache populated by `preload_eval_manifests!/1`." + @spec clear_eval_manifest_cache!() :: :ok + def clear_eval_manifest_cache! do + :persistent_term.erase(@eval_manifest_cache_key) + :persistent_term.put(@eval_manifest_cache_enabled_key, false) + :ok + end + + defp build_eval_resource_manifest!(resource) do + {:ok, %Manifest{} = manifest} = build_eval_resource_manifest(resource) + manifest + end + + defp build_eval_resource_manifest(resource) do otp_app = AshLua.EvalActions.Info.otp_app(resource) + labels = AshLua.EvalActions.Info.labels(resource) + exposed_action_entrypoints = AshLua.EvalActions.Info.exposed_action_entrypoints(resource) + + action_entrypoints = + case {labels, exposed_action_entrypoints} do + {labels, []} when labels != [] -> nil + _ -> exposed_action_entrypoints + end for_otp_app(otp_app, - action_entrypoints: AshLua.EvalActions.Info.exposed_action_entrypoints(resource) + action_entrypoints: action_entrypoints, + labels: labels ) end + defp eval_resources(otp_app) do + otp_app + |> Ash.Info.domains() + |> Enum.flat_map(&Ash.Domain.Info.resources/1) + |> Enum.filter(&(AshLua.EvalActions in Ash.Resource.Info.extensions(&1))) + end + + defp eval_manifest_cache_enabled? do + :persistent_term.get(@eval_manifest_cache_enabled_key, false) + end + + defp cached_eval_manifest(resource) do + case :persistent_term.get(@eval_manifest_cache_key, @cache_miss) do + @cache_miss -> + :error + + cache when is_map(cache) -> + case Map.fetch(cache, resource) do + {:ok, %Manifest{} = manifest} -> {:ok, manifest} + _ -> :error + end + end + end + + defp cache_eval_manifest(resource) do + with {:ok, %Manifest{} = manifest} <- build_eval_resource_manifest(resource) do + cache = + @eval_manifest_cache_key + |> :persistent_term.get(%{}) + |> Map.put(resource, manifest) + + :persistent_term.put(@eval_manifest_cache_key, cache) + :persistent_term.put(@eval_manifest_cache_enabled_key, true) + + {:ok, manifest} + end + end + @doc """ Attaches AshLua surface metadata to an existing manifest. @@ -55,7 +158,15 @@ defmodule AshLua.Surface do @spec for_manifest(Manifest.t()) :: Manifest.t() def for_manifest(%Manifest{entrypoints: []} = manifest), do: manifest - def for_manifest(%Manifest{} = manifest) do + def for_manifest(%Manifest{entrypoints: entrypoints} = manifest) do + if Enum.all?(entrypoints, &(not is_nil(config(&1)))) do + manifest + else + annotate_manifest(manifest) + end + end + + defp annotate_manifest(%Manifest{} = manifest) do filter = Enum.map(manifest.entrypoints, fn %Manifest.Entrypoint{} = entrypoint -> {entrypoint.resource, entrypoint.action.name} @@ -69,7 +180,7 @@ defmodule AshLua.Surface do entrypoints = manifest |> otp_app_from_manifest() - |> action_entrypoints_for_otp_app(filter) + |> action_entrypoints_for_otp_app(filter, []) |> Enum.flat_map(fn %{resource: resource, action: action, config: config} -> case Map.get(entries_by_key, {resource, action}) do nil -> @@ -126,33 +237,39 @@ defmodule AshLua.Surface do config(entrypoint) || raise ArgumentError, "manifest entrypoint is missing AshLua metadata" end - defp action_entrypoints_for_otp_app(otp_app, filter) do + defp action_entrypoints_for_otp_app(otp_app, filter, labels) do filter = filter_set(filter) + label_filter = label_filter(labels) otp_app |> Ash.Info.domains() - |> Enum.flat_map(&action_entrypoints_for_domain(&1, filter)) + |> Enum.flat_map(&action_entrypoints_for_domain(&1, filter, label_filter)) |> Enum.sort_by(fn %{config: %{@config_key => %{path_string: path}}} -> path end) end - defp action_entrypoints_for_domain(domain, filter) do + defp action_entrypoints_for_domain(domain, filter, label_filter) do case AshLua.Domain.Info.namespaces(domain) do - [] -> derived_action_entrypoints(domain, filter) - namespaces -> explicit_action_entrypoints(domain, namespaces, filter) + [] -> derived_action_entrypoints(domain, filter, label_filter) + namespaces -> explicit_action_entrypoints(domain, namespaces, filter, label_filter) end end - defp explicit_action_entrypoints(domain, namespaces, filter) do - Enum.flat_map(namespaces, fn %Namespace{name: namespace, actions: actions} -> - namespace_segments = namespace_segments(namespace) + defp explicit_action_entrypoints(domain, namespaces, filter, label_filter) do + Enum.flat_map(namespaces, fn %Namespace{name: namespace_name, actions: actions} = namespace -> + namespace_labels = namespace_labels(namespace) + namespace_segments = namespace_segments(namespace_name) actions - |> Enum.filter(&included?(&1.resource, &1.action, filter)) - |> Enum.map(fn %Action{} = action -> + |> Enum.map(&{&1, effective_labels(namespace_labels, &1)}) + |> Enum.filter(fn {%Action{} = action, labels} -> + included?(action.resource, action.action, filter, labels, label_filter) + end) + |> Enum.map(fn {%Action{} = action, labels} -> path = namespace_segments ++ [Atom.to_string(action.name)] action_entrypoint(action.resource, action.action, %{ domain: domain, + labels: labels, lua_action: Atom.to_string(action.name), path: path, path_string: Enum.join(path, "."), @@ -162,19 +279,22 @@ defmodule AshLua.Surface do end) end - defp derived_action_entrypoints(domain, filter) do + defp derived_action_entrypoints(_domain, _filter, %MapSet{}), do: [] + + defp derived_action_entrypoints(domain, filter, nil) do domain |> Ash.Domain.Info.resources() |> Enum.flat_map(fn resource -> if AshLua.Resource.Info.expose?(resource) do resource |> Ash.Resource.Info.actions() - |> Enum.filter(&included?(resource, &1.name, filter)) + |> Enum.filter(&action_included?(resource, &1.name, filter)) |> Enum.map(fn action -> path = legacy_path(resource, action.name) action_entrypoint(resource, action.name, %{ domain: domain, + labels: [], lua_action: Atom.to_string(action.name), path: path, path_string: Enum.join(path, "."), @@ -213,12 +333,38 @@ defmodule AshLua.Surface do |> MapSet.new() end - defp included?(_resource, _action, nil), do: true + defp label_filter(nil), do: nil + defp label_filter([]), do: nil + defp label_filter(labels) when is_list(labels), do: MapSet.new(labels) + + defp included?(resource, action, filter, labels, label_filter) do + action_included?(resource, action, filter) and labels_included?(labels, label_filter) + end - defp included?(resource, action, %MapSet{} = filter) do + defp action_included?(_resource, _action, nil), do: true + + defp action_included?(resource, action, %MapSet{} = filter) do MapSet.member?(filter, {resource, action}) end + defp labels_included?(_labels, nil), do: true + + defp labels_included?(labels, %MapSet{} = label_filter) do + label_filter + |> MapSet.subset?(MapSet.new(labels)) + end + + defp namespace_labels(%Namespace{labels: labels}) when is_list(labels), do: labels + defp namespace_labels(%Namespace{}), do: [] + + defp action_labels(%Action{labels: labels}) when is_list(labels), do: labels + defp action_labels(%Action{}), do: [] + + defp effective_labels(namespace_labels, %Action{} = action) do + (namespace_labels ++ action_labels(action)) + |> Enum.uniq() + end + defp otp_app_from_manifest(%Manifest{ entrypoints: [%Manifest.Entrypoint{resource: resource} | _] }) do diff --git a/mix.exs b/mix.exs index 77257b2..9dd97df 100644 --- a/mix.exs +++ b/mix.exs @@ -65,6 +65,7 @@ defmodule AshLua.MixProject do {"README.md", title: "Home"}, "documentation/tutorials/getting-started-with-ash-lua.md", "documentation/topics/example-use-cases.md", + "documentation/how_to/use-ash-lua-as-a-runtime.md", "documentation/how_to/integrate-with-ash-ai.md", "CHANGELOG.md" ], @@ -79,7 +80,8 @@ defmodule AshLua.MixProject do ], groups_for_modules: [ AshLua: [ - AshLua + AshLua, + AshLua.Eval ] ] ] diff --git a/test/ash_lua/eval_actions_test.exs b/test/ash_lua/eval_actions_test.exs index 1582ec0..d86d8a3 100644 --- a/test/ash_lua/eval_actions_test.exs +++ b/test/ash_lua/eval_actions_test.exs @@ -102,7 +102,10 @@ defmodule AshLua.EvalActionsTest do }) assert {:ok, %{result: nil, error: err}} = Ash.run_action(input) - assert [%{"code" => "lua_error"} | _] = err["errors"] + assert [%{"code" => "lua_error", "vars" => vars} | _] = err["errors"] + refute String.contains?(err["message"], <<27>>) + assert is_integer(vars["line"]) + assert is_map(vars["source_context"]) end test "an assert(...) on a failed action surfaces the structured error, not 'object is a table'" do diff --git a/test/ash_lua/eval_test.exs b/test/ash_lua/eval_test.exs new file mode 100644 index 0000000..480b54d --- /dev/null +++ b/test/ash_lua/eval_test.exs @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.EvalTest do + use ExUnit.Case, async: false + + alias AshLua.Test.Surface.MCPActions + alias AshLua.Test.Surface.Page + + test "manifest/1 resolves eval resource scopes" do + assert {:ok, manifest} = AshLua.Eval.manifest(eval_resource: MCPActions) + + callables = AshLua.Docs.list_callables(manifest) + + assert "surface.page_list" in callables + refute "surface.admin.page_rename" in callables + end + + test "manifest/1 resolves otp app label scopes" do + assert {:ok, manifest} = AshLua.Eval.manifest(otp_app: :ash_lua, labels: [:read_model]) + + assert AshLua.Docs.list_callables(manifest) == ["surface.page_list"] + end + + test "run/2 evaluates against a prebuilt scoped manifest" do + title = unique_title("Runtime") + {:ok, _page} = Ash.create(Page, %{title: title}, action: :create) + manifest = AshLua.Eval.manifest!(eval_resource: MCPActions) + + assert {:ok, %{result: ^title, error: nil, print_output: ["loaded"]}} = + AshLua.Eval.run( + """ + local rows = assert(surface.page_list({ + fields = { "headline" }, + filter = { headline = "#{title}" } + })) + + print("loaded") + return rows[1].headline + """, + manifest: manifest + ) + end + + test "run/2 accepts Lua safety options" do + assert {:ok, %{result: nil, error: err, print_output: []}} = + AshLua.Eval.run("while true do end", + eval_resource: MCPActions, + lua_options: [max_instructions: 1_000] + ) + + assert [%{"code" => "lua_error"} | _] = err["errors"] + assert err["message"] =~ "instruction budget exceeded" + end + + test "run/2 returns structured Lua syntax errors" do + assert {:ok, %{result: nil, error: err, print_output: []}} = + AshLua.Eval.run( + """ + local x = + return x + """, + eval_resource: MCPActions, + source: "agent_script.lua" + ) + + assert [%{"code" => "lua_error", "vars" => vars} | _] = err["errors"] + refute String.contains?(err["message"], <<27>>) + assert vars["type"] == "unexpected_token" + assert vars["source"] == "agent_script.lua" + assert vars["line"] == 2 + assert vars["source_context"]["pointer_column"] == 1 + + assert Enum.any?(vars["source_context"]["lines"], fn line -> + line["number"] == 2 and line["highlight"] == true + end) + end + + test "docs/2 dispatches against a scoped manifest" do + manifest = AshLua.Eval.manifest!(eval_resource: MCPActions) + + assert {:ok, index} = AshLua.Eval.docs(manifest) + assert index =~ "- `surface.page_list`" + refute index =~ "surface.admin.page_rename" + + assert {:ok, callable} = AshLua.Eval.docs(manifest, name: "surface.page_list") + assert callable =~ "# `surface.page_list`" + + assert {:error, %Ash.Error.Action.InvalidArgument{field: :search}} = + AshLua.Eval.docs(manifest, name: "surface.page_list", search: "page") + end + + defp unique_title(prefix) do + "#{prefix} #{System.unique_integer([:positive])}" + end +end diff --git a/test/ash_lua/surface_test.exs b/test/ash_lua/surface_test.exs index b98f184..3bd915d 100644 --- a/test/ash_lua/surface_test.exs +++ b/test/ash_lua/surface_test.exs @@ -50,6 +50,7 @@ defmodule AshLua.SurfaceTest do assert "surface.page_list" in callables assert "surface.page_create" in callables assert "surface.page_rename" in callables + assert "surface.admin.page_rename" in callables assert "surface.page_summarize" in callables refute "pages.list" in callables refute "surface.page.list_for_storefront" in callables @@ -174,6 +175,52 @@ defmodule AshLua.SurfaceTest do assert {:ok, %{result: ^title, error: nil}} = Ash.run_action(input) end + test "eval_actions labels scope duplicate resource actions by namespace path" do + input = + Ash.ActionInput.for_action(MCPActions, :eval, %{ + script: """ + return surface.admin == nil + """ + }) + + assert {:ok, %{result: true, error: nil}} = Ash.run_action(input) + end + + test "labels and action filters intersect on explicit namespace surfaces" do + assert {:ok, manifest} = + AshLua.Surface.for_otp_app(:ash_lua, + labels: [:public], + action_entrypoints: [{Page, :rename}] + ) + + callables = AshLua.Docs.list_callables(manifest) + + assert callables == ["surface.page_rename"] + end + + test "labels resolve individual action mappings inside a namespace" do + assert {:ok, manifest} = AshLua.Surface.for_otp_app(:ash_lua, labels: [:read_model]) + + assert AshLua.Docs.list_callables(manifest) == ["surface.page_list"] + end + + test "preload_eval_manifests! caches eval resource manifests for an otp app" do + AshLua.Surface.clear_eval_manifest_cache!() + + try do + resources = AshLua.preload_eval_manifests!(:ash_lua) + + assert MCPActions in resources + assert AshLua.Test.Posts.MCPActions in resources + + assert {:ok, manifest} = AshLua.Surface.for_eval_resource(MCPActions) + assert "surface.page_list" in AshLua.Docs.list_callables(manifest) + refute "surface.admin.page_rename" in AshLua.Docs.list_callables(manifest) + after + AshLua.Surface.clear_eval_manifest_cache!() + end + end + test "eval_actions map field_names through returned records" do title = unique_title("Eval Record") {:ok, _page} = Ash.create(Page, %{title: title}, action: :create) @@ -204,10 +251,12 @@ defmodule AshLua.SurfaceTest do assert {:ok, md} = Ash.run_action(input) assert md =~ "- `surface.page_list`" + assert md =~ "- `surface.page_create`" assert md =~ "- `surface.page_rename`" assert md =~ "- `surface.page_summarize`" assert md =~ "- `surface.page`" assert md =~ ~s(name = "full") + refute md =~ "- `surface.admin.page_rename`" refute md =~ "surface.page.list_for_storefront" end diff --git a/test/ash_lua_test.exs b/test/ash_lua_test.exs index 61f1078..f639b6e 100644 --- a/test/ash_lua_test.exs +++ b/test/ash_lua_test.exs @@ -72,6 +72,24 @@ defmodule AshLuaTest do ) end end + + test "forwards source names into Lua runtime errors" do + error = + assert_raise Lua.RuntimeException, fn -> + AshLua.eval!( + """ + local f = nil + return f() + """, + otp_app: :ash_lua, + source: "agent_script.lua" + ) + end + + assert error.source == "agent_script.lua" + assert error.line == 2 + assert Exception.message(error) =~ "agent_script.lua:2" + end end describe "read / update / destroy / generic action" do diff --git a/test/support/fixtures/surface.ex b/test/support/fixtures/surface.ex index 369391d..dc25572 100644 --- a/test/support/fixtures/surface.ex +++ b/test/support/fixtures/surface.ex @@ -10,10 +10,17 @@ defmodule AshLua.Test.Surface do lua do namespace "surface" do - action :page_create, AshLua.Test.Surface.Page, :create - action :page_list, AshLua.Test.Surface.Page, :list_for_storefront - action :page_rename, AshLua.Test.Surface.Page, :rename - action :page_summarize, AshLua.Test.Surface.Page, :summarize + action :page_create, AshLua.Test.Surface.Page, :create, labels: [:public, :writes] + + action :page_list, AshLua.Test.Surface.Page, :list_for_storefront, + labels: [:public, :read_model] + + action :page_rename, AshLua.Test.Surface.Page, :rename, labels: [:public, :writes] + action :page_summarize, AshLua.Test.Surface.Page, :summarize, labels: [:public] + end + + namespace "surface.admin" do + action :page_rename, AshLua.Test.Surface.Page, :rename, labels: [:admin, :writes] end end diff --git a/test/support/fixtures/surface_mcp_actions.ex b/test/support/fixtures/surface_mcp_actions.ex index 35a19ee..11d6d93 100644 --- a/test/support/fixtures/surface_mcp_actions.ex +++ b/test/support/fixtures/surface_mcp_actions.ex @@ -9,6 +9,6 @@ defmodule AshLua.Test.Surface.MCPActions do extensions: [AshLua.EvalActions] eval_actions do - resource AshLua.Test.Surface.Page, actions: [:list_for_storefront, :rename, :summarize] + labels [:public] end end