Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов.
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user