PhoenixKitAgent.Confirmations (PhoenixKitAgent v0.1.0)

Copy Markdown View Source

The confirmation cards of the chat (spec §12 Ф2, Б2.2): a write the model asked for waits for the user, who runs it, turns it down or asks for it again.

A turn never writes (PhoenixKitAgent.Turns.Runner): core answers a write of a live session with a preview and holds the call in a confirmation row bound to the user (PhoenixKit.API.Executor.execute_as/5, allow: :all), and the turn stores a tool message needs_confirmation with the row's uuid (confirmation_uuid) and the answer that asked for it (assistant_message_uuid), and ends there. A card is that message; it waits while the chat has no notice with the same confirmation_uuid.

Like PhoenixKitAgent.Conversations, every function acts for a live browser session and takes the answer of MultiSession.impersonated?/1 for it: an impersonated session, a session that cannot act, a user who may not use the assistant, someone else's chat, or a message that is not a card of it, are refused before anything else is read (the refusals of Conversations.owned_conversation/3, then :not_waiting).

A click

confirm/5, reject/5 and repeat/5 take the user's turn lock for the click (PhoenixKitAgent.Turns.Locks.acquire_user/4): while a turn of the user runs, a click is {:error, :turn_running}, while another click runs {:error, :click_running}, and no turn starts while a click runs (the turn is refused :click_in_flight). The lock blocks a turn for 30 s from the moment it was last kept: a process next to the click, under PhoenixKitAgent.Confirmations.TaskSupervisor, keeps it fresh every 10 s for as long as the click runs (Locks.keep_user/4; config :phoenix_kit_agent, click_keep_ms:, held between 100 ms and 15 s, half the hold), and stops with it, so a lock never released (its node went down) frees the user's turns 30 s after its last keep. Should the database refuse the keeps for longer than the hold, a turn may start while the click still runs. A click calls no model and stores no user message, so it spends neither money nor a turn of the user's limits. The card is read again under the lock.

  • confirm/5 redeems the row (Executor.redeem_as/6) with the same tool and the arguments the model sent — the registry name of the card and PhoenixKitAgent.Turns.Tools.arguments/1 of the call in its assistant message — under the rights of the session that clicks, now. Core checks everything again (the session, the tool and its module, the "Agent may act" box, the scope and the permission, the rate limit, the input, a fresh dry run against the preview's fingerprint) and spends the row atomically before the handler runs.
  • reject/5 turns the row down (PhoenixKit.API.Confirm.reject_for_user/2).
  • repeat/5, for a card whose row ran out (or that core has deleted), asks for the same call again (execute_as/5): a new card.

What became of the card is a new notice with its confirmation_uuid, the tool's name and tool_outcome:

  • confirmed — the handler ran; and also for a row core had spent already with no notice of it here (core's used to redeem_as/6 or reject_for_user/2, read back as used with get_for_user/2, or, when that read is withheld because the user may no longer use the tool, with lookup_for_user/2): the click that spent it was lost after core spent it, and core spends the row right before the handler runs. Should the row not read so, the card keeps waiting with the reason;
  • rejected;
  • expired — the row ran out, or is gone: core's invalid (it deletes its rows) reads as expired too;
  • changed — the values changed under the preview: core holds the call in a new row, which is a new card with the same assistant message, stored right after the notice in the same transaction (as is the new card of "Ask again" with the expired notice of a card not yet closed): both are there, or neither;
  • failed — only when the handler failed after core spent the row (an error of redeem_as/6 with the row used).

A notice the chat refuses (it was deleted meanwhile) makes the click {:error, :not_stored}, whatever core did.

A card can also stop waiting with no click to say so: its row ran out, core deleted it, or the click that settled it was lost with its node. settle_lapsed/3 (the chat panel, when it opens a chat) and settle_lapsed_in_turn/2 (a turn, before it reads the history) give such a card its notice by what core's row says — used is confirmed, rejected is rejected, a row that ran out or is gone is expired — so neither the panel nor the model takes it for waiting. A card the user may no longer run (the right to its tool is gone) still waits, for "Cancel the change".

A refusal before the row is spent — the session, the rights, the module's box, the handler's own dry run, the rate limit — writes nothing: the card keeps waiting and the click answers {:error, {:refused, code}} with core's code. Such a notice is the only thing of a click the model learns, in the history of the next turn (PhoenixKitAgent.Turns.History), where a card with no notice yet reads as waiting.

Neither the arguments, nor the preview, nor what the handler returned are stored or logged here.

Summary

Types

What a card shows (see cards/4).

Why a click did nothing.

Functions

What the chat panel shows of the cards among messages (messages of the user's chat conversation_uuid, as PhoenixKitAgent.Conversations hands them out): a map from the uuid of each card to card/0. Only the uuids of messages are used; the rows are read again in this chat, and a message that is not a card of it is left out.

The user runs the write the card message_uuid waits for (see the moduledoc). {:ok, outcome} — :confirmed, :changed, :expired, :rejected or :failed, written as the card's notice — or {:error, reason}, after which the card still waits.

The outcomes a card's notice takes.

The user turns down the write the card message_uuid waits for: {:ok, :rejected} (or :expired when the row ran out or is gone, and :confirmed when core had spent it already: see the moduledoc), written as the card's notice.

The user asks again for the write of the card message_uuid, whose row ran out or is gone: core holds the same call in a new row, a new card with the same assistant message ({:ok, :repeated}). The old card is closed as expired if it was not yet. {:error, :not_expired} for any other card, and for one asked for again already; {:error, :already_waiting} when another card of this chat still waits for the same tool and arguments.

Gives each card of the user's chat conversation_uuid that core no longer holds, though no notice says so, the notice of what became of it (see the moduledoc). Nothing is written, and no lock is asked for, when there is no such card; else under the user's lock, as a click: {:error, :turn_running} or {:error, :click_running}, with nothing written, while a turn or a click holds it.

The cards of the user's chat conversation_uuid that wait — no notice of their confirmation_uuid in the chat — wherever they are in it, oldest first, as PhoenixKitAgent.Conversations hands messages out: what the chat panel shows of the cards that are not on the page of the chat it loaded.

Types

card()

@type card() :: %{
  state: String.t(),
  tool: String.t(),
  preview: map() | nil,
  arguments: String.t() | nil,
  expires_at: DateTime.t() | nil,
  repeatable?: boolean(),
  rejectable?: boolean()
}

What a card shows (see cards/4).

refusal()

@type refusal() ::
  PhoenixKitAgent.Conversations.refusal()
  | :not_waiting
  | :not_expired
  | :already_waiting
  | :turn_running
  | :click_running
  | :no_arguments
  | :not_stored
  | {:refused, atom()}

Why a click did nothing.

Functions

cards(session_ref, impersonated?, conversation_uuid, messages)

@spec cards(PhoenixKitAgent.Conversations.session_ref(), boolean(), term(), [
  %{:uuid => term(), optional(atom()) => term()}
]) ::
  {:ok, %{required(String.t()) => card()}}
  | {:error, PhoenixKitAgent.Conversations.refusal()}

What the chat panel shows of the cards among messages (messages of the user's chat conversation_uuid, as PhoenixKitAgent.Conversations hands them out): a map from the uuid of each card to card/0. Only the uuids of messages are used; the rows are read again in this chat, and a message that is not a card of it is left out.

  • state — "waiting", an outcome of its notice, "expired" for a row that ran out or is gone, or "unavailable" (the session cannot read it, or the row was settled without a notice here);
  • preview, expires_at, arguments — for a waiting card only: core's preview (PhoenixKit.API.Confirm.get_for_user/2, read with this session), when it runs out, and the arguments the model sent, as plain JSON text (to be shown as text, never as markup), cut to 2000 characters;
  • repeatable? — the card ran out, its call is still available, it was not asked for again and no other card waits for the same write;
  • rejectable? — it can be turned down: it waits, or it is "unavailable" because the user may no longer use its tool (core lets such a row be turned down, not run).

confirm(session_ref, impersonated?, conversation_uuid, message_uuid, opts \\ [])

@spec confirm(
  PhoenixKitAgent.Conversations.session_ref(),
  boolean(),
  term(),
  term(),
  keyword()
) ::
  {:ok, :confirmed | :changed | :expired | :rejected | :failed}
  | {:error, refusal()}

The user runs the write the card message_uuid waits for (see the moduledoc). {:ok, outcome} — :confirmed, :changed, :expired, :rejected or :failed, written as the card's notice — or {:error, reason}, after which the card still waits.

Options: :request_context — as for PhoenixKitAgent.Turns.start/6; :request_id is the click's own.

outcomes()

@spec outcomes() :: [String.t()]

The outcomes a card's notice takes.

reject(session_ref, impersonated?, conversation_uuid, message_uuid, opts \\ [])

@spec reject(
  PhoenixKitAgent.Conversations.session_ref(),
  boolean(),
  term(),
  term(),
  keyword()
) ::
  {:ok, :rejected | :expired | :confirmed} | {:error, refusal()}

The user turns down the write the card message_uuid waits for: {:ok, :rejected} (or :expired when the row ran out or is gone, and :confirmed when core had spent it already: see the moduledoc), written as the card's notice.

repeat(session_ref, impersonated?, conversation_uuid, message_uuid, opts \\ [])

@spec repeat(
  PhoenixKitAgent.Conversations.session_ref(),
  boolean(),
  term(),
  term(),
  keyword()
) ::
  {:ok, :repeated} | {:error, refusal()}

The user asks again for the write of the card message_uuid, whose row ran out or is gone: core holds the same call in a new row, a new card with the same assistant message ({:ok, :repeated}). The old card is closed as expired if it was not yet. {:error, :not_expired} for any other card, and for one asked for again already; {:error, :already_waiting} when another card of this chat still waits for the same tool and arguments.

settle_lapsed(session_ref, impersonated?, conversation_uuid)

@spec settle_lapsed(PhoenixKitAgent.Conversations.session_ref(), boolean(), term()) ::
  :ok
  | {:error,
     PhoenixKitAgent.Conversations.refusal() | :turn_running | :click_running}

Gives each card of the user's chat conversation_uuid that core no longer holds, though no notice says so, the notice of what became of it (see the moduledoc). Nothing is written, and no lock is asked for, when there is no such card; else under the user's lock, as a click: {:error, :turn_running} or {:error, :click_running}, with nothing written, while a turn or a click holds it.

waiting_cards(session_ref, impersonated?, conversation_uuid)

The cards of the user's chat conversation_uuid that wait — no notice of their confirmation_uuid in the chat — wherever they are in it, oldest first, as PhoenixKitAgent.Conversations hands messages out: what the chat panel shows of the cards that are not on the page of the chat it loaded.