PhoenixKitAgent.Turns (PhoenixKitAgent v0.1.0)

Copy Markdown View Source

A turn of the assistant (spec §6.1): the user's message, then rounds of model calls and tool calls until the model answers, or until a write waits for the user's confirmation (PhoenixKitAgent.Confirmations).

Like PhoenixKitAgent.Conversations, every function acts for a live browser session — the {session_token_uuid, user_uuid} reference of PhoenixKit.Users.Auth.SessionRef.from_session/1 — and takes the answer of PhoenixKitWeb.Users.MultiSession.impersonated?/1 for it, a boolean (anything else raises ArgumentError). An impersonated session is refused before anything is read or written.

start/6, in order (the first failure is the answer; a start that fails leaves no lock behind and writes nothing to the chat — except for a limit reached, below):

0-1. the checks of Conversations — impersonation, the session's scope,

   the `agent` permission, the module and the user's switch, the chat is
   the user's;
 - a non-blank message of at most `agent_history_chars` characters, the
   AI module on, an endpoint chosen (the user's own, else
   `agent_default_endpoint_uuid`; `PhoenixKitAgent.UserConfig`), no more
   attachments than `max_attachments_per_message` allows, and each one
   ready, the user's own, of this chat and not sent yet
   (`PhoenixKitAgent.Attachments`);
  1. the locks (PhoenixKitAgent.Turns.Locks): one turn per chat (:conversation_busy) and per user (:turn_in_flight, or :click_in_flight while a click on a confirmation card runs); the history is read only once they are taken, so it holds the previous turn's end;
  2. the limits (PhoenixKitAgent.Limits.check/3), under the locks: the user's previous turn is over, so all it spent is in the AI journal. A limit used up is {:error, {:limit_reached, kind}}: the locks are released, the model is not called, the user's text is not stored, and — the one refusal that writes — a notice goes into the chat (error daily_cost_limit, monthly_cost_limit or daily_turn_limit) and the limit is announced (PhoenixKitAgent.Limits.announce_reached/2); 3a. the AI guard admits the turn (PhoenixKitAI.Guard.admit/4, task agent.chat, for as long as the turn's lock may live): the user's block ({:error, :task_blocked}) and the task's own limits ({:error, :task_limit_exceeded}) are checked here once, and the rounds pass the admission on (guard_admission:), so a turn admitted before a block or a limit runs to its end. A refusal is like a limit reached: nothing runs or is stored but a notice (error task_blocked or task_limit_exceeded), once a day per chat (a monthly limit: once a month); an answer of the guard this module does not know starts no turn either (model_error, a log line without it);
  3. the task starts (PhoenixKitAgent.Turns.Monitor), and {:ok, turn_ref} is returned; there the user message is stored (and the attachments linked to it), then the rounds run (PhoenixKitAgent.Turns.Runner). A turn that started under its limits runs to its end.

The chat's topic (Conversations.topic/1) hears every stored message ({:agent_message_added, conversation_uuid, message_uuid}) and the end of the turn ({:agent_turn_finished, conversation_uuid, turn_ref}), after its locks are released. A listener matches the turn_ref it got from start/6: a start that failed half-way is announced as finished too.

A refusal never carries turn data: an unavailable monitor or a task that did not start is {:monitor_unavailable, class} / {:not_started, class} with only the class of the exit (PhoenixKitAgent.Turns.Monitor.exit_class/0). NUL characters are cut from the text before anything else.

The assistant acts with the session's rights only: tools are listed and run by PhoenixKit.API.Executor.visible_for/3 / execute_as/5 with allow: :all, for this session; there is no service user and no API key. A write is never run by the turn: core holds it for the user's click (PhoenixKitAgent.Turns.Runner, "A write"), and only modules that let a session write (core's api_session_write_modules) show any.

Summary

Types

Why a turn was not started, or not stopped.

Functions

What became of the turn the user's chat is locked by, for a client that opens the chat (decision of 2026-10-20, spec §6.1): {:ok, :idle} — no turn; {:ok, {:running, turn_ref}} — a turn runs (its task is here, or its lock is younger than its TTL: it may run on another node); {:ok, :interrupted} — the turn was lost (its node went down, its monitor with it): its lock is older than the TTL and no task runs it here, so its locks are released, the chat gets the notice "the answer was interrupted" (error interrupted) and its topic hears {:agent_turn_finished, conversation_uuid, turn_ref}. Of two clients that open the chat at once, only one writes the notice. Refusals are those of Conversations.

Starts a turn in the user's chat with text and the attachments attachment_uuids (see the moduledoc). Returns {:ok, turn_ref} as soon as the turn runs; its first act is to store the user message (the chat's topic hears it).

Stops the turn running in the user's chat. The turn is asked to stop and ends at its next step — a model call under way is let finish, so the AI journal records what it cost, but its answer is not kept — or is killed if it has not stopped within the grace period (PhoenixKitAgent.Turns.Monitor). Its locks are then released and the chat's topic hears :agent_turn_finished; :ok means the request was made. With turn_ref, only that turn is stopped — a newer one in the chat keeps running. {:error, :not_running} when no such turn runs there (or the monitor is restarting); someone else's chat is {:error, :not_found}.

Types

refusal()

@type refusal() ::
  PhoenixKitAgent.Conversations.refusal()
  | :empty_message
  | :message_too_long
  | :ai_disabled
  | :no_endpoint
  | :invalid_attachments
  | :too_many_attachments
  | :conversation_busy
  | :turn_in_flight
  | :click_in_flight
  | {:limit_reached, PhoenixKitAgent.Limits.kind()}
  | :task_blocked
  | :task_limit_exceeded
  | :model_error
  | :not_running
  | {:not_started, PhoenixKitAgent.Turns.Monitor.exit_class()}
  | {:monitor_unavailable, PhoenixKitAgent.Turns.Monitor.exit_class()}

Why a turn was not started, or not stopped.

Functions

settle_interrupted(session_ref, impersonated?, conversation_uuid)

@spec settle_interrupted(
  PhoenixKitAgent.Conversations.session_ref(),
  boolean(),
  term()
) ::
  {:ok, :idle | :interrupted | {:running, String.t()}} | {:error, refusal()}

What became of the turn the user's chat is locked by, for a client that opens the chat (decision of 2026-10-20, spec §6.1): {:ok, :idle} — no turn; {:ok, {:running, turn_ref}} — a turn runs (its task is here, or its lock is younger than its TTL: it may run on another node); {:ok, :interrupted} — the turn was lost (its node went down, its monitor with it): its lock is older than the TTL and no task runs it here, so its locks are released, the chat gets the notice "the answer was interrupted" (error interrupted) and its topic hears {:agent_turn_finished, conversation_uuid, turn_ref}. Of two clients that open the chat at once, only one writes the notice. Refusals are those of Conversations.

start(session_ref, impersonated?, conversation_uuid, text, attachment_uuids \\ [], opts \\ [])

@spec start(
  PhoenixKitAgent.Conversations.session_ref(),
  boolean(),
  term(),
  term(),
  [term()],
  keyword()
) ::
  {:ok, String.t()} | {:error, refusal()}

Starts a turn in the user's chat with text and the attachments attachment_uuids (see the moduledoc). Returns {:ok, turn_ref} as soon as the turn runs; its first act is to store the user message (the chat's topic hears it).

Options:

  • :request_context — what the tools' handlers get as context (PhoenixKit.API.Executor.execute_as/5): :language, :base_url, :hosts, :currency, :ip; :request_id defaults to the turn ref;
  • :deadline_ms — a shorter deadline than agent_turn_deadline_s; it can only shorten it;
  • :page — where the user is, for the model's context: the page's path (what the host says of it comes from its page_context hook, called with this session's scope — PhoenixKitAgent.Prompt);
  • :locale — the user's locale; else the :language of the request context, else the process's Gettext locale. Both come from a browser and are checked by PhoenixKitAgent.Prompt.

stop(session_ref, impersonated?, conversation_uuid, turn_ref \\ nil)

@spec stop(
  PhoenixKitAgent.Conversations.session_ref(),
  boolean(),
  term(),
  String.t() | nil
) ::
  :ok | {:error, refusal()}

Stops the turn running in the user's chat. The turn is asked to stop and ends at its next step — a model call under way is let finish, so the AI journal records what it cost, but its answer is not kept — or is killed if it has not stopped within the grace period (PhoenixKitAgent.Turns.Monitor). Its locks are then released and the chat's topic hears :agent_turn_finished; :ok means the request was made. With turn_ref, only that turn is stopped — a newer one in the chat keeps running. {:error, :not_running} when no such turn runs there (or the monitor is restarting); someone else's chat is {:error, :not_found}.

Who may stop is checked here, with the session's scope as it is now; the turn is the one the chat's lock names. When its task is not on this node while the lock is younger than its TTL, the turn may run on another node: the request goes out over PubSub (PhoenixKitAgent.Turns.Monitor.broadcast_stop/1, the turn ref only) and :ok is returned — the node that runs the turn stops it the same way, and the chat's topic hears its end. A lock past its TTL is a turn no node runs: {:error, :not_running}.