lib/omni/tools/repl.ex

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{}`).
  """

  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