lib/openagents/tools/embeddings.ex

main at 58e6347eeb72 · 4 KB

defmodule OpenAgents.Tools.Embeddings do
  @moduledoc """
  Vector embeddings for the tool catalog, for cosine-similarity discovery.

  There are only a handful of tools, so the vectors live in a `persistent_term`
  cache keyed by the registry digest and cosine is computed in process — no
  pgvector, no per-turn index. The provider is the same embedding boundary the
  memory system uses.

  Everything degrades safely: if embeddings are disabled or the provider errors,
  `vectors/1` and `embed_query/1` return `nil`/`:error` and the Selector falls
  back to lexical scoring. A tool turn must never fail because embeddings are
  unavailable.
  """

  alias OpenAgents.Tools.Discovery.Doc
  alias OpenAgents.Tools.Snapshot

  require Logger

  @persistent_key {__MODULE__, :vectors}

  @doc "Whether embedding-backed discovery is switched on and configured."
  @spec enabled?() :: boolean()
  def enabled? do
    config = Application.get_env(:openagents, :tool_discovery, [])
    Keyword.get(config, :embeddings_enabled, false) == true and not is_nil(provider())
  end

  @doc """
  Compute and cache the tool vectors for this snapshot's digest. Safe to call at
  boot from a Task; returns `:ok` on success or when disabled, `:error` on a
  provider failure (leaving the cache empty so callers fall back to lexical).
  """
  @spec warm(Snapshot.t()) :: :ok | :error
  def warm(%Snapshot{digest: digest} = snapshot) do
    cond do
      not enabled?() -> :ok
      is_map(cached(digest)) -> :ok
      true -> build_and_cache(snapshot)
    end
  rescue
    error ->
      Logger.warning("tool_embeddings_warm_failed code=#{OpenAgents.OperationalLog.code(error)}")
      :error
  end

  @doc "Cached tool-name → vector map for a digest, or nil when unavailable."
  @spec vectors(String.t()) :: %{optional(String.t()) => [float()]} | nil
  def vectors(digest) when is_binary(digest), do: cached(digest)
  def vectors(_digest), do: nil

  @doc "Embed a query string, or `:error` when embeddings are unavailable."
  @spec embed_query(String.t()) :: {:ok, [float()]} | :error
  def embed_query(text) when is_binary(text) and text != "" do
    if enabled?() do
      case provider().embed(text, embed_config()) do
        {:ok, vector} when is_list(vector) and vector != [] -> {:ok, vector}
        _other -> :error
      end
    else
      :error
    end
  rescue
    _error -> :error
  end

  def embed_query(_text), do: :error

  @doc "Cosine similarity of two equal-length vectors; 0.0 on any mismatch."
  @spec cosine([float()], [float()]) :: float()
  def cosine(a, b) when is_list(a) and is_list(b) and length(a) == length(b) and a != [] do
    {dot, na, nb} =
      Enum.zip(a, b)
      |> Enum.reduce({0.0, 0.0, 0.0}, fn {x, y}, {dot, na, nb} ->
        {dot + x * y, na + x * x, nb + y * y}
      end)

    if na == 0.0 or nb == 0.0, do: 0.0, else: dot / (:math.sqrt(na) * :math.sqrt(nb))
  end

  def cosine(_a, _b), do: 0.0

  # ── internal ───────────────────────────────────────────────────────────────

  defp build_and_cache(%Snapshot{digest: digest} = snapshot) do
    tools = Map.values(snapshot.tools)

    vectors =
      tools
      |> Enum.reduce_while(%{}, fn tool, acc ->
        case provider().embed(Doc.text(tool), embed_config()) do
          {:ok, vector} when is_list(vector) and vector != [] ->
            {:cont, Map.put(acc, tool.name, vector)}

          _other ->
            {:halt, :error}
        end
      end)

    case vectors do
      :error ->
        :error

      map when map_size(map) > 0 ->
        :persistent_term.put(@persistent_key, {digest, map})

        Logger.info(
          "tool_embeddings_warmed digest=#{binary_part(digest, 0, 12)} count=#{map_size(map)}"
        )

        :ok

      _empty ->
        :ok
    end
  end

  defp cached(digest) do
    case :persistent_term.get(@persistent_key, nil) do
      {^digest, map} -> map
      _absent_or_stale -> nil
    end
  end

  defp provider do
    :openagents
    |> Application.get_env(:tool_discovery, [])
    |> Keyword.get(:provider)
  end

  defp embed_config do
    config = Application.get_env(:openagents, :tool_discovery, [])

    %{
      model_id: Keyword.get(config, :model_id, "text-embedding-3-small"),
      model_version: Keyword.get(config, :model_version, "2024-01"),
      dimensions: Keyword.get(config, :dimensions, 64)
    }
  end
end