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
- 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 isdeniedtoo ("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.
Summary
Types
Everything a turn needs; built by PhoenixKitAgent.Turns.start/6.
Functions
The usage sink behind PhoenixKitAgent.handle_ai_usage/1: links an AI
request row to the message its attribution names (see the moduledoc).
Stores the user's message of turn, then runs its rounds until the model
answers without tool calls.
Types
@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.
Functions
@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).
@spec run(turn()) :: :ok
Stores the user's message of turn, then runs its rounds until the model
answers without tool calls.