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

The rounds of one turn (spec §6.1, "раунд r = 1..max_tool_rounds"), run in
the turn's task. The task first stores the user's message (spec §6.1 step
4) and links its attachments, so a turn that never ran leaves nothing in the
chat. Then each round:

  a. checks the session again (`Conversations.authorize_session/2`), that
     the chat is still there, that the AI module is on and that the turn
     still holds its locks (`Locks.held?/3`) — a revoked session, a blocked
     user, a lost `agent` permission, a switched-off assistant or a lock
     taken over end the turn with a notice; a deleted chat ends it quietly;
  b. lists the session's tools (`Executor.visible_for/3`, `allow: :all`: the
     read tools, and the write tools of the modules that let a session
     write, `api_session_write_modules` in core) and shows them to the model
     (`PhoenixKitAgent.Turns.Tools`);
  c. sends the system prompt, the history and this turn's messages — of the
     results of earlier rounds of this turn only as many as fit, together
     with the newest round's, in `10 × agent_tool_result_chars` characters
     (spec §6.4): an older one beyond that is sent as a short stub that says
     it was left out (the database keeps it whole); the pictures of the
     user's message, if it has any, go along with it as image parts (or a
     note, where the model cannot see them or a picture cannot be made) in
     every round of this turn only — the history keeps the text — and such a
     turn is never captured into the AI journal
     (`PhoenixKitAgent.Attachments.model_parts/3`); a first model call with
     pictures that the provider refuses as a request (`api_error` 400, 413,
     415 or 422; not a 404 or a 5xx) takes the message's files off it, to be
     sent again; a model that is not known to take images when the turn
     starts is not called: the files go back, with a `pictures_not_seen`
     notice;
  d. calls the model (`PhoenixKitAI.complete/3`, `source: "agent"`, the user,
     the AI task `agent.chat` with the turn's admission
     (`guard_admission:`, from `PhoenixKitAI.Guard.admit/4` in
     `PhoenixKitAgent.Turns`), no cache, the module's content capture, and
     the uuid the answer will be stored under and the turn's ref as
     `attribution`); a failed call is a notice under its code, all in one
     place (`model_failed/4`) — `phoenix_kit_ai` itself tells its watchers
     of a refusal of its budget;
  e. stores the answer; a marker `⟦off_purpose: <reason>⟧` at its very end
     (the model flagging the request, `PhoenixKitAgent.Prompt`) is cut off
     before it is stored and reported to the guard
     (`PhoenixKitAI.Guard.report_off_purpose/4`, keyed by the turn's ref,
     so one flag per turn); a marker anywhere else is left as it is and
     flags nothing. Without tool calls the turn is over. A tool call
     without a name is dropped (not run, stored or sent back); an answer
     with neither text nor a named tool call is stored as an
     `empty_answer` notice, not as an empty answer;
  f. runs each tool call — only a name shown in **this** round reaches the
     executor (`execute_as/5`, `allow: :all`, `via: "agent"`), any other is
     `denied`, and so is every call past the 10th of the round — and stores
     each result, truncated. Before each call the turn checks that it still
     holds its locks: a lock taken over ends the turn there, with a notice.

## A write

Core never runs a write of a live session on its call: it answers the
preview and holds the call in a confirmation row bound to the user
(`"status": "confirmation_required"`, `"confirmation_id"`). The call is
stored as a `tool` message `needs_confirmation` with that uuid in
`confirmation_uuid` and the answer that asked for it in
`assistant_message_uuid`; its `tool_result` keeps the status and the uuid
only — never a code, the preview or the arguments. The card in the chat
shows it (`PhoenixKitAgent.Confirmations`).

  * At most one write of a round waits: a write after it in the same answer
    is `denied` ("confirm the earlier change first"), and so is a write whose
    call id another call of the answer shares (the click finds its arguments
    by that id). A read after it is `denied` too ("held for confirmation"):
    the turn ends with the round, the model would never read its result.
  * A write of the same tool with the same arguments as a card of the chat
    that still waits is `denied` ("already waiting for confirmation"): no
    second card of one change.
  * A round that left a write waiting ends the turn: the model is not called
    again, so no answer can say that the change was made. The card is the
    end of the turn; what the click did reaches the model as a notice in the
    history of the next turn (`PhoenixKitAgent.Turns.History`).

## Stopping between steps

"Stop" and the deadline do not kill the task at once: the monitor asks it to
stop (`PhoenixKitAgent.Turns.Monitor`), and the task looks for that request
before it stores the user's message, before each round, as soon as the
model has answered and before each tool call — and then ends as
`{:shutdown, :stopped | :deadline}` without storing anything more (a
partial answer is not kept). A model call under way is let finish, so its
row in the AI journal — what the call cost — is written. Only a task that
does not get there within the grace period is killed.

A message the database refuses ends the task as `{:shutdown,
:store_failed}` (the log gets the field names and validation codes only),
and the monitor writes an `interrupted` notice; a chat deleted mid-turn
ends it as `{:shutdown, :chat_gone}`, with nothing written and no further
model call.

Every stored message is broadcast by `PhoenixKitAgent.Conversations`. Ending
the task (stop, deadline, crash) and the locks are
`PhoenixKitAgent.Turns.Monitor`'s business.

## The AI request of a message

An answer of `complete/3` names its journal row (`"request_uuid"`), and
the message is stored with it as `ai_request_uuid`. The row itself
reaches `record_usage/1` (`PhoenixKitAgent.handle_ai_usage/1`, a
`phoenix_kit_ai` usage sink) with the `attribution` the call carried,
synchronously, in the calling process and before `complete/3` returns. So
the turn says which message uuid it waits for, and `record_usage/1` keeps
the row for it in the process: the message gets the row's model, tokens,
cost and latency, and a failed call's notice its row (an error answer
names none). A row the guard refused never reaches a sink: its notice
has no `ai_request_uuid`. A row for an already stored message —
from any other process — sets its `ai_request_uuid` in the database, only
for a message of the row's own user that has none yet.

# `turn`

```elixir
@type turn() :: %{
  turn_ref: String.t(),
  session_ref: PhoenixKit.Users.Auth.SessionRef.t(),
  user_uuid: String.t(),
  conversation_uuid: String.t(),
  endpoint_uuid: String.t(),
  text: String.t(),
  attachment_uuids: [String.t()],
  system_prompt: String.t() | nil,
  history: [map()],
  request_context: keyword(),
  max_rounds: pos_integer(),
  result_chars: pos_integer(),
  capture?: boolean(),
  guard_admission: String.t() | nil
}
```

Everything a turn needs; built by `PhoenixKitAgent.Turns.start/6`.

# `record_usage`

```elixir
@spec record_usage(struct()) :: :ok
```

The usage sink behind `PhoenixKitAgent.handle_ai_usage/1`: links an AI
request row to the message its `attribution` names (see the moduledoc).

# `run`

```elixir
@spec run(turn()) :: :ok
```

Stores the user's message of `turn`, then runs its rounds until the model
answers without tool calls.

---

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