Изменение выносит типы контента в :content-api и разделяет потоки событий.

This commit is contained in:
2026-09-30 12:41:10 +03:00
parent bd7e079780
commit 059c23e3ab
60 changed files with 613 additions and 400 deletions
+4 -2
View File
@@ -20,8 +20,10 @@ kotlin {
sourceSets {
commonMain.dependencies {
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization).
// :proto не нужен (Event/OnlineEvent/AgentEvent/CommonEvent живут
// здесь). Message-события несут общие типы содержимого из
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
api(project(":content-api"))
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
@@ -2,29 +2,25 @@ package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant
/**
* Элемент live-потока `Conversation.events(after)`.
* Элемент **durable**-потока диалога.
*
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
* как курсор («где остановился») при обрыве/переподключении и для разрешения
* порядка при равных timestamps.
*
* Базовая структура хода:
* `Working` → `End` | `Interrupted` | `Error`, между ними — целые события
* ([ToolCall]/[ToolResult]/[ToolFailed]).
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
* Их можно перезапросить по курсору (`after`).
*
* **Стриминг ответа (дельты текста/картинок и маркеры фаз) — НЕ здесь.**
* Дельты токенов живут в [OnlineEvent] (live-only, не сохраняются).
* [Event] — только «целые» (durable) события, пригодные к перезапросу
* по курсору.
*
* `Working` — маркер «агент принял запрос и пошёл обрабатывать», эмитится
* синхронно в `Conversation.send()` ДО старта LLM-цикла (и возможной долгой
* очереди turnLock'а). Парный терминатор не нужен: `End`/`Interrupted`/`Error`
* уже закрывают ход. UI использует `Working` чтобы показать спиннер ещё до
* первого токена ответа.
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
@@ -39,24 +35,35 @@ sealed interface Event {
val date: Instant
/**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
* [End]/[Interrupted]/[Error], синхронно из `Conversation.send()`,
* чтобы UI мог показать спиннер ещё до первого токена ответа.
* Терминатор хода ([End]/[Interrupted]/[Error]) — парный.
* Целое пользовательское сообщение хода. Эмитится в тот же момент, когда
* запись попадает в журнал (для персистентных диалогов). [id] совпадает
* с id соответствующего `MessageRecord.UserMessage`/`Message.UserMessage`.
*/
@Serializable
@SerialName("working")
data class Working(override val date: Instant) : Event
@SerialName("user_message")
data class UserMessage(
override val date: Instant,
val id: String,
val content: List<Content>,
val context: MessageContext? = null,
) : Event
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
/**
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
* после того как текст ответа полностью собран.
*
* [reasoning] — текст размышлений модели (chain-of-thought), если провайдер
* его отдаёт; иначе `null`. [tokens] — расход токенов за ход.
*/
@Serializable
@SerialName("end")
data class End(override val date: Instant) : Event
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
@SerialName("assistant_message")
data class AssistantMessage(
override val date: Instant,
val id: String,
val content: List<Content>,
val reasoning: String? = null,
val tokens: TurnTokens? = null,
) : Event
/**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
@@ -96,6 +103,14 @@ sealed interface Event {
val result: String?,
) : Event
/**
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
* в истории.
*/
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
/**
* Ошибка хода. После неё поток завершается; дальнейшие события могут
* прийти, но ход считается проваленным.
@@ -5,8 +5,8 @@ import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* **Онлайн-события** диалога: стриминг ответа агента «в моменте» —
* дельты текста/картинок и маркеры фаз хода.
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
* стриминг ответа агента (дельты текста/картинок).
*
* Принципиальное отличие от [Event] (durable):
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
@@ -14,13 +14,13 @@ import kotlin.time.Instant
* - **Только онлайн-подписка**: события, эмитнутые до подписки
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
* Потерянный фрагмент не страшен — целый результат хода приходит
* durable-событием ([Event.End]) и/или лежит в журнале.
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
* есть что реплеить.
*
* Зачем разделять: дельты токенов — высокочастотный мусор, который,
* попав в durable store, копится в RAM (standalone-outbox растёт unbounded)
* и засоряет историю. В [Event] остаются только «целые» события,
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
* который, попав в durable store, копится в RAM (standalone-outbox растёт
* unbounded) и засоряет историю. В [Event] остаются только «целые» события,
* пригодные к перезапросу по курсору.
*/
@Serializable
@@ -34,6 +34,20 @@ sealed interface OnlineEvent {
@SerialName("image") IMAGE
}
/**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
* [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`,
* чтобы UI мог показать спиннер ещё до первого токена ответа.
*/
@Serializable
@SerialName("working")
data class Working(override val date: Instant) : OnlineEvent
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : OnlineEvent
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
*
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
* фрагмента при обрыве не критична — целый ответ приходит [Event.End]
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
*
* Read-only view: запись — через [MutableOnlineOutbox].