Files
agentik/client
subochev fb963bfb6b
ci / JVM build + tests (push) Failing after 1m2s
feat(journal-inmemory): add :journal-inmemory module with InMemoryJournalStore
- 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
2026-09-21 03:45:40 +03:00
..

: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) (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).

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.