lib/planck/agent/sidecar.ex

defmodule Planck.Agent.Sidecar do
  @moduledoc """
  Behaviour and utilities for sidecar applications that extend planck_headless
  over distributed Erlang.

  ## Behaviour

  A sidecar entry-point module implements one required callback:

  - `tools/0` — returns `[Planck.Agent.Tool.t()]` with full `execute_fn` closures.
    These run **locally on the sidecar node**.

  ## Module-level utilities

  `Planck.Agent.Sidecar` itself provides module-level functions that
  planck_headless calls on the sidecar node via `:rpc.call/5`. Because
  `planck_agent` is a dependency of both planck_headless and the sidecar, these
  are available on both nodes:

  - `discover/0` — finds the module implementing this behaviour (cached in
    `:persistent_term` after the first call).
  - `list_tools/0` — discovers the entry module and returns its tools as
    `[Planck.AI.Tool.t()]` (no closures, serialisable across nodes).
  - `list_tools/1` — same but takes an explicit module; intended for tests.
  - `execute_tool/3` — discovers the entry module and executes a named tool.
  - `execute_tool/4` — same but takes an explicit module; intended for tests.

  planck_headless calls:

      :rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_tools, [])
      :rpc.call(sidecar_node, Planck.Agent.Sidecar, :execute_tool,
                [tool_name, agent_id, args], timeout)

  ## Minimal example

      defmodule MySidecar.Planck do
        use Planck.Agent.Sidecar

        @impl true
        def tools do
          [
            Planck.Agent.Tool.new(
              name: "run_tests",
              description: "Run the test suite. Pass timeout_ms to override the default.",
              parameters: %{
                "type" => "object",
                "properties" => %{
                  "timeout_ms" => %{
                    "type" => "integer",
                    "description" => "Max milliseconds to wait (default 120000)"
                  }
                }
              },
              execute_fn: fn _agent_id, _id, args ->
                timeout = Map.get(args, "timeout_ms", 120_000)
                case System.cmd("mix", ["test"], timeout: timeout) do
                  {output, 0} -> {:ok, output}
                  {output, _} -> {:error, output}
                end
              end
            )
          ]
        end
      end

  See `specs/sidecar.md` for the full design.
  """

  @doc """
  Return the sidecar's tools as `Planck.Agent.Tool` structs (with `execute_fn`).

  This is the only required callback. `execute_fn` closures run **locally on the
  sidecar node** — they are never serialised or called on planck_headless.

  Each tool should accept an optional `"timeout_ms"` argument in its parameter
  schema so the AI can hint at how long to wait for the tool call.
  """
  @callback tools() :: [Planck.Agent.Tool.t()]

  @doc """
  Convenience macro for implementing the `Planck.Agent.Sidecar` behaviour.

  `use Planck.Agent.Sidecar` injects:

  - `@behaviour Planck.Agent.Sidecar` — marks the module as a sidecar entry point.
  - A default `tools/0` returning `[]` — override this to provide tools.

  ## Usage

      defmodule MySidecar.Planck do
        use Planck.Agent.Sidecar

        @impl true
        def tools do
          [
            Planck.Agent.Tool.new(
              name: "run_tests",
              description: "Run the test suite.",
              parameters: %{"type" => "object", "properties" => %{}},
              execute_fn: fn _agent_id, _id, _args ->
                {out, 0} = System.cmd("mix", ["test"])
                {:ok, out}
              end
            )
          ]
        end
      end

  The `tools/0` function is the only thing you normally need to override.
  `list_tools/0`, `discover/0`, `execute_tool/3`, and `execute_tool/4` are
  **not** injected here — they are module-level functions on
  `Planck.Agent.Sidecar` itself that planck_headless calls on the sidecar node:

      :rpc.call(node, Planck.Agent.Sidecar, :list_tools, [])
      :rpc.call(node, Planck.Agent.Sidecar, :execute_tool,
                [tool_name, agent_id, args], timeout)

  This design keeps the dispatch logic in `planck_agent` (available on both
  nodes) rather than requiring each sidecar module to implement it. No config
  is needed — `list_tools/0` discovers the entry module automatically via
  `discover/0`.
  """
  defmacro __using__(_options) do
    quote do
      @behaviour Planck.Agent.Sidecar

      @impl Planck.Agent.Sidecar
      def tools, do: []

      defoverridable tools: 0
    end
  end

  # ---------------------------------------------------------------------------
  # Module-level utilities called via :rpc.call on the sidecar node
  # ---------------------------------------------------------------------------

  @doc """
  Discover the module in the current node that implements `Planck.Agent.Sidecar`.

  Scans modules across all loaded OTP applications and returns the first one
  whose `@behaviour` attribute includes `Planck.Agent.Sidecar`, or `nil` if
  none is found. Only Elixir modules (names starting with `"Elixir."`) are
  checked; Erlang modules are skipped.

  Successful results are cached in `:persistent_term`. `nil` results are **not**
  cached — the next call will retry the scan, which is useful when the sidecar
  entry module is loaded after `discover/0` is first called.

  Called by planck_headless on the sidecar node via `list_tools/0`. You
  normally do not need to call this directly.
  """
  @spec discover() :: module() | nil
  def discover do
    sidecar_module_key = {__MODULE__, :entry_module}

    with :miss <- :persistent_term.get(sidecar_module_key, :miss),
         module when not is_nil(module) <- scan_entry_module() do
      :persistent_term.put(sidecar_module_key, module)
      module
    end
  end

  @spec elixir_module?(module()) :: boolean()
  defp elixir_module?(mod), do: mod |> Atom.to_string() |> String.starts_with?("Elixir.")

  @spec scan_entry_module() :: module() | nil
  defp scan_entry_module do
    :application.loaded_applications()
    |> Enum.flat_map(fn {app, _, _} ->
      case :application.get_key(app, :modules) do
        {:ok, mods} -> mods
        _ -> []
      end
    end)
    |> Enum.find(fn mod ->
      elixir_module?(mod) and
        :code.ensure_loaded(mod) == {:module, mod} and
        __MODULE__ in (mod.__info__(:attributes)[:behaviour] || [])
    end)
  end

  @doc """
  Discover the sidecar entry module and return its tools as `[Planck.AI.Tool.t()]`.

  Combines `discover/0` and `list_tools/1`. Returns `[]` if no entry module is
  found.

  Called by planck_headless on the sidecar node:

      :rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_tools, [])
  """
  @spec list_tools() :: [Planck.AI.Tool.t()]
  def list_tools do
    case discover() do
      nil -> []
      module -> list_tools(module)
    end
  end

  @doc """
  Convert `module.tools()` to `[Planck.AI.Tool.t()]` — serialisable, no closures.
  """
  @spec list_tools(module()) :: [Planck.AI.Tool.t()]
  def list_tools(module) do
    Enum.map(module.tools(), fn tool ->
      Planck.AI.Tool.new(
        name: tool.name,
        description: tool.description,
        parameters: tool.parameters
      )
    end)
  end

  @doc """
  Discover the entry module and execute a named tool.

  Called by planck_headless on the sidecar node:

      :rpc.call(sidecar_node, Planck.Agent.Sidecar, :execute_tool,
                [tool_name, agent_id, tool_call_id, args], timeout)

  The `timeout` is read from `args["timeout_ms"]` by the planck_headless RPC
  wrapper, not by this function.
  """
  @spec execute_tool(String.t(), String.t(), String.t(), map()) ::
          {:ok, term()} | {:error, term()}
  def execute_tool(tool_name, agent_id, tool_call_id, args) do
    case discover() do
      nil -> {:error, "no sidecar entry module found"}
      module -> execute_tool(module, tool_name, agent_id, tool_call_id, args)
    end
  end

  @doc """
  Execute a named tool via an explicit sidecar module's `tools/0` list.

  Intended for tests. Production code should use `execute_tool/3`.
  """
  @spec execute_tool(module(), String.t(), String.t(), String.t(), map()) ::
          {:ok, term()} | {:error, term()}
  def execute_tool(module, tool_name, agent_id, tool_call_id, args) do
    case Enum.find(module.tools(), &(&1.name == tool_name)) do
      nil -> {:error, "unknown tool: #{tool_name}"}
      tool -> tool.execute_fn.(agent_id, tool_call_id, args)
    end
  end
end