feat(journal-inmemory): add :journal-inmemory module with InMemoryJournalStore
ci / JVM build + tests (push) Failing after 1m2s
ci / JVM build + tests (push) Failing after 1m2s
- Introduced a new `:journal-inmemory` module implementing `:journal-api` with an in-memory backend. - Added `InMemoryJournalStore` for concurrent append, list, and clear operations using `Mutex` and `MutableList`. - Use cases include tests, dev mode, embedded scenarios, and client-side in-process caching. - Integrated the module into the project setup and documented usage in `:client/README.md`. - Added comprehensive
This commit is contained in:
+291
-64
@@ -1,101 +1,328 @@
|
||||
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
||||
# `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
|
||||
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
|
||||
про HTTP, JSON, SSE и lifecycle `Conversation`.
|
||||
|
||||
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
||||
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
||||
интерфейсы `:proto`:
|
||||
## Что есть
|
||||
|
||||
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
||||
- `AgentClient` — список и lifecycle диалогов.
|
||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
||||
`getMessages()`, `rename()`, `close()`.
|
||||
- Внутренний парсер SSE → `Flow<Event>`.
|
||||
- `AgentikAgent(id, baseUrl, httpClient)` — entry-point. Возвращает `Agent`
|
||||
(тот же интерфейс, что в `:proto`).
|
||||
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
|
||||
`deleteConversation` / `journal` / `outbox`.
|
||||
- `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).
|
||||
|
||||
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
||||
JVM, iOS, macOS, Linux, Windows.
|
||||
`HttpClient` создаётся снаружи (выбор движка — на тебе: CIO, OkHttp,
|
||||
Darwin). Конфигурация (JSON + Bearer-токен) — через `applyAgentikDefaults`.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:agentik-cli` — REPL.
|
||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
||||
|
||||
## Как подключить
|
||||
## Подключение
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:client:0.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
// ваш код:
|
||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
||||
val conv = agent.createConversation(title = "test")
|
||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
||||
when (event) {
|
||||
is Event.AppendText -> print(event.body)
|
||||
is Event.End -> println("\n--- end ---")
|
||||
is Event.Error -> error("agent error: ${event.message}")
|
||||
else -> Unit
|
||||
// Опционально — только если будешь использовать `InMemoryJournalStore`
|
||||
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
|
||||
api("pw.binom.agentik:journal-inmemory:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Версии
|
||||
## Быстрый старт: свой клиент за 5 минут
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
||||
|
||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
||||
|
||||
## Примеры API
|
||||
Один self-contained пример: создаём HTTP-клиент, открываем диалог,
|
||||
отправляем сообщение, печатаем streaming-ответ.
|
||||
|
||||
```kotlin
|
||||
// список диалогов
|
||||
agent.getConversations().collect { println(it.id to it.title) }
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.client.applyAgentikDefaults
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.time.Clock
|
||||
|
||||
// live-подписка на события отдельного диалога
|
||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
||||
fun main() = runBlocking {
|
||||
// 1. HTTP-клиент. Движок выбираешь сам (CIO/OkHttp/Darwin).
|
||||
val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") }
|
||||
|
||||
// прерывание текущего хода
|
||||
conversation.interrupt()
|
||||
// 2. Agent — обёртка над :server фасадом.
|
||||
val agent = AgentikAgent(
|
||||
id = "my-client",
|
||||
baseUrl = "http://localhost:8080/agentik",
|
||||
httpClient = http,
|
||||
)
|
||||
|
||||
// история
|
||||
conversation.getMessages(offset = 0).collect { msg ->
|
||||
when (msg) {
|
||||
is Message.UserMessage -> println("user: ${msg.content}")
|
||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
||||
else -> Unit
|
||||
// 3. Открыть диалог, отправить сообщение.
|
||||
val conv = agent.createConversation(temp = false)
|
||||
conv.send(listOf(Content.Text("Привет")))
|
||||
|
||||
// 4. Собирать streaming-ответ.
|
||||
conv.events(after = Clock.System.now()).collect { ev ->
|
||||
when (ev) {
|
||||
is Event.StartResponse -> println("[start]")
|
||||
is Event.AppendText -> print(ev.body)
|
||||
is Event.End -> println("[end]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Чистый shutdown.
|
||||
conv.close()
|
||||
http.close()
|
||||
}
|
||||
```
|
||||
|
||||
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
||||
|
||||
### Добавить локальный кэш истории (ещё 4 строки)
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import kotlin.time.Instant
|
||||
|
||||
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
|
||||
val cache = InMemoryJournalStore()
|
||||
|
||||
// Backfill + live-refresh в одном фоне:
|
||||
launch {
|
||||
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
|
||||
.collect { cache.append(it) }
|
||||
}
|
||||
|
||||
// История — теперь из кэша, без HTTP:
|
||||
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||
history.forEach { rec ->
|
||||
when (rec) {
|
||||
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
|
||||
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
|
||||
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
|
||||
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
|
||||
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Шаблон "remote.listFlow → local.append" работает с любым
|
||||
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||
"без геморроя".
|
||||
|
||||
### Что вообще не нужно писать самому
|
||||
|
||||
- HTTP-сериализация `Event`/`Message` — `applyAgentikDefaults` регистрирует
|
||||
`agentikJson` и `InstantSerializer`.
|
||||
- SSE-парсер — `readSse()` внутри `:client`.
|
||||
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
|
||||
`JournalStore.listFlow` сама пагинирует.
|
||||
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
|
||||
- Bearer-токен в каждом запросе — `applyAgentikDefaults(token = ...)` инжектит
|
||||
один раз на весь `HttpClient`.
|
||||
|
||||
### Что нужно написать самому
|
||||
|
||||
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
|
||||
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
|
||||
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
|
||||
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
|
||||
|
||||
|
||||
## Базовый пример: send + collect events
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.client.applyAgentikDefaults
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
|
||||
val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") }
|
||||
val agent = AgentikAgent(
|
||||
id = "agentik",
|
||||
baseUrl = "http://localhost:8080/agentik",
|
||||
httpClient = http,
|
||||
)
|
||||
|
||||
val conv = agent.createConversation(temp = false)
|
||||
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||
|
||||
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
||||
when (ev) {
|
||||
is Event.AppendText -> print(ev.body) // streaming чанки
|
||||
is Event.End -> println("\n--- end ---")
|
||||
is Event.Error -> error("agent error: ${ev.message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## История с локальным кэшем
|
||||
|
||||
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
|
||||
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
|
||||
чтение истории — из локального кэша.
|
||||
|
||||
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
|
||||
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
|
||||
реализовывал интерфейс.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
init {
|
||||
// 1. Backfill: забираем всю историю разговора с сервера.
|
||||
scope.launch {
|
||||
agent.journal.listFlow(
|
||||
conversationId = conversationId,
|
||||
after = Instant.DISTANT_PAST,
|
||||
).collect { cache.append(it) }
|
||||
}
|
||||
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||
scope.launch {
|
||||
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||
if (ev is Event.End) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun history() = kotlinx.coroutines.runBlocking {
|
||||
cache.list(conversationId, Instant.DISTANT_PAST, 0, 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("Привет ещё раз")))
|
||||
}
|
||||
```
|
||||
|
||||
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
|
||||
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
|
||||
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||
|
||||
## Стриминг live-ответа
|
||||
|
||||
Для streaming-рендера текущего хода подписывайся на `events()` и
|
||||
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
|
||||
только для UI-feedback во время хода. После `End` хода запись уже
|
||||
появится в кэше через refresh-блок выше.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.proto.Event
|
||||
|
||||
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||
when (ev) {
|
||||
is Event.StartResponse -> println("[start]")
|
||||
is Event.AppendText -> print(ev.body)
|
||||
is Event.AppendImage -> showImage(ev.body)
|
||||
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
|
||||
is Event.ToolResult -> println("[result]")
|
||||
is Event.End -> println("[end]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Прерывание хода
|
||||
|
||||
```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` хранит в RAM. Для
|
||||
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в
|
||||
`:journal-ksqlite` как образец).
|
||||
- **Авторизация** — `applyAgentikDefaults(token = "...")` для Bearer;
|
||||
для OAuth/что-то ещё — конфигурируй `HttpClient` сам.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :client:jvmTest
|
||||
```
|
||||
|
||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
||||
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||
401/404.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого LLM-кода. Это просто клиент.
|
||||
- Никакого persistent state. История хранится у сервера, клиент её
|
||||
запрашивает через `getMessages` или подписывается через `events`.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
||||
но клиент совместим с любым сервером, который держит wire-контракт
|
||||
`:server`.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
||||
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||
|
||||
Reference in New Issue
Block a user