# `PhoenixKitAgent.Turns`
[🔗](https://github.com/BeamLabEU/phoenix_kit_agent/blob/v0.1.0/lib/phoenix_kit_agent/turns.ex#L1)

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`);
  2. 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;
  3. 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);
  4. 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 (`t: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.

# `refusal`

```elixir
@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.

# `settle_interrupted`

```elixir
@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`

```elixir
@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`

```elixir
@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}`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
