lib/llm_core/agent.ex

defmodule LlmCore.Agent do
  @moduledoc """
  Agent struct representing a registered LLM provider with a human-friendly name.

  Each agent encapsulates:

    * A name/alias for routing lookups
    * The underlying provider module or CLI config
    * Default configuration (model, temperature, etc.)

  Agents are registered by the config loader from TOML `[providers.*]` blocks.
  They are keyed by the provider's aliases, not by `agent.name`.

  ## Accessing Agents

      # By alias (from TOML or runtime)
      {:ok, agent} = LlmCore.Agent.Registry.get("claude")

      # Get the dispatchable provider
      provider = LlmCore.Agent.dispatch_provider(agent)

  """

  @type t :: %__MODULE__{
          name: String.t(),
          provider: module() | struct(),
          config: map(),
          registered_at: DateTime.t()
        }

  @enforce_keys [:name, :provider]
  defstruct [
    :name,
    :provider,
    config: %{},
    registered_at: nil,
    provider_struct: nil
  ]

  @name_regex ~r/^[a-z0-9][a-z0-9_-]*$/

  @doc """
  Creates a new Agent struct with validation.

  ## Parameters

    * `name` - Human-friendly alias (lowercase alphanumeric with dashes/underscores)
    * `provider` - Module implementing `LlmCore.LLM.Provider` behaviour
    * `config` - Provider-specific configuration map (default: %{})

  ## Returns

    * `{:ok, Agent.t()}` - Valid agent
    * `{:error, :invalid_name}` - Invalid name format

  ## Examples

      iex> Agent.new("steve", LlmCore.LLM.CLIProvider, %{model: "claude-3-opus"})
      {:ok, %Agent{name: "steve", provider: LlmCore.LLM.CLIProvider, ...}}

      iex> Agent.new("INVALID", LlmCore.LLM.CLIProvider, %{})
      {:error, :invalid_name}
  """
  @spec new(String.t(), module(), map()) :: {:ok, t()} | {:error, :invalid_name}
  def new(name, provider, config \\ %{}) do
    if valid_name?(name) do
      provider_value = resolve_provider_value(provider, config)

      agent = %__MODULE__{
        name: name,
        provider: provider,
        config: config,
        provider_struct: provider_value,
        registered_at: DateTime.utc_now()
      }

      {:ok, agent}
    else
      {:error, :invalid_name}
    end
  end

  @doc """
  Validates an agent name format.

  Valid names must:
  - Start with lowercase letter or number
  - Contain only lowercase letters, numbers, dashes, and underscores
  - Not be empty

  ## Examples

      iex> Agent.valid_name?("steve")
      true

      iex> Agent.valid_name?("claude-code")
      true

      iex> Agent.valid_name?("UPPERCASE")
      false

      iex> Agent.valid_name?("")
      false
  """
  @spec valid_name?(String.t()) :: boolean()
  def valid_name?(name) when is_binary(name) do
    String.length(name) > 0 and Regex.match?(@name_regex, name)
  end

  def valid_name?(_), do: false

  @doc """
  Returns the dispatchable provider value.

  For struct-based providers (CLIProvider, Appliance), returns the struct.
  For module-based providers, returns the module atom.
  """
  @spec dispatch_provider(t()) :: module() | struct()
  def dispatch_provider(%__MODULE__{provider_struct: %_{} = ps}), do: ps
  def dispatch_provider(%__MODULE__{provider: provider}), do: provider

  defp resolve_provider_value(%LlmCore.LLM.CLIProvider{} = p, _config), do: p

  defp resolve_provider_value(module, config) when is_atom(module) do
    if module == LlmCore.LLM.CLIProvider or function_exported?(module, :__struct__, 0) do
      build_provider_struct(module, config)
    else
      nil
    end
  end

  defp resolve_provider_value(_, _), do: nil

  defp build_provider_struct(LlmCore.LLM.CLIProvider, config) do
    cli_name = config[:cli_provider] || config["cli_provider"]

    if cli_name do
      LlmCore.LLM.CLIProvider.from_config(cli_name)
    else
      nil
    end
  end

  defp build_provider_struct(_module, _config), do: nil
end