Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.

Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.

outbox-api:
  - Cursor (offset: Long) — курсор в журнале событий агента.
  - OffsetSequencer — интерфейс резервирования уникального offset.
  - CursorStore — персистентное хранилище текущего offset'а.
  - PersistentOffsetSequencer — декоратор над любым OutboxStore,
    обновляет CursorStore на каждом append (atomic transaction).
  - OutboxGapException — клиент запросил after < earliestCursor() →
    сервер не может удовлетворить, клиент обязан делать full resync.
  - DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
    был общим sealed-типом, теперь это термин из спеки).
  - MutableOutboxStore и OutboxStore теперь читают offset через
    CursorStore вместо in-memory counter'а.

outbox-inmemory:
  - InMemoryOffsetSequencer — для тестов и dev-режима.
  - InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.

outbox-ksqlite (новый модуль):
  - KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
    offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
  - KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).

proto + server:
  - Snapshot.proto — server-state snapshot endpoint для клиентов,
    которым нужна полная материализация (использование TBD).
  - Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).

journal-ksqlite:
  - KsqliteJournalStore.listFlow/append — без изменений по API,
    нотации минимальные (codecs).

standalone:
  - DurableLog (бывший ChatAgent-orchestration) — атомарный commit
    события в OutboxStore + PersistentOffsetSequencer + materialization
    (через Reducer) одной транзакцией.
  - SqliteStores — добавляет KsqliteCursorStore в bundle, единая
    shared-connection для всех ksqlite-сторов standalone-агента.
  - ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
    ToolDispatcher — переход на новые абстракции.
  - standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
    включено (раньше было закомментировано — модуль только создавался).

client:
  - AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
    ReconnectingOutbox — используют Cursor через transport API.
  - client/README.md — синхронизирован с новым поведением (468 строк
    diff — это в основном оформление и примеры).

kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).

SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.

Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.

sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
+216 -254
View File
@@ -13,14 +13,19 @@
`deleteConversation` / `journal` / `outbox` / `close`.
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
- `HttpJournalStore` — `list(convId, afterSeq, upToSeq, limit)` /
`count(convId, afterSeq)` → `List<MessageRecord>` со всеми типами записей
(User/Assistant/ToolCall/ToolResult/Error + tokens). Адресация — по `seq`
(см. «Курсорный протокол»), не по датам.
- `HttpEventStore` — `events(after: Cursor?)` / `agentEvents` /
`conversationEvents` (SSE), `currentCursor()` / `oldestCursor()`.
- `Agent.conversationsSnapshot()` / `Agent.chatSnapshot(convId)` — состояние +
`Cursor`, на котором оно валидно. Точка входа resync'а.
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
с основным потоком событий. См. ниже.
потока: `events(after: Cursor?)` (те же `CommonEvent`) и `connectionStatus()`
(`Connecting`/`Connected`/`Disconnected`/`Failed`/`Gap`) — статус НЕ мешается
с основным потоком событий. Мёртвый курсор даёт `Gap` (не ретраится). См. ниже.
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
@@ -65,6 +70,95 @@ dependencies {
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
## Курсорный протокол (как получить гарантированно актуальное состояние)
Всё серьёзное в `:client` крутится вокруг одного понятия — **курсор события**
(`Cursor(epoch, offset)`), аналога Kafka-offset. Он монотонный, сквозной на
все события агента (одна общая нумерация для `AgentEvent` и `Conversation`
-событий) и лежит **над** двумя хранилищами:
- **`OutboxStore`** — короткий bounded-tail live-поток `CommonEvent`
(уведомления/дельты). Хранится ограниченно (cap/TTL), события вытесняются.
- **`journal` + `conversationStore`** — персистентный источник истины
(полное состояние). У каждой записи есть свой `seq` из того же счётчика.
Ключевое свойство: **`CommonEvent.offset` == `MessageRecord.seq` ==
`ConversationRecord.seq`**. Событие с `offset = N` — это ровно «строка состояния
с `seq = N` изменилась (или появилась/удалилась)». События **абсолютные**: в
`UserMessage`/`AssistantMessage` лежит целая запись, `Renamed` несёт новый
заголовок, `Deleted` — «строки больше нет». Поэтому накатывать их на состояние
можно повторно (идемпотентно по `id`) и в любом порядке относительно снапшота.
### Инвариант, на котором стоит гарантия
1. **Писатель** (сервер) сначала пишет строку состояния с `seq = N`, потом
кладёт событие с `offset = N` в outbox. `seq` и `offset` — один счётчик.
2. **Читатель** (клиент) читает **сначала курсор, потом состояние**:
`C = currentCursor()` → `state = listUpTo(C)`. Всё, что `≤ C`, уже в снапшоте;
всё, что `> C`, придёт потоком.
3. **Применение идемпотентно** (upsert/delete/rename по `id`), поэтому
перекрытие снапшота и дельт безвредно.
Ничего не блокируется. Снапшот — это **не** «заморозка таблицы на время
выгрузки»: это baseline на курсоре `C` плюс накат всех дельт `> C`.
### Правильная последовательность синхронизации
```
1. lastSeen = локально сохранённый курсор (или null при первом запуске)
2. попытка: outbox.events(after = lastSeen) ← если сервер ответил
OutboxGapException / ConnectionStatus.Gap → курсор мёртв, иди в п.3
3. ПОЛНЫЙ RESYNC:
a. очистить локальную БД (строки + курсор), пометить «resyncing»
b. C = agent.conversationsSnapshot().cursor (или .chatSnapshot(convId))
c. подписаться events(after = C) и СКОПИРОВАТЬ события в буфер (не применять!)
d. прочитать полное состояние: listUpTo(C) / snapshot.messages
e. применить снапшот целиком
f. применить буфер дельт в порядке offset
4. дальше: применение каждого события из потока (upsert by id)
5. сохранить последний offset как lastSeen
```
Порядок из шага 3 критичен: **сначала подписка, потом снапшот**. Если сделать
наоборот (снапшот, потом подписка) — события, пришедшие в промежуток, потеряются.
Буферизация (а не «применять на лету») закрывает delete-resurrection: событие
`Deleted(offset > C)` для строки, которая ещё лежит в необработанной странице
снапшота, при применении «на лету» было бы стёрто, а потом снапшот вставил бы
строку обратно.
### Курсор мёртв: `OutboxGapException`
Клиент давно не заходил, outbox вытеснил его события (`after.offset <
oldestCursor().offset`), либо сменилась `epoch` (БД сервера откатили/
восстановили/скопировали — счётчик начал считаться заново). Сервер отвечает
`410 Gone`. Клиент **не ретраит** — это сигнал «сделай полный resync»
(шаг 3 выше). С `ReconnectingOutbox` это приходит как
`ConnectionStatus.Gap`, поток закрывается, background-loop встаёт.
**Никогда не ретрай `OutboxGapException`** — ретрай никогда не пройдёт.
### Простой вариант: пересоздать outbox на resync
Если своя реализация шага 3 кажется тяжёлой — минимальный корректный путь
через `ReconnectingOutbox`:
```kotlin
var recon = ReconnectingOutbox(agent.outbox, scope)
scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } }
scope.launch {
recon.connectionStatus().collect { s ->
if (s is ConnectionStatus.Gap) {
recon.close()
val snap = agent.chatSnapshot(convId) // state + cursor
applySnapshot(snap.messages) // upsert by id
lastSeen = snap.cursor
recon = ReconnectingOutbox(agent.outbox, scope)
scope.launch { recon.events(after = lastSeen).collect { applyEvent(it) } }
}
}
}
```
## Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём агента, открываем диалог,
@@ -73,12 +167,11 @@ dependencies {
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.time.Clock
fun main() = runBlocking {
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
@@ -91,17 +184,20 @@ fun main() = runBlocking {
val conv = agent.createConversation(temp = false)
// Подписка «после текущего курсора» — событий строго после этой точки.
val cursor = agent.outbox.currentCursor()
// 2. Два независимых потока событий диалога:
// durable (outbox) — целые события, с курсором после переподключения;
// online (OnlineOutbox) — стриминг ответа, только live (без курсора).
launch {
agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
.collect { ce ->
when (val ev = ce.event) {
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
is Event.Interrupted -> println("[interrupted]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
is DurableEvent.AssistantMessage -> println("[answer ready: ${ev.content}]")
is DurableEvent.Interrupted -> println("[interrupted]")
is DurableEvent.Error -> println("[error: ${ev.message}]")
else -> Unit
}
}
}
@@ -129,12 +225,12 @@ fun main() = runBlocking {
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их
> **Durable vs online.** `DurableEvent` (в `agent.outbox`) — «целые» события, их
> можно перезапросить по курсору `after`. `OnlineEvent` (в
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
> `Event.AssistantMessage` и/или ляжет в journal.
> `DurableEvent.AssistantMessage` и/или ляжет в journal.
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
@@ -143,19 +239,20 @@ fun main() = runBlocking {
```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import kotlin.time.Instant
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
val cache = InMemoryJournalStore()
// Backfill + live-refresh в одном фоне:
// Снапшот на курсоре + подписка ПОСЛЕ него — без потерь (см. «Курсорный протокол»).
val snap = agent.chatSnapshot(conv.id)
cache.appendAll(snap.messages)
launch {
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
.collect { cache.append(it) }
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
.collect { ce -> applyDurable(ce.event, cache) } // upsert by id
}
// История — теперь из кэша, без HTTP:
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
val history = cache.list(conv.id, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE)
history.forEach { rec ->
when (rec) {
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
@@ -167,17 +264,18 @@ history.forEach { rec ->
}
```
Шаблон "remote.listFlow → local.append" работает с любым
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
"без геморроя".
Шаблон «snapshot(курсор) → local.apply → live-дельты после курсора» работает с
любым `MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
«без геморроя» с гарантией актуальности.
### Что вообще не нужно писать самому
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
- HTTP-сериализация `DurableEvent`/`Message` — `agentikHttpClient` регистрирует
`agentikJson` и `InstantSerializer`.
- SSE-парсер — `readSse()` внутри `:client`.
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
`JournalStore.listFlow` сама пагинирует.
- Cursor-менеджмент — сервер ведёт единый монотонный `offset`/`seq`, клиент
лишь хранит `Cursor(epoch, offset)`. Никаких `Instant`-сравнений и
pagination-циклов вручную.
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
@@ -187,7 +285,7 @@ history.forEach { rec ->
### Что нужно написать самому
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
- UI-рендеринг `DurableEvent`'ов — это твоё (Compose/HTML/CLI).
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
@@ -198,7 +296,7 @@ history.forEach { rec ->
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
@@ -212,13 +310,14 @@ val agent = AgentikAgent(
val conv = agent.createConversation(temp = false)
// durable-поток (с курсором): terminal-события хода.
val cursor = agent.outbox.currentCursor()
launch {
agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
agent.outbox.conversationEvents(after = cursor, conversationId = conv.id)
.collect { ce ->
when (ce.event) {
is Event.AssistantMessage -> println("\n--- answer ready ---")
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
else -> Unit
is DurableEvent.AssistantMessage -> println("\n--- answer ready ---")
is DurableEvent.Error -> error("agent error: ${(ce.event as DurableEvent.Error).message}")
else -> Unit
}
}
}
@@ -235,268 +334,122 @@ launch {
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
```
## Локальный кэш истории (правильный паттерн)
## История с локальным кэшем
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
чтение истории — из локального кэша.
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
реализовывал интерфейс.
Клиент держит свой `MutableJournalStore` и наполняет его **снапшотом на
курсоре + дельтами после курсора** (см. «Курсорный протокол»). Чтение истории —
из локального кэша, без HTTP.
```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.Event
import kotlin.time.Instant
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.DurableEvent
// Кэш. Для диска — свой MutableJournalStore (KsqliteJournalStore в :journal-ksqlite).
val cache = InMemoryJournalStore()
class ChatSession(
private val agent: pw.binom.agentik.proto.Agent,
val conversationId: String,
) : AutoCloseable {
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
// (модуль `:journal-api`).
val cache = InMemoryJournalStore()
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
private val scope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() +
kotlinx.coroutines.Dispatchers.Default,
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default,
)
var lastSeen: pw.binom.agentik.outbox.Cursor? = null
init {
// 1. Backfill: забираем всю историю разговора с сервера.
scope.launch {
agent.journal.listFlow(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
).collect { cache.append(it) }
}
// 2. Live: на каждом завершённом ходе (durable AssistantMessage)
// просим у сервера новые записи.
scope.launch {
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
if (ce.event is Event.AssistantMessage) {
val newest = cache.let {
// last-seen курсор — последний createdAt в кэше
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
?: Instant.DISTANT_PAST
}
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
.forEach { cache.append(it) }
// 1. Снапшот: состояние + курсор, на котором оно валидно.
val snap = agent.chatSnapshot(conversationId)
snap.messages.forEach { cache.append(it) }
lastSeen = snap.cursor
// 2. Дельты строго после курсора снапшота.
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conversationId)
.collect { ce ->
applyToCache(ce.event)
lastSeen = ce.cursor
}
}
}
}
private suspend fun applyToCache(e: DurableEvent) {
when (e) {
is DurableEvent.UserMessage -> cache.append(e.toRecord())
is DurableEvent.AssistantMessage -> cache.append(e.toRecord())
is DurableEvent.ToolCall -> cache.append(e.toRecord())
is DurableEvent.ToolResult -> cache.append(e.toRecord())
is DurableEvent.Error -> cache.append(e.toRecord())
is DurableEvent.Interrupted -> Unit
}
}
fun history() = kotlinx.coroutines.runBlocking {
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
cache.list(conversationId, afterSeq = 0L, upToSeq = Long.MAX_VALUE, limit = Int.MAX_VALUE)
}
override fun close() {
scope.cancel()
}
}
// Использование:
val session = ChatSession(agent, conv.id)
// История — из кэша:
session.history().forEach { rec ->
when (rec) {
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
is MessageRecord.Error -> println("error: ${rec.message}")
}
}
// Отправить новое сообщение:
session.scope.launch {
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
override fun close() { scope.cancel() }
}
```
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
`rec is MessageRecord.UserMessage` для реплик пользователя,
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
> `applyToCache` через `cache.append` даёт upsert по `id` (append-only store
> отбрасывает дубликаты `id`), поэтому перекрытие снапшота и дельт безвредно.
> Замените `InMemoryJournalStore` на `KsqliteJournalStore` — код не меняется.
### Когда курсор мёртв
Если `conversationEvents(after = ...)` бросает `OutboxGapException` (или
`ReconnectingOutbox` эмитит `ConnectionStatus.Gap`) — клиент был оффлайн дольше
retention'а. Повторите всю последовательность с шага 1 (снапшот), **предварительно
очистив локальную БД** (`cache.clear(conversationId)`), иначе воскреснут
удалённые строки. Полный алгоритм — в «Курсорный протокол» выше.
## Кэш списка бесед
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
`agent.conversationStore` — read-only projection поверх таблицы `conversation`
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
updatedAt, без `Conversation` handle и без флагов image-support).
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
моментально реагировал на создание/удаление/переименование в другой
вкладке.
Подход — тот же **«remote → local snapshot + live-events»**:
updatedAt). `AgentikAgent` оборачивает его в локальный кэш
(`wrapWithLocalConversationCache`) по тому же протоколу, что и историю:
снапшот на курсоре + live-дельты.
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.outbox.AgentEvent
import io.ktor.client.engine.cio.CIO
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
// remote.listFlow → local.upsert (snapshot)
// outbox.agentEvents → local.upsert / delete (live)
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
val agent = AgentikAgent(id = "agentik", baseUrl = "http://localhost:8080/agentik", engineFactory = CIO)
// Кэш уже наполняется в фоне, читать можно сразу:
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
// Снапшот списка бесед + его курсор (глобальный для агента).
val snap = agent.conversationsSnapshot()
snap.conversations.forEach { println("${it.id} ${it.title ?: "(no title)"} ${it.updatedAt}") }
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
when (ev) {
is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}")
// Дельты после курсора снапшота: Created / Deleted / Renamed / Touched.
agent.outbox.agentEvents(after = snap.cursor).collect { ce ->
when (val ev = ce.event) {
is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} -> ${ev.title}")
is AgentEvent.Touched -> println("~ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}")
}
}
```
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
если нужен SQLite/JSON-store.
## Стриминг live-ответа
Для streaming-рендера текущего хода подписывайся на `events()` и
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
только для UI-feedback во время хода. После `End` хода запись уже
появится в кэше через refresh-блок выше.
**Команды** (создать / переименовать / удалить) идут через `agent`; сервер сам
эмитит соответствующее `AgentEvent` в outbox, клиент применяет его к кэшу:
```kotlin
import pw.binom.agentik.outbox.OnlineEvent
agent.onlineOutbox.onlineEvents(convId).collect { ev ->
when (ev) {
is OnlineEvent.Working -> println("[working]")
is OnlineEvent.StartResponse -> println("[start]")
is OnlineEvent.AppendText -> print(ev.body)
is OnlineEvent.AppendImage -> showImage(ev.body)
is OnlineEvent.End -> println("[end]")
else -> Unit
}
}
val conv = agent.createConversation(temp = false) // POST /conversations -> Created
agent.renameConversation(conv.id, "Новый заголовок") // PATCH /conversations/{id} -> Renamed
agent.deleteConversation(conv.id) // DELETE /conversations/{id} -> Deleted
```
Инструментальные вызовы и целый ответ — durable-поток
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
## Прерывание хода
```kotlin
agent.getConversation(convId)!!.interrupt()
```
## Multi-conversation
Один `Agent`, много `ChatSession`:
```kotlin
val sessions = mutableMapOf<String, ChatSession>()
fun open(convId: String): ChatSession =
sessions.getOrPut(convId) { ChatSession(agent, convId) }
fun close(convId: String) {
sessions.remove(convId)?.close()
}
```
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
## Где `:client` НЕ помогает
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
отдаёт типы и потоки.
- **Персистентность кэша** — `InMemoryJournalStore` и
`InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
`MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
в `:journal-ksqlite` как образец).
- **Нестандартные движковые настройки** — для `requestTimeout`,
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
напрямую.
## Кэш списка бесед
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
в `localStore.upsert(...)`.
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
- `Deleted(id)` → `local.delete(id)`
- `Renamed(id, title)` → `local.rename(id, title)`
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
клиент видит их через SSE и применяет к локальной копии.
**Команды** (создать / переименовать / удалить) идут через `agent`:
```kotlin
// Создать новую беседу:
val conv = agent.createConversation(temp = false) // → POST /conversations
// → server эмитит Created
// → client cache получает Created
// → UI увидит её в списке
// Переименовать:
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
// → server эмитит Renamed
// → client cache обновляет title
// Удалить:
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
// → server эмитит Deleted
// → client cache удаляет запись
```
`conversationStore` доступен **только для чтения**. Это read-only projection
на серверную таблицу `conversation` (id + title + timestamps). Для активной
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
командами `agent.createConversation / deleteConversation / renameConversation`.
**Никогда не пиши в `conversationStore` напрямую.** Для активной работы
(send / interrupt) — handle через `agent.getConversation(id)`.
### Если хочется своего cache-импла
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:journal-ksqlite` как образец.
```kotlin
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ConversationRecord
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
`InMemoryMutableConversationStore` подходит для большинства случаев. Для диска —
свой `MutableConversationStore` (см. `KsqliteMutableConversationStore` в
`:journal-ksqlite`). Методы `rename`/`touch` принимают `seq` из общего счётчика:
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
override suspend fun delete(id: String): Boolean { /* DELETE */ }
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
@@ -511,15 +464,17 @@ class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
./gradlew :client:jvmTest
```
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
reconnect / exhausted attempts → Failed / close → cancel).
Покрывают: JSON-парсинг `DurableEvent`-ов, SSE-стрим, recovery после разрыва,
401/404, reconnect-cycle `ReconnectingOutbox` (5 кейсов: успех / обрыв +
reconnect / exhausted attempts → Failed / мёртвый курсор → Gap (без ретрая) /
close → cancel).
## Auto-reconnect для живого outbox
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
Базовый `OutboxStore.events(after: Cursor?)` — cold SSE-стрим; при обрыве
(мобильная сеть, рестарт сервера) клиент должен сам реконнектиться с курсором
последнего увиденного события. Это повторяется в каждом клиенте, поэтому
`ReconnectingOutbox` берёт это на себя:
```kotlin
val recon = ReconnectingOutbox(
@@ -528,7 +483,8 @@ val recon = ReconnectingOutbox(
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
)
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
// lastSeen — курсор из последнего снапшота / последнего события.
scope.launch { recon.events(after = lastSeen).collect { handle(it) } }
scope.launch {
recon.connectionStatus().collect { status ->
when (status) {
@@ -536,6 +492,7 @@ scope.launch {
is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause)
is Gap -> resync(status.cause) // курсор мёртв — полный resync
}
}
}
@@ -544,6 +501,11 @@ scope.launch {
recon.close() // отменяет background-loop, потоки терминируются
```
`Gap` — единственный статус, который **не** ретраится: курсор старше
retention'а или чужая эпоха. Обработка — полный resync (см. «Курсорный
протокол»). Если не передать `after`, при старте берётся
`outbox.currentCursor()` (live-only семантика).
Два потока **независимы** — `events()` содержит только `CommonEvent`,
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
"мешающего" `Connecting`/`Disconnected` в потоке событий.