defmodule Omni.Tools.Repl do @moduledoc """ An `Omni.Tool` for executing Elixir code in a sandboxed peer node. Each invocation runs in a fresh Erlang peer node with a clean slate. IO output is captured and returned alongside the expression result. tool = Omni.Tools.Repl.new() tool = Omni.Tools.Repl.new(timeout: 30_000, max_output: 10_000) ## Extensions Extensions inject code and/or documentation into the sandbox. Pass module-based extensions as `{module, opts}` tuples, or use inline extensions via `Omni.Tools.Repl.Extension.new/1`. alias Omni.Tools.Repl.Extension tool = Omni.Tools.Repl.new( extensions: [ {MyApp.ReplExtension, api_key: "sk-..."}, Extension.new(description: "Req and Jason are available.") ] ) See `Omni.Tools.Repl.Extension` for the full extension API. ## Options - `:timeout` — execution timeout in milliseconds. Default `60_000`. - `:max_output` — output truncation limit in bytes. Default `50_000`. - `:extensions` — list of extensions (module tuples or `%Extension{}`). ## Application config Any option can be set under the module key in application config — instance opts to `new/1` take precedence. See `Omni.Tools` for details. config :omni_tools, Omni.Tools.Repl, timeout: 30_000, max_output: 10_000 """ use Omni.Tool, name: "repl" alias Omni.Tools.Repl.{Extension, Sandbox} @defaults [ timeout: 60_000, max_output: 50_000, extensions: [] ] @impl Omni.Tool def schema do import Omni.Schema object( %{ title: string( description: "Brief title describing what the code achieves in active form, e.g. 'Calculating average score'" ), code: string(description: "Elixir code to evaluate") }, required: [:title, :code] ) end @impl Omni.Tool def init(opts) do opts = @defaults |> Keyword.merge(Application.get_env(:omni_tools, __MODULE__, [])) |> Keyword.merge(opts || []) [ timeout: Keyword.fetch!(opts, :timeout), max_output: Keyword.fetch!(opts, :max_output), extensions: opts |> Keyword.fetch!(:extensions) |> resolve_extensions() ] end @impl Omni.Tool def description(opts) do """ Execute Elixir code in a sandboxed peer node. ## When to Use - Calculations and data transformations - Testing code snippets and exploring APIs - Processing, analysing, or generating data - Verifying assumptions about Elixir behaviour ## Environment - Full Elixir/Erlang standard library - Each invocation is a fresh VM — no state persists between calls - The host application's compiled dependencies are available - Use `Mix.install/1` to add packages not already available (dev only) ## Output - IO output (IO.puts, IO.inspect, etc.) is captured and returned to you - The return value of the last expression is always shown - The user does not see raw output — summarise key findings in your response ## Example numbers = [10, 20, 15, 25] sum = Enum.sum(numbers) avg = sum / length(numbers) IO.puts("Sum: \#{sum}, Average: \#{avg}") ## Important Notes - Be intentional about return values — end with :ok if only IO output matters - For large data, use IO.inspect(data, limit: 20) rather than returning the full structure - Define modules freely — they exist only for the current invocation\ #{extension_section(opts)} """ end @impl Omni.Tool def call(%{code: code}, opts) do setup = build_setup(opts) sandbox_opts = opts |> Keyword.take([:timeout, :max_output]) |> maybe_put_setup(setup) case Sandbox.run(code, sandbox_opts) do {:ok, %{output: output, result: result}} -> format_success(output, result) {:error, :timeout, %{output: output}} -> raise format_error(output, "Execution timed out") {:error, :noconnection, %{output: output}} -> raise format_error(output, "Sandbox node crashed") {:error, {kind, reason, stacktrace}, %{output: output}} -> raise format_error(output, Exception.format(kind, reason, stacktrace)) end end # ── Setup ───────────────────────────────────────────────────────── defp build_setup(opts) do case opts |> Keyword.get(:extensions, []) |> Enum.map(& &1.code) |> Enum.reject(&is_nil/1) do [] -> nil codes -> codes end end defp maybe_put_setup(opts, nil), do: opts defp maybe_put_setup(opts, setup), do: Keyword.put(opts, :setup, setup) defp resolve_extensions(exts) do Enum.map(exts, fn %Extension{} = ext -> ext {mod, ext_opts} when is_atom(mod) -> %Extension{code: mod.code(ext_opts), description: mod.description(ext_opts)} mod when is_atom(mod) -> %Extension{code: mod.code([]), description: mod.description([])} other -> raise ArgumentError, "expected an %Extension{}, {module, opts} tuple, or module, got: #{inspect(other)}" end) end # ── Description helpers ─────────────────────────────────────────── defp extension_section(opts) do case Keyword.get(opts, :extensions, []) do [] -> "" exts -> desc = exts |> Enum.map(& &1.description) |> Enum.reject(&is_nil/1) |> Enum.reject(&(&1 == "")) |> Enum.join("\n\n") case desc do "" -> "" text -> "\n\n" <> text end end end # ── Formatting ──────────────────────────────────────────────────── defp format_success(output, result) do inspected = inspect(result, pretty: true) case output do "" -> "=> #{inspected}" _ -> "#{output}\n=> #{inspected}" end end defp format_error("", message), do: message defp format_error(output, message), do: "#{output}\n#{message}" end