Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов.

This commit is contained in:
2026-09-30 11:43:49 +03:00
parent 509763cf36
commit bd7e079780
25 changed files with 491 additions and 102 deletions
@@ -12,8 +12,14 @@ import kotlin.time.Instant
* порядка при равных timestamps.
*
* Базовая структура хода:
* `Working` → `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
* `Working` → `End` | `Interrupted` | `Error`, между ними — целые события
* ([ToolCall]/[ToolResult]/[ToolFailed]).
*
* **Стриминг ответа (дельты текста/картинок и маркеры фаз) — НЕ здесь.**
* Дельты токенов живут в [OnlineEvent] (live-only, не сохраняются).
* [Event] — только «целые» (durable) события, пригодные к перезапросу
* по курсору.
*
* `Working` — маркер «агент принял запрос и пошёл обрабатывать», эмитится
* синхронно в `Conversation.send()` ДО старта LLM-цикла (и возможной долгой
* очереди turnLock'а). Парный терминатор не нужен: `End`/`Interrupted`/`Error`
@@ -32,15 +38,9 @@ sealed interface Event {
/** Момент эмиссии события в UTC. */
val date: Instant
@Serializable
enum class ResponseType {
@SerialName("text") TEXT,
@SerialName("image") IMAGE
}
/**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
* [StartReasoning]/[StartResponse], синхронно из `Conversation.send()`,
* [End]/[Interrupted]/[Error], синхронно из `Conversation.send()`,
* чтобы UI мог показать спиннер ещё до первого токена ответа.
* Терминатор хода ([End]/[Interrupted]/[Error]) — парный.
*/
@@ -48,16 +48,6 @@ sealed interface Event {
@SerialName("working")
data class Working(override val date: Instant) : Event
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : Event
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
@Serializable
@SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
@Serializable
@SerialName("end")
@@ -68,14 +58,6 @@ sealed interface Event {
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : Event
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
/**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
* [id] совпадает с id соответствующего `Message.ToolCall` в истории
@@ -0,0 +1,26 @@
package pw.binom.agentik.outbox
/**
* Write-сторона [OnlineOutbox]: используется продюсерами
* ([pw.binom.agentik.standalone.agent.ConversationLoop] и т.п.) для эмиссии
* live-дельт. Наружу (в [pw.binom.agentik.proto.Agent]) отдаётся
* read-only [OnlineOutbox].
*
* **Non-suspend и best-effort**: онлайн-события по определению нигде не
* персистятся, I/O нет — блокировать продюсера незачем. [tryAppendOnline]
* не буферизует и не ждёт (см. [OnlineOutbox]): медленный подписчик может
* потерять дельту, это допустимо.
*/
interface MutableOnlineOutbox : OnlineOutbox {
suspend fun appendOnline(conversationId: String, event: OnlineEvent)
/**
* Эмитит [event] в live-канал диалога [conversationId]. Не сохраняется.
*
* Возвращает `true`, если событие принято live-каналом. Возврат `false`
* (нет активных подписчиков / буфер переполнен с DROP-политикой) —
* не ошибка: у онлайн-событий нет гарантии доставки.
*/
fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean
}
@@ -0,0 +1,56 @@
package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* **Онлайн-события** диалога: стриминг ответа агента «в моменте» —
* дельты текста/картинок и маркеры фаз хода.
*
* Принципиальное отличие от [Event] (durable):
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
* ни в journal. Это чистый live-канал.
* - **Только онлайн-подписка**: события, эмитнутые до подписки
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
* Потерянный фрагмент не страшен — целый результат хода приходит
* durable-событием ([Event.End]) и/или лежит в журнале.
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
* есть что реплеить.
*
* Зачем разделять: дельты токенов — высокочастотный мусор, который,
* попав в durable store, копится в RAM (standalone-outbox растёт unbounded)
* и засоряет историю. В [Event] остаются только «целые» события,
* пригодные к перезапросу по курсору.
*/
@Serializable
sealed interface OnlineEvent {
/** Момент эмиссии события в UTC (для упорядочивания в рамках стрима). */
val date: Instant
@Serializable
enum class ResponseType {
@SerialName("text") TEXT,
@SerialName("image") IMAGE
}
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : OnlineEvent
/** Начало ответа ассистента заданного типа. Далее идут соответствующие `Append*`. */
@Serializable
@SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : OnlineEvent
/** Очередная дельта текста ответа. */
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : OnlineEvent
/** Очередная дельта картинки ответа. */
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : OnlineEvent
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.outbox
import kotlinx.coroutines.flow.Flow
/**
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
* агента «в моменте».
*
* Контракт принципиально проще [OutboxStore]:
* - **без catchup**: `onlineEvents` отдаёт только то, что эмитится *после*
* подписки. Прошлое не реплеится — его и нет (нечего хранить).
* - **без курсора**: нет `after`/`lastSeen`.
* - **без TTL/buffer**: [OnlineEvent] не буферизуются.
*
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
* фрагмента при обрыве не критична — целый ответ приходит [Event.End]
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
*
* Read-only view: запись — через [MutableOnlineOutbox].
*/
interface OnlineOutbox : AutoCloseable {
fun onlineEvents(): Flow<OnlineEvent>
/**
* Подписка на live-поток онлайн-событий диалога [conversationId].
* События, эмитнутые до подписки, не приходят.
*/
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
/** Освобождает ресурсы. Idempotent. */
override fun close()
}
@@ -8,6 +8,11 @@ import kotlin.time.Instant
/**
* Bounded-tail event log с автоматическим управлением TTL.
*
* Хранит **только durable-события [Event]** — «целые» факты хода
* (Working/End/Interrupted/Error, ToolCall/ToolResult/ToolFailed).
* Высокочастотный **стриминг ответа** (дельты текста/картинок) сюда
* НЕ попадает — он живёт в [OnlineOutbox] (live-only, не сохраняется).
*
* **Архитектура двухуровневого хранилища событий**:
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
* События автоматически эвиктятся по TTL/cap (implementation-defined).