PhoenixKitAgent.Conversations (PhoenixKitAgent v0.1.0)

Copy Markdown View Source

The chats and messages of the assistant (spec §5.1-5.2, §8, §10).

Every function acts for a live browser session, never for a bare user uuid: the first argument is the {session_token_uuid, user_uuid} reference that PhoenixKit.Users.Auth.SessionRef.from_session/1 reads from a LiveView's signed session (in its connected mount), and the last is a keyword list that must carry impersonated: boolean — the answer of PhoenixKitWeb.Users.MultiSession.impersonated?/1 for the same session. The flag has no default on purpose: a caller that forgets it raises ArgumentError instead of being let in.

Each call is checked in this order, and the first failure is the answer:

  1. impersonated: true — {:error, :impersonated}. An operator who entered a user's session through impersonation would otherwise read the whole chat history (customer data from tool results) and write as the user, spending their limits (spec §8, В13). Nothing is read or written.
  2. The session's scope (SessionRef.scope_for_session/2): a revoked or expired session, a blocked user, a session of another user — {:error, :session_gone | :mismatch | :inactive | :unconfirmed | :referral_required}. The scope is rebuilt on every call, so a logout takes effect on the next one.
  3. The module is enabled, the scope holds agent, and the assistant is not switched off for the user (user_settings.enabled) — {:error, :forbidden}.
  4. For one chat: it belongs to the session's user. Anything else — someone else's chat, a deleted one, one that never existed, a malformed uuid — is the same {:error, :not_found}, so the answer does not reveal that a chat exists.

The owner is the only one who reads or writes a chat. A soft-deleted chat (status: "deleted") is gone for every function here; an archived one is left out of list_conversations/2 only, and can still be opened and written to (a turn in flight when the user archives the chat must still be able to save its answer). Renaming, archiving and deleting are one conditional UPDATE each (the owner's chat, not deleted), so a concurrent archive cannot bring a deleted chat back.

Writing

A session writes only its user's text: add_message/4 stores a user message with content and ignores every other attribute. Model answers, tool calls and results, notices and their tokens and cost are written by the turn loop (PhoenixKitAgent.Turns) alone.

Reading: what a client gets of a message

get_conversation/3, get_message/4 and list_messages/3 never hand out what a tool returned, nor the arguments the model gave a tool (customer data read under the rights the session had then): a tool message comes with tool_result: nil and tool_summary: %{name:, outcome:, count:, truncated:} (count — how many items it found, when the result has one list or a count/total; nil otherwise), and the tool_calls of an assistant message keep only "id" and "name". The turn loop reads the full rows on its own (spec §6.6).

Events

Every stored message is broadcast as {:agent_message_added, conversation_uuid, message_uuid} on topic/1 ("agent:conv:<uuid>") after it is committed. The event carries uuids, not the message: a listener reads it through get_message/4 (or list_messages/3 with after:), with a fresh scope. subscribe/3 subscribes only after the checks above; unsubscribe/1 undoes it (subscribing twice delivers every event twice). The topic also hears {:agent_attachment_processed, conversation_uuid, attachment_uuid} when a file added to the chat is ready or has failed (PhoenixKitAgent.Attachments). PubSub is in-process and anyone in the VM can subscribe to a topic directly; the check guards the code that serves a user's browser, not hostile code in the node.

Summary

Types

Where the next page of the list starts; opaque, taken from list_conversations/2.

Why a call was refused.

{session_token_uuid, user_uuid} from SessionRef.from_session/1.

What a client learns of a tool call's result (see the moduledoc).

Functions

Adds the user's text to their chat, as a user message: only content (:content or "content", not blank, and no longer than agent_history_chars, as a turn takes it) is taken from attrs, never a role, tool fields, tokens or cost (see "Writing" in the moduledoc). The chat is updated in the same transaction: the message count, last_message_at and — when the chat has no title yet — the title, the first 60 characters of the text. After the commit the chat's topic hears {:agent_message_added, conversation_uuid, message_uuid}.

Archives a chat: it leaves the list, nothing is deleted.

Checks 1-3 of the moduledoc for a session, without a chat: {:ok, user_uuid} when the session may use the assistant now.

Creates an empty active chat for the session's user. attrs may carry a :title; the owner and status are never taken from it.

Soft-deletes a chat: its row and messages stay, but no function here sees it any more.

The user's chat with all its messages, oldest first, as a client sees them (see "Reading" in the moduledoc).

One message of the user's chat, as a client sees it (see "Reading" in the moduledoc) — what a listener of {:agent_message_added, conversation_uuid, message_uuid} reads. A message of another chat, or none, is {:error, :not_found}.

The user's active chats, newest last_message_at first, one page at a time.

The messages of the user's chat, oldest first, as a client sees them (see "Reading" in the moduledoc).

Renames a chat. A blank title or one over 200 characters is an {:error, changeset}.

Subscribes the calling process to the chat's topic, but only after the checks of the moduledoc — a chat of someone else is {:error, :not_found} and nothing is subscribed.

The PubSub topic of a chat's events.

Unsubscribes the calling process from the chat's topic; no checks are needed.

Types

cursor()

@type cursor() :: {DateTime.t(), String.t()}

Where the next page of the list starts; opaque, taken from list_conversations/2.

refusal()

@type refusal() ::
  :impersonated
  | PhoenixKit.Users.Auth.SessionRef.refusal()
  | :forbidden
  | :not_found

Why a call was refused.

session_ref()

@type session_ref() :: PhoenixKit.Users.Auth.SessionRef.t()

{session_token_uuid, user_uuid} from SessionRef.from_session/1.

tool_summary()

@type tool_summary() :: %{
  name: String.t() | nil,
  outcome: String.t() | nil,
  count: non_neg_integer() | nil,
  truncated: boolean()
}

What a client learns of a tool call's result (see the moduledoc).

Functions

add_message(session_ref, conversation_uuid, attrs, opts)

@spec add_message(session_ref(), term(), map(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Message.t()}
  | {:error, refusal() | Ecto.Changeset.t()}

Adds the user's text to their chat, as a user message: only content (:content or "content", not blank, and no longer than agent_history_chars, as a turn takes it) is taken from attrs, never a role, tool fields, tokens or cost (see "Writing" in the moduledoc). The chat is updated in the same transaction: the message count, last_message_at and — when the chat has no title yet — the title, the first 60 characters of the text. After the commit the chat's topic hears {:agent_message_added, conversation_uuid, message_uuid}.

archive_conversation(session_ref, conversation_uuid, opts)

@spec archive_conversation(session_ref(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}

Archives a chat: it leaves the list, nothing is deleted.

authorize_session(session_ref, opts)

@spec authorize_session(session_ref(), keyword()) ::
  {:ok, String.t()} | {:error, refusal()}

Checks 1-3 of the moduledoc for a session, without a chat: {:ok, user_uuid} when the session may use the assistant now.

create_conversation(session_ref, attrs, opts)

@spec create_conversation(session_ref(), map(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}

Creates an empty active chat for the session's user. attrs may carry a :title; the owner and status are never taken from it.

delete_conversation(session_ref, conversation_uuid, opts)

@spec delete_conversation(session_ref(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}

Soft-deletes a chat: its row and messages stay, but no function here sees it any more.

get_conversation(session_ref, conversation_uuid, opts)

@spec get_conversation(session_ref(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()} | {:error, refusal()}

The user's chat with all its messages, oldest first, as a client sees them (see "Reading" in the moduledoc).

get_message(session_ref, conversation_uuid, message_uuid, opts)

@spec get_message(session_ref(), term(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Message.t()} | {:error, refusal()}

One message of the user's chat, as a client sees it (see "Reading" in the moduledoc) — what a listener of {:agent_message_added, conversation_uuid, message_uuid} reads. A message of another chat, or none, is {:error, :not_found}.

list_conversations(session_ref, opts)

@spec list_conversations(session_ref(), keyword()) ::
  {:ok,
   %{
     conversations: [PhoenixKitAgent.Schemas.Conversation.t()],
     next_cursor: cursor() | nil
   }}
  | {:error, refusal() | :invalid_cursor}

The user's active chats, newest last_message_at first, one page at a time.

Options (besides impersonated:): :limit (default 20, at most 50) and :cursor, the next_cursor of the previous page. Returns the chats and the cursor of the next page (nil on the last one). Archived and deleted chats are not listed. Messages are not loaded.

list_messages(session_ref, conversation_uuid, opts)

@spec list_messages(session_ref(), term(), keyword()) ::
  {:ok, [PhoenixKitAgent.Schemas.Message.t()]}
  | {:error, refusal() | :invalid_cursor}

The messages of the user's chat, oldest first, as a client sees them (see "Reading" in the moduledoc).

Options (besides impersonated:): :after — the uuid of a message of this chat; only the messages after it come; :limit — default 50, at most 200. The next page starts after the last message of this one. From the other end: latest: true — the last limit messages of the chat; :before — the uuid of a message of this chat, the limit messages just before it (the page before starts before the first message of this one). Every page is oldest first. A cursor that is not a message of this chat, or :after with :before / :latest, is {:error, :invalid_cursor}.

rename_conversation(session_ref, conversation_uuid, title, opts)

@spec rename_conversation(session_ref(), term(), term(), keyword()) ::
  {:ok, PhoenixKitAgent.Schemas.Conversation.t()}
  | {:error, refusal() | Ecto.Changeset.t()}

Renames a chat. A blank title or one over 200 characters is an {:error, changeset}.

subscribe(session_ref, conversation_uuid, opts)

@spec subscribe(session_ref(), term(), keyword()) :: :ok | {:error, refusal()}

Subscribes the calling process to the chat's topic, but only after the checks of the moduledoc — a chat of someone else is {:error, :not_found} and nothing is subscribed.

topic(conversation_uuid)

@spec topic(String.t()) :: String.t()

The PubSub topic of a chat's events.

unsubscribe(conversation_uuid)

@spec unsubscribe(String.t()) :: :ok

Unsubscribes the calling process from the chat's topic; no checks are needed.