lib/allm/error/tool_error.ex

defmodule ALLM.Error.ToolError do
  @moduledoc """
  Errors from tool handler execution.

  Layer A — serializable. Note that `:cause` may carry a raised Exception
  struct, the exit reason, or an invalid term the handler returned. Callers
  should treat `:cause` as opaque and rely on `:reason` for programmatic
  dispatch.

  Note: the spec §20 atom `:tool_not_found` is represented here as
  `:not_found` because the module name already carries the "tool" context.

  See Phase 1 design §Sub-phase 1.1 for the closed reason enum.
  """

  @typedoc "Closed set of tool-execution error reasons (spec §20)."
  @type reason ::
          :handler_raised
          | :handler_exit
          | :timeout
          | :invalid_return
          | :not_found
          | :encoding_failed

  @type t :: %__MODULE__{
          reason: reason(),
          message: String.t(),
          tool_name: String.t() | nil,
          tool_call_id: String.t() | nil,
          cause: term() | nil,
          metadata: map()
        }

  @legal_reasons ~w(
    handler_raised
    handler_exit
    timeout
    invalid_return
    not_found
    encoding_failed
  )a

  defexception [
    :reason,
    :message,
    :tool_name,
    :tool_call_id,
    :cause,
    metadata: %{}
  ]

  @doc """
  Build a `%ToolError{}` from a `reason` atom and optional keyword fields.

  `opts` may include `:message`, `:tool_name`, `:tool_call_id`, `:cause`, and
  `:metadata`. When `:message` is omitted, the default is
  `"tool error: \#{reason}"` — with a tool-name suffix
  `"tool error (\#{tool_name}): \#{reason}"` when `:tool_name` is set.

  Raises `ArgumentError` if `reason` is not one of the atoms in the closed
  `t:reason/0` enum.

  ## Examples

      iex> err = ALLM.Error.ToolError.new(:handler_raised)
      iex> err.reason
      :handler_raised
      iex> Exception.message(err)
      "tool error: handler_raised"

      iex> err = ALLM.Error.ToolError.new(:timeout, tool_name: "search_web", tool_call_id: "call_1")
      iex> err.tool_call_id
      "call_1"
      iex> Exception.message(err)
      "tool error (search_web): timeout"
  """
  @spec new(reason(), keyword()) :: t()
  def new(reason, opts \\ []) when is_atom(reason) do
    unless reason in @legal_reasons do
      raise ArgumentError,
            "unknown reason #{inspect(reason)} for ALLM.Error.ToolError " <>
              "(legal: #{inspect(@legal_reasons)})"
    end

    tool_name = Keyword.get(opts, :tool_name)
    message = Keyword.get(opts, :message) || default_message(reason, tool_name)

    %__MODULE__{
      reason: reason,
      message: message,
      tool_name: tool_name,
      tool_call_id: Keyword.get(opts, :tool_call_id),
      cause: Keyword.get(opts, :cause),
      metadata: Keyword.get(opts, :metadata, %{})
    }
  end

  @impl Exception
  def message(%__MODULE__{message: m}) when is_binary(m) and m != "", do: m

  def message(%__MODULE__{reason: r, tool_name: name}) when is_atom(r) and not is_nil(r),
    do: default_message(r, name)

  def message(%__MODULE__{}), do: "tool error"

  defp default_message(reason, nil), do: "tool error: #{reason}"
  defp default_message(reason, name), do: "tool error (#{name}): #{reason}"

  @doc false
  @spec __from_tagged__(map()) :: t()
  def __from_tagged__(data) when is_map(data) do
    %__MODULE__{
      reason: ALLM.Serializer.to_atom_field(data["reason"]),
      message: data["message"],
      tool_name: data["tool_name"],
      tool_call_id: data["tool_call_id"],
      cause: data["cause"],
      metadata: data["metadata"] || %{}
    }
  end
end

defimpl Jason.Encoder, for: ALLM.Error.ToolError do
  def encode(value, opts), do: ALLM.Serializer.encode_tagged(value, opts)
end