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:
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.- 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. - The module is enabled, the scope holds
agent, and the assistant is not switched off for the user (user_settings.enabled) —{:error, :forbidden}. - 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
@type cursor() :: {DateTime.t(), String.t()}
Where the next page of the list starts; opaque, taken from list_conversations/2.
@type refusal() :: :impersonated | PhoenixKit.Users.Auth.SessionRef.refusal() | :forbidden | :not_found
Why a call was refused.
@type session_ref() :: PhoenixKit.Users.Auth.SessionRef.t()
{session_token_uuid, user_uuid} from SessionRef.from_session/1.
@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
@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}.
@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.
@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.
@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.
@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.
@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).
@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}.
@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.
@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}.
@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}.
@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.
The PubSub topic of a chat's events.
@spec unsubscribe(String.t()) :: :ok
Unsubscribes the calling process from the chat's topic; no checks are needed.