lib/openagents/tools/reach.ex

58e6347eeb72 · 4 KB

defmodule OpenAgents.Tools.Reach do
  @moduledoc """
  What a caller must already hold before a tool can succeed for them.

  A tool the caller cannot reach is not a tool. Offering it costs a provider
  round trip, a refusal the model has to read, and a turn spent explaining the
  refusal to someone who could not have acted on it. So the catalog resolves
  the caller once per turn and leaves unreachable tools out of the set the
  model is shown.

  Three requirements, declared per tool as `reach:` on its specification:

  - `:signed_in_owner` — the conversation resolves to an active account. Every
    tool that reads or acts on account-owned records needs this; without it the
    tool refuses with `owner_not_signed_in`.
  - `:paired_computer` — that account has an active paired Computer. Every tool
    that runs work *on* a Computer needs this; `computer_list` deliberately
    does not, because listing zero Computers is how the model learns to say
    "pair one first".
  - `:operator` — that account holds operator authority. `scv_deploy` spends
    our capacity rather than the caller's, so SCV-001 makes it operator-only.

  A tool with no `reach:` is reachable by anyone the scope, authority, and
  surface checks already admit — `module_discover`, conversation recall, and
  memory are reachable by any admitted conversation, and the Box tools depend
  on deployment configuration rather than on who is asking.

  This narrowing is advisory. It decides what to *offer*; it never decides what
  is *allowed*. Each tool still resolves its own owner and re-checks its own
  gate at execution (TOOL-002), so a stale catalog cannot widen authority.
  """

  alias OpenAgents.Accounts
  alias OpenAgents.Accounts.User
  alias OpenAgents.Machines
  alias OpenAgents.Repo
  alias OpenAgents.Tools.{ExecutionContext, OwnerContext, Tool}

  @requirements [:signed_in_owner, :paired_computer, :operator]

  defmodule Caller do
    @moduledoc "One turn's resolved caller authority, read once and reused."

    defstruct owner: nil, operator?: false, paired_computer?: false

    @type t :: %__MODULE__{
            owner: OpenAgents.Accounts.User.t() | nil,
            operator?: boolean(),
            paired_computer?: boolean()
          }
  end

  @doc "Every requirement a tool specification may declare."
  @spec requirements() :: [Tool.reach_requirement()]
  def requirements, do: @requirements

  @doc "A caller who holds nothing — the honest answer when no owner resolves."
  @spec unbound() :: Caller.t()
  def unbound, do: %Caller{}

  @doc """
  Resolves the caller behind an execution context.

  Goes through `OpenAgents.Tools.OwnerContext`, so the catalog and the tools
  agree about who the owner is instead of each deciding for itself.
  """
  @spec caller(ExecutionContext.t()) :: Caller.t()
  def caller(%ExecutionContext{} = context) do
    case OwnerContext.resolve(context) do
      {:ok, %User{} = user} -> owner(user)
      {:error, _unavailable} -> unbound()
    end
  end

  @doc "Resolves the caller from an account id, for surfaces holding the visitor already."
  @spec caller_for_user_id(String.t() | nil) :: Caller.t()
  def caller_for_user_id(user_id) when is_binary(user_id) do
    case Ecto.UUID.cast(user_id) do
      {:ok, _id} -> User |> Repo.get(user_id) |> owner()
      :error -> unbound()
    end
  end

  def caller_for_user_id(_user_id), do: unbound()

  @doc "Resolves the caller from an already-loaded account."
  @spec owner(User.t() | nil) :: Caller.t()
  def owner(%User{status: "active"} = user) do
    %Caller{
      owner: user,
      operator?: Accounts.admin?(user),
      paired_computer?: Machines.active_machine?(user.id)
    }
  end

  def owner(_user), do: unbound()

  @doc "Whether this caller holds everything the tool's `reach:` names."
  @spec reachable?(Tool.t(), Caller.t()) :: boolean()
  def reachable?(%Tool{} = tool, %Caller{} = caller), do: unmet(tool, caller) == []

  @doc "The requirements this caller does not hold, in declaration order."
  @spec unmet(Tool.t(), Caller.t()) :: [Tool.reach_requirement()]
  def unmet(%Tool{reach: reach}, %Caller{} = caller) when is_list(reach),
    do: Enum.reject(reach, &met?(&1, caller))

  def unmet(%Tool{}, %Caller{}), do: []

  defp met?(:signed_in_owner, %Caller{owner: %User{}}), do: true
  defp met?(:signed_in_owner, %Caller{}), do: false
  defp met?(:paired_computer, %Caller{paired_computer?: paired?}), do: paired?
  defp met?(:operator, %Caller{operator?: operator?}), do: operator?
  defp met?(_unknown, %Caller{}), do: false
end