- 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
13 KiB
:client — Ktor-клиент к :server (KMP, jvm + native)
Тонкий HTTP-клиент к :server-фасаду + локальные примитивы, чтобы
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
про HTTP, JSON, SSE и lifecycle Conversation.
Что есть
AgentikAgent(id, baseUrl, httpClient)— entry-point. ВозвращаетAgent(тот же интерфейс, что в:proto).Agent:createConversation/getConversation/getConversations/deleteConversation/journal/outbox.Conversation:send(content, context?)/events(after)(SSEFlow<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).
HttpClient создаётся снаружи (выбор движка — на тебе: CIO, OkHttp,
Darwin). Конфигурация (JSON + Bearer-токен) — через applyAgentikDefaults.
Подключение
// build.gradle.kts
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:client:0.1.0")
// Опционально — только если будешь использовать `InMemoryJournalStore`
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
api("pw.binom.agentik:journal-inmemory:0.1.0")
}
}
Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём HTTP-клиент, открываем диалог, отправляем сообщение, печатаем streaming-ответ.
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
fun main() = runBlocking {
// 1. HTTP-клиент. Движок выбираешь сам (CIO/OkHttp/Darwin).
val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") }
// 2. Agent — обёртка над :server фасадом.
val agent = AgentikAgent(
id = "my-client",
baseUrl = "http://localhost:8080/agentik",
httpClient = http,
)
// 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 строки)
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
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) — главное чтобы
реализовывал интерфейс.
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-блок выше.
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
}
}
Прерывание хода
agent.getConversation(convId)!!.interrupt()
Multi-conversation
Один Agent, много ChatSession:
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 после разрыва,
401/404.
Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без -tt) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE.