PhoenixKitAgent.Turns.Runner (PhoenixKitAgent v0.1.0)

Copy Markdown View Source

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

  1. 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.

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

turn()

@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

record_usage(request)

@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(turn)

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

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