Изменение выносит типы контента в :content-api и разделяет потоки событий.

This commit is contained in:
2026-09-30 12:41:10 +03:00
parent bd7e079780
commit 059c23e3ab
60 changed files with 613 additions and 400 deletions
@@ -5,7 +5,7 @@ import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import kotlin.time.Instant import kotlin.time.Instant
@@ -12,7 +12,7 @@ import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import kotlin.time.Instant import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") { class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
@@ -5,7 +5,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
@@ -8,11 +8,11 @@ import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -3,7 +3,7 @@ package pw.binom.agentik.tui
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
+2
View File
@@ -40,6 +40,8 @@ val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").t
// beforeEvaluate — поздно). // beforeEvaluate — поздно).
val moduleDescriptions: Map<String, String> = mapOf( val moduleDescriptions: Map<String, String> = mapOf(
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.", "proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
"content-api" to "agentik :content-api — общие типы содержимого сообщения (Content/MessageContext/MessageOrigin/TurnTokens) для :proto, :journal-api, :outbox-api.",
"outbox-api" to "agentik :outbox-api — durable (Event) и live-only (OnlineEvent) потоки событий диалога + OutboxStore/OnlineOutbox.",
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.", "skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.", "server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.", "client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
+81 -37
View File
@@ -72,9 +72,11 @@ dependencies {
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.time.Clock import kotlin.time.Clock
@@ -87,21 +89,37 @@ fun main() = runBlocking {
token = "s3cret", // или null, если не нужен token = "s3cret", // или null, если не нужен
) )
// 2. Открыть диалог, отправить сообщение.
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет")))
// 3. Собирать streaming-ответ. // 2. Два независимых потока событий диалога:
conv.events(after = Clock.System.now()).collect { ev -> // durable (outbox) — целые события, с курсором после переподключения;
when (ev) { // online (OnlineOutbox) — стриминг ответа, только live (без курсора).
is Event.StartResponse -> println("[start]") launch {
is Event.AppendText -> print(ev.body) agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
is Event.End -> println("[end]") .collect { ce ->
is Event.Error -> println("[error: ${ev.message}]") when (val ev = ce.event) {
else -> Unit is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
is Event.Interrupted -> println("[interrupted]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
}
}
}
launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
when (ev) {
is OnlineEvent.Working -> println("[working]")
is OnlineEvent.AppendText -> print(ev.body)
is OnlineEvent.StartResponse -> println("[start]")
is OnlineEvent.End -> println("\n[end]")
else -> Unit
}
} }
} }
// 3. Отправить ход (fire-and-forget — ответ придёт по подпискам выше).
conv.send(listOf(Content.Text("Привет")))
// 4. Чистый shutdown. // 4. Чистый shutdown.
conv.close() conv.close()
agent.close() agent.close()
@@ -109,7 +127,14 @@ fun main() = runBlocking {
``` ```
**Это весь клиент.** `:server` сам хранит историю, контекст, события. **Это весь клиент.** `:server` сам хранит историю, контекст, события.
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь. Ты только получаешь два типизированных `Flow` и рендеришь как хочешь.
> **Durable vs online.** `Event` (в `agent.outbox`) — «целые» события, их
> можно перезапросить по курсору `after`. `OnlineEvent` (в
> `agent.onlineOutbox`) — поток стриминга (`Working`/`End`/`AppendText`/
> `AppendImage`), **никогда не сохраняется** и не реплеится: потерянный при
> обрыве фрагмент невосстановим, но целый ответ всегда придёт durable-
> `Event.AssistantMessage` и/или ляжет в journal.
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто `HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
внутри `AgentikAgent`. Один вызов — один готовый `Agent`. внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
@@ -172,9 +197,11 @@ history.forEach { rec ->
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.launch
val agent = AgentikAgent( val agent = AgentikAgent(
id = "agentik", id = "agentik",
@@ -183,16 +210,30 @@ val agent = AgentikAgent(
) )
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
conv.events(after = kotlin.time.Clock.System.now()).collect { ev -> // durable-поток (с курсором): terminal-события хода.
when (ev) { launch {
is Event.AppendText -> print(ev.body) // streaming чанки agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
is Event.End -> println("\n--- end ---") .collect { ce ->
is Event.Error -> error("agent error: ${ev.message}") when (ce.event) {
else -> Unit is Event.AssistantMessage -> println("\n--- answer ready ---")
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
else -> Unit
}
}
}
// online-поток (live-only): стриминг ответа.
launch {
agent.onlineOutbox.onlineEvents(conv.id).collect { ev ->
when (ev) {
is OnlineEvent.AppendText -> print(ev.body) // streaming чанки
is OnlineEvent.End -> println("\n--- end ---")
else -> Unit
}
} }
} }
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
``` ```
## История с локальным кэшем ## История с локальным кэшем
@@ -207,8 +248,8 @@ conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
```kotlin ```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.time.Instant import kotlin.time.Instant
class ChatSession( class ChatSession(
@@ -235,10 +276,11 @@ class ChatSession(
after = Instant.DISTANT_PAST, after = Instant.DISTANT_PAST,
).collect { cache.append(it) } ).collect { cache.append(it) }
} }
// 2. Live: на каждом `End` хода просим у сервера новые записи. // 2. Live: на каждом завершённом ходе (durable AssistantMessage)
// просим у сервера новые записи.
scope.launch { scope.launch {
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev -> agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
if (ev is Event.End) { if (ce.event is Event.AssistantMessage) {
val newest = cache.let { val newest = cache.let {
// last-seen курсор — последний createdAt в кэше // last-seen курсор — последний createdAt в кэше
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
@@ -342,22 +384,24 @@ escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступе
появится в кэше через refresh-блок выше. появится в кэше через refresh-блок выше.
```kotlin ```kotlin
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.OnlineEvent
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev -> agent.onlineOutbox.onlineEvents(convId).collect { ev ->
when (ev) { when (ev) {
is Event.StartResponse -> println("[start]") is OnlineEvent.Working -> println("[working]")
is Event.AppendText -> print(ev.body) is OnlineEvent.StartResponse -> println("[start]")
is Event.AppendImage -> showImage(ev.body) is OnlineEvent.AppendText -> print(ev.body)
is Event.ToolCall -> println("[tool: ${ev.toolName}]") is OnlineEvent.AppendImage -> showImage(ev.body)
is Event.ToolResult -> println("[result]") is OnlineEvent.End -> println("[end]")
is Event.End -> println("[end]") else -> Unit
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
} }
} }
``` ```
Инструментальные вызовы и целый ответ — durable-поток
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
## Прерывание хода ## Прерывание хода
```kotlin ```kotlin
@@ -10,10 +10,10 @@ import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -13,11 +13,16 @@ import pw.binom.agentik.outbox.OnlineOutbox
* HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]), * HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]),
* ходящая в `:server`-фасад. * ходящая в `:server`-фасад.
* *
* **Endpoint**: [onlineEvents] → `GET {baseUrl}/conversations/{id}/online` * **Endpoints**:
* (live-only SSE, без `after`). Сервер не реплеит — подписка получает * - [onlineEvents] (без аргумента) → `GET {baseUrl}/online` (live-only SSE,
* только то, что эмитится после подключения. Reconnect-логика здесь не * все диалоги агента);
* нужна: при обрыве поток просто закрывается, а потерянные дельты * - [onlineEvents] с `conversationId` → `GET {baseUrl}/conversations/{id}/online`
* восстанавливаются из durable-истории ([HttpEventStore] + журнал). * (live-only SSE, один диалог).
*
* Сервер не реплеит — подписка получает только то, что эмитится после
* подключения. Reconnect-логика здесь не нужна: при обрыве поток просто
* закрывается, а потерянные дельты восстанавливаются из durable-истории
* ([HttpEventStore] + журнал).
*/ */
internal class HttpOnlineOutbox( internal class HttpOnlineOutbox(
private val httpClient: HttpClient, private val httpClient: HttpClient,
@@ -26,8 +31,12 @@ internal class HttpOnlineOutbox(
private val agentUrl: String = baseUrl.trimEnd('/') private val agentUrl: String = baseUrl.trimEnd('/')
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = flow { override fun onlineEvents(): Flow<OnlineEvent> = stream("$agentUrl/online")
val url = "$agentUrl/conversations/$conversationId/online"
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
stream("$agentUrl/conversations/$conversationId/online")
private fun stream(url: String): Flow<OnlineEvent> = flow {
httpClient.prepareGet(url) { noReadTimeout() } httpClient.prepareGet(url) { noReadTimeout() }
.execute { response -> .execute { response ->
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
@@ -66,7 +66,7 @@ private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation( CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs), date = Instant.fromEpochMilliseconds(dateMs),
conversationId = "test", conversationId = "test",
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)), event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
+33
View File
@@ -0,0 +1,33 @@
# `:content-api` — общие типы содержимого сообщения
Низкоуровневый KMP-модуль с типами, которые используются во всех слоях
agentik и раньше дублировались:
- `Content` — часть содержимого сообщения: `Content.Text(body)`,
`Content.Image(data, mime)`.
- `MessageContext` — контекст инициации хода (`origin`, `description`,
`sourceId`, `metadata`).
- `MessageOrigin` — `USER` / `SYSTEM` / `EVENT`.
- `TurnTokens` — token usage одного assistant turn'а (`input`, `output`).
## Зачем отдельный модуль
`:proto` (wire-контракт), `:journal-api` (слой хранения) и `:outbox-api`
(события) должны ссылаться на **один и тот же** `Content`/`MessageContext`,
а не держать по собственной копии. Общий модуль убирает дубли и циклы:
```
:content-api ◄── :proto
◄── :journal-api
◄── :outbox-api
```
`:proto`/`:journal-api`/`:outbox-api` объявляют `api(project(":content-api"))`,
поэтому потребители (`:client`, `:server`, `:standalone`, ...) видят типы
транзитивно, но должны импортировать их напрямую из `pw.binom.agentik.content`.
## Публикация
Каталог `gradle/libs.versions.toml` → `agentik-content-api`.
`./gradlew :content-api:publish -Pversion=...` публикует все KMP-таргеты
(jvm + натив).
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
// для JsonElement в MessageContext.metadata
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,31 @@
package pw.binom.agentik.content
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть содержимого сообщения (пользовательского или агентского).
*
* Единый тип для всего проекта: используется и в wire-контракте ([pw.binom.agentik.proto]),
* и в слое хранения ([pw.binom.agentik.journal]), и в durable-событиях
* ([pw.binom.agentik.outbox.Event]). Вынесен в отдельный модуль `:content-api`,
* чтобы не дублировать его в каждом слое и не заводить циклов в графе.
*/
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
data class Text(val body: String) : Content
/**
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
*/
@Serializable
@SerialName("image")
data class Image(val data: ByteArray, val mime: String) : Content {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
@@ -0,0 +1,52 @@
package pw.binom.agentik.content
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
*
* Примеры:
* ```
* // cron-задача утренней сводки
* MessageContext(
* origin = MessageOrigin.EVENT,
* description = "scheduled cron 'morning-briefing'",
* sourceId = "cron-42",
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
* )
*
* // обычное сообщение из IRC
* MessageContext(
* origin = MessageOrigin.USER,
* sourceId = "irc-channel:agentik",
* description = "PRIVMSG from nick",
* )
* ```
*
* Семантический контракт:
* - origin != USER ⇒ [description] обязателен и должен быть человекочитаемым.
* - origin == USER ⇒ context может быть `null` (дефолт).
*
* Снапшот-стабильность wire-формата: поля сериализуются по именам, snake_case
* на enum'е [MessageOrigin] даёт `user`/`system`/`event`. Новые поля —
* non-breaking для старых клиентов.
*/
@Serializable
data class MessageContext(
val origin: MessageOrigin,
/**
* Короткая человекочитаемая фраза для LLM: попадает в working memory
* как префикс `[origin] description (sourceId=…)` к user-сообщению.
*/
val description: String? = null,
/**
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC.
*/
val sourceId: String? = null,
/**
* Произвольный структурированный payload о событии. Никогда не попадает
* в LLM-нагрузку как сырой JSON — только логирование и пост-аналитика.
*/
val metadata: JsonElement? = null,
)
@@ -0,0 +1,19 @@
package pw.binom.agentik.content
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Кто/что инициировал ход (кто/что и почему).
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.journal package pw.binom.agentik.content
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -11,6 +11,7 @@ data class TurnTokens(
val output: Int, val output: Int,
) { ) {
val total: Int get() = input + output val total: Int get() = input + output
init { init {
require(input >= 0) { "input tokens must be non-negative, got $input" } require(input >= 0) { "input tokens must be non-negative, got $input" }
require(output >= 0) { "output tokens must be non-negative, got $output" } require(output >= 0) { "output tokens must be non-negative, got $output" }
@@ -1,4 +1,4 @@
package pw.binom.agentik.proto package pw.binom.agentik.content
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -2,8 +2,8 @@ package pw.binom.agentik.context
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.MessageContext import pw.binom.agentik.content.MessageContext
/** /**
* Запись в working memory диалога: ровно то, что агент сейчас видит в * Запись в working memory диалога: ровно то, что агент сейчас видит в
@@ -2,7 +2,7 @@ package pw.binom.agentik.context.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
+19 -12
View File
@@ -47,28 +47,35 @@ agentik
## 3. `:proto` — интерфейсы ## 3. `:proto` — интерфейсы
`Agent` (см. `proto/src/commonMain/.../Agent.kt`): `Agent` (см. `proto/src/commonMain/.../Agent.kt`):
- `id: String` - `id: String`, `info: AgentInfo`
- read-only сторы: `journal: JournalStore`, `outbox: OutboxStore`,
`onlineOutbox: OnlineOutbox`, `conversationStore: ConversationStore`
- `createConversation(temp: Boolean): Conversation` - `createConversation(temp: Boolean): Conversation`
- `suspend getConversation(id): Conversation?` - `suspend getConversation(id): Conversation?`
- `suspend getConversations(offset, limit): List<Conversation>` - `suspend deleteConversation(id): Boolean`
- `getConversations(offset = 0): Flow<Conversation>` — cold-flow paging через suspend-версию, `PAGE_SIZE = 100`. - `suspend renameConversation(id, title): Instant?`
- `events(after: Instant): Flow<AgentEvent>` — replay-free, бэкфилл через snapshot.
- `deleteConversation(id): Boolean`
`Conversation`: `Conversation`:
- `isSupportImageInput / Output / isTemporal: Boolean` - `isSupportImageInput / Output / isTemporal: Boolean`, `title: String?`
- `updatedAt: Instant` - `updatedAt: Instant`
- `send(content: List<Content>)` — write-only, ничего не возвращает. - `send(content: List<Content>, context: MessageContext? = null)` — write-only, ничего не возвращает.
- `interrupt()` — отмена активного хода. - `interrupt()` — отмена активного хода.
- `events(after): Flow<Event>` — live, replay-free. - `getMessages(after, offset, limit): List<Message>` — paging.
- `getMessages(after, offset, limit)` + `getMessages(after): Flow<Message>` — paging.
- `rename(title)` — мутация, бампит `updatedAt`. - `rename(title)` — мутация, бампит `updatedAt`.
- `AutoCloseable` — `close()` идемпотентен. - `AutoCloseable` — `close()` идемпотентен.
`Content = Text(body) | Image(data, mime)`. `Content = Text(body) | Image(data, mime)` — из `:content-api` (там же `MessageContext`/`MessageOrigin`/`TurnTokens`).
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`. `Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
`Event = StartReasoning | StartResponse | End | AppendText | AppendImage | ToolCall | ToolResult | Error`.
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title)`. События разделены на два потока (оба в `:outbox-api`):
- **durable** `Event` (`outbox.conversationEvents(after, id)`): `UserMessage |
AssistantMessage | ToolCall | ToolResult | ToolFailed | Interrupted | Error |
ConversationClosing | CompactionTriggered` — перезапрашиваются по курсору;
- **online** `OnlineEvent` (`onlineOutbox.onlineEvents(id)`): `Working | End |
StartReasoning | StartResponse | AppendText | AppendImage` — live-only,
никогда не сохраняются.
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title) | Touched(...)`.
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
лишь рендерит Event-stream и кэширует историю. лишь рендерит Event-stream и кэширует историю.
+6 -5
View File
@@ -63,9 +63,9 @@
│ 3. ensureLiteConversation: │ │ 3. ensureLiteConversation: │
│ first turn → create from WM; │ │ first turn → create from WM; │
│ next turns → reuse (KV-cache) │ │ next turns → reuse (KV-cache) │
│ 4. sendStreamContents → emit │ │ 4. sendStreamContents → online: │
│ StartResponse / AppendText / │ │ StartResponse/AppendText/End; │
│ End │ │ durable: AssistantMessage │
│ 5. audit + WM: append AssistantMessage│ │ 5. audit + WM: append AssistantMessage│
└──────────────────────────────────────┘ └──────────────────────────────────────┘
│ │ │ │
@@ -151,7 +151,8 @@ fun main() {
| `updatedAt: Instant` | последний `send`/`rename` | | `updatedAt: Instant` | последний `send`/`rename` |
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти | | `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
| `interrupt()` | остановить текущий ход (best-effort) | | `interrupt()` | остановить текущий ход (best-effort) |
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) | | `outbox.conversationEvents(after, id): Flow<Event>` | durable-события (UserMessage, AssistantMessage, ToolCall/Result, Interrupted, Error) — скурсором |
| `onlineOutbox.onlineEvents(id): Flow<OnlineEvent>` | live-only стриминг (Working, End, StartReasoning, StartResponse, AppendText, AppendImage) |
| `getMessages(after, offset, limit)` | страница истории | | `getMessages(after, offset, limit)` | страница истории |
| `rename(title)` | переименовать | | `rename(title)` | переименовать |
| `close()` | освободить ресурсы | | `close()` | освободить ресурсы |
@@ -173,7 +174,7 @@ fun main() {
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`. Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов). **Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит durable `Event.Error` + онлайн `OnlineEvent.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
### `JournalStore` ### `JournalStore`
+1
View File
@@ -17,6 +17,7 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(project(":content-api"))
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
@@ -1,24 +0,0 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
* в `Mapping.kt` storage impl'ов.
*/
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
data class Text(val body: String) : Content
@Serializable
@SerialName("image")
data class Image(val data: ByteArray, val mime: String) : Content {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
@@ -1,12 +0,0 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class MessageContext(
val origin: MessageOrigin,
val sourceId: String? = null,
val description: String? = null,
val metadata: JsonElement? = null,
)
@@ -1,20 +0,0 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@@ -2,6 +2,9 @@ package pw.binom.agentik.journal
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -36,6 +39,12 @@ sealed interface MessageRecord {
override val content: List<Content>, override val content: List<Content>,
override val createdAt: Instant, override val createdAt: Instant,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
/**
* Текст размышлений модели (chain-of-thought / reasoning), если провайдер
* его отдаёт. Опционально: null, если размышлений не было или провайдер
* их не раскрывает.
*/
val reasoning: String? = null,
) : Body ) : Body
@Serializable @Serializable
@@ -4,6 +4,9 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
private val bodyJson = Json { private val bodyJson = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -17,15 +20,17 @@ data class MessageBodyPayload(
@SerialName("context") @SerialName("context")
val context: MessageContext? = null, val context: MessageContext? = null,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
val reasoning: String? = null,
) )
fun encodeBodyPayload( fun encodeBodyPayload(
content: List<Content>, content: List<Content>,
context: MessageContext? = null, context: MessageContext? = null,
tokens: TurnTokens? = null, tokens: TurnTokens? = null,
reasoning: String? = null,
): String = bodyJson.encodeToString( ): String = bodyJson.encodeToString(
MessageBodyPayload.serializer(), MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context, tokens = tokens), MessageBodyPayload(content = content, context = context, tokens = tokens, reasoning = reasoning),
) )
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json) fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
@@ -34,14 +39,15 @@ data class BodyDecoded(
val content: List<Content>, val content: List<Content>,
val context: MessageContext?, val context: MessageContext?,
val tokens: TurnTokens? = null, val tokens: TurnTokens? = null,
val reasoning: String? = null,
) )
private fun readPayload(json: String): BodyDecoded { private fun readPayload(json: String): BodyDecoded {
return try { return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json) val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context, p.tokens) BodyDecoded(p.content, p.context, p.tokens, p.reasoning)
} catch (e: kotlinx.serialization.SerializationException) { } catch (e: kotlinx.serialization.SerializationException) {
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json) val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null, null) BodyDecoded(arr, null, null, null)
} }
} }
@@ -14,7 +14,7 @@ class InMemoryJournalStoreTest {
MessageRecord.UserMessage( MessageRecord.UserMessage(
id = id, id = id,
conversationId = convId, conversationId = convId,
content = listOf(pw.binom.agentik.journal.Content.Text(text)), content = listOf(pw.binom.agentik.content.Content.Text(text)),
createdAt = at, createdAt = at,
) )
@@ -21,6 +21,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload( is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
content = record.content, content = record.content,
tokens = record.tokens, tokens = record.tokens,
reasoning = record.reasoning,
) )
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString( is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(), CallPayload.serializer(),
@@ -49,7 +50,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
} }
"assistant" -> { "assistant" -> {
val d = decodeBodyPayload(payload) val d = decodeBodyPayload(payload)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens) MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
} }
"tool_call" -> { "tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload) val p = Json.decodeFromString(CallPayload.serializer(), payload)
@@ -2,9 +2,9 @@ package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.TurnTokens import pw.binom.agentik.content.TurnTokens
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -1,7 +1,7 @@
package pw.binom.agentik.journal.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
+4 -2
View File
@@ -20,8 +20,10 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены // :proto не нужен (Event/OnlineEvent/AgentEvent/CommonEvent живут
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization). // здесь). Message-события несут общие типы содержимого из
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
api(project(":content-api"))
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
@@ -2,29 +2,25 @@ package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Элемент live-потока `Conversation.events(after)`. * Элемент **durable**-потока диалога.
* *
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом * Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* для трекинга «где остановился» при обрыве/переподключении и для разрешения * как курсор («где остановился») при обрыве/переподключении и для разрешения
* порядка при равных timestamps. * порядка при равных timestamps.
* *
* Базовая структура хода: * Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
* `Working` → `End` | `Interrupted` | `Error`, между ними — целые события * вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
* ([ToolCall]/[ToolResult]/[ToolFailed]). * ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
* Их можно перезапросить по курсору (`after`).
* *
* **Стриминг ответа (дельты текста/картинок и маркеры фаз) — НЕ здесь.** * **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
* Дельты токенов живут в [OnlineEvent] (live-only, не сохраняются). * и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
* [Event] — только «целые» (durable) события, пригодные к перезапросу
* по курсору.
*
* `Working` — маркер «агент принял запрос и пошёл обрабатывать», эмитится
* синхронно в `Conversation.send()` ДО старта LLM-цикла (и возможной долгой
* очереди turnLock'а). Парный терминатор не нужен: `End`/`Interrupted`/`Error`
* уже закрывают ход. UI использует `Working` чтобы показать спиннер ещё до
* первого токена ответа.
* *
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`; * **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для * при миграции в `:outbox-api` был оставлен typealias в `:proto` для
@@ -39,24 +35,35 @@ sealed interface Event {
val date: Instant val date: Instant
/** /**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до** * Целое пользовательское сообщение хода. Эмитится в тот же момент, когда
* [End]/[Interrupted]/[Error], синхронно из `Conversation.send()`, * запись попадает в журнал (для персистентных диалогов). [id] совпадает
* чтобы UI мог показать спиннер ещё до первого токена ответа. * с id соответствующего `MessageRecord.UserMessage`/`Message.UserMessage`.
* Терминатор хода ([End]/[Interrupted]/[Error]) — парный.
*/ */
@Serializable @Serializable
@SerialName("working") @SerialName("user_message")
data class Working(override val date: Instant) : Event data class UserMessage(
override val date: Instant,
val id: String,
val content: List<Content>,
val context: MessageContext? = null,
) : Event
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */ /**
* Целое сообщение ассистента — итог хода. Эмитится при завершении хода,
* после того как текст ответа полностью собран.
*
* [reasoning] — текст размышлений модели (chain-of-thought), если провайдер
* его отдаёт; иначе `null`. [tokens] — расход токенов за ход.
*/
@Serializable @Serializable
@SerialName("end") @SerialName("assistant_message")
data class End(override val date: Instant) : Event data class AssistantMessage(
override val date: Instant,
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */ val id: String,
@Serializable val content: List<Content>,
@SerialName("interrupted") val reasoning: String? = null,
data class Interrupted(override val date: Instant) : Event val tokens: TurnTokens? = null,
) : Event
/** /**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет. * Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
@@ -96,6 +103,14 @@ sealed interface Event {
val result: String?, val result: String?,
) : Event ) : Event
/**
* Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется
* в истории.
*/
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
/** /**
* Ошибка хода. После неё поток завершается; дальнейшие события могут * Ошибка хода. После неё поток завершается; дальнейшие события могут
* прийти, но ход считается проваленным. * прийти, но ход считается проваленным.
@@ -5,8 +5,8 @@ import kotlinx.serialization.Serializable
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* **Онлайн-события** диалога: стриминг ответа агента «в моменте» — * **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
* дельты текста/картинок и маркеры фаз хода. * стриминг ответа агента (дельты текста/картинок).
* *
* Принципиальное отличие от [Event] (durable): * Принципиальное отличие от [Event] (durable):
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox], * - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
@@ -14,13 +14,13 @@ import kotlin.time.Instant
* - **Только онлайн-подписка**: события, эмитнутые до подписки * - **Только онлайн-подписка**: события, эмитнутые до подписки
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются. * (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
* Потерянный фрагмент не страшен — целый результат хода приходит * Потерянный фрагмент не страшен — целый результат хода приходит
* durable-событием ([Event.End]) и/или лежит в журнале. * durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где * - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
* есть что реплеить. * есть что реплеить.
* *
* Зачем разделять: дельты токенов — высокочастотный мусор, который, * Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
* попав в durable store, копится в RAM (standalone-outbox растёт unbounded) * который, попав в durable store, копится в RAM (standalone-outbox растёт
* и засоряет историю. В [Event] остаются только «целые» события, * unbounded) и засоряет историю. В [Event] остаются только «целые» события,
* пригодные к перезапросу по курсору. * пригодные к перезапросу по курсору.
*/ */
@Serializable @Serializable
@@ -34,6 +34,20 @@ sealed interface OnlineEvent {
@SerialName("image") IMAGE @SerialName("image") IMAGE
} }
/**
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
* [End]/[Event.Interrupted]/[Event.Error], синхронно из `Conversation.send()`,
* чтобы UI мог показать спиннер ещё до первого токена ответа.
*/
@Serializable
@SerialName("working")
data class Working(override val date: Instant) : OnlineEvent
/** Ход завершён (нормально либо оборван). Зеркало терминатора — см. [Event]. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : OnlineEvent
/** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */ /** Ассистент начал рассуждение (опциональный маркер; контент идёт через [AppendText]). */
@Serializable @Serializable
@SerialName("start_reasoning") @SerialName("start_reasoning")
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
* *
* Это осознанный компромисс: дельты токенов — высокочастотный мусор, * Это осознанный компромисс: дельты токенов — высокочастотный мусор,
* который в durable-сторе копился бы в RAM и засорял историю. Потеря * который в durable-сторе копился бы в RAM и засорял историю. Потеря
* фрагмента при обрыве не критична — целый ответ приходит [Event.End] * фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
* и/или лежит в [pw.binom.agentik.journal.JournalStore]. * и/или лежит в [pw.binom.agentik.journal.JournalStore].
* *
* Read-only view: запись — через [MutableOnlineOutbox]. * Read-only view: запись — через [MutableOnlineOutbox].
@@ -60,6 +60,10 @@ class InMemoryOnlineOutbox(
} }
private companion object { private companion object {
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 1 // Достаточно, чтобы не терять подряд идущие маркеры фаз
// (Working/StartReasoning/StartResponse) и первые дельты, пока
// подписчик не возобновил сбор. Дельты токенов при переполнении
// всё равно дропаются (DROP_OLDEST) — потеря фрагмента допустима.
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 64
} }
} }
@@ -164,7 +164,7 @@ class InMemoryOutboxStoreTest {
store.append(CommonEvent.Conversation( store.append(CommonEvent.Conversation(
date = now, date = now,
conversationId = "c-1", conversationId = "c-1",
event = Event.Working(date = now), event = Event.Interrupted(date = now),
)) ))
// Snapshot-based test of the default impl (uses events() + filterIsInstance). // Snapshot-based test of the default impl (uses events() + filterIsInstance).
@@ -180,9 +180,9 @@ class InMemoryOutboxStoreTest {
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking { fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null) val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0) val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now))) store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
store.append(CommonEvent.Conversation(now, "c-2", Event.Working(now))) store.append(CommonEvent.Conversation(now, "c-2", Event.Interrupted(now)))
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now))) store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
// Test the filter logic by manually filtering snapshot. // Test the filter logic by manually filtering snapshot.
val c1 = store.snapshot() val c1 = store.snapshot()
@@ -200,7 +200,7 @@ class InMemoryOutboxStoreTest {
date = now, date = now,
event = AgentEvent.Created(date = now, conversationId = "created"), event = AgentEvent.Created(date = now, conversationId = "created"),
)) ))
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now))) store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
val all = store.snapshot() val all = store.snapshot()
val agents = all.filterIsInstance<CommonEvent.Agent>() val agents = all.filterIsInstance<CommonEvent.Agent>()
+56 -26
View File
@@ -59,57 +59,87 @@ target-specific артефакты + общий `kotlinMultiplatform`.
## Основные типы ## Основные типы
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
а общие типы содержимого и события живут в нижележащих модулях:
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
```kotlin ```kotlin
interface Agent { interface Agent : AutoCloseable {
fun id: String val id: String
suspend fun createConversation(title: String? = null): Conversation val info: AgentInfo
val journal: JournalStore // append-only audit (read-only)
val outbox: OutboxStore // durable-события: catchup+live по курсору
val onlineOutbox: OnlineOutbox // live-only: стриминг, без курсора
val conversationStore: ConversationStore
fun createConversation(temp: Boolean): Conversation
suspend fun getConversation(id: String): Conversation? suspend fun getConversation(id: String): Conversation?
suspend fun getConversations(offset: Int = 0): Flow<Conversation> suspend fun deleteConversation(id: String): Boolean
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed suspend fun renameConversation(id: String, title: String?): Instant?
} }
interface Conversation : AutoCloseable { interface Conversation : AutoCloseable {
val id: String val id: String
val updatedAt: Instant val updatedAt: Instant
val isTemporal: Boolean
val title: String?
val isSupportImageInput: Boolean val isSupportImageInput: Boolean
val isSupportImageOutput: Boolean val isSupportImageOutput: Boolean
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше suspend fun send(content: List<Content>, context: MessageContext? = null) // fire-and-forget
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
suspend fun getMessages(offset: Int = 0): Flow<Message> suspend fun rename(title: String)
suspend fun rename(title: String): Boolean suspend fun interrupt()
fun interrupt()
} }
// :content-api
sealed interface Content { sealed interface Content {
class Text(val body: String) : Content data class Text(val body: String) : Content
class Image(val data: ByteArray, val mime: String) : Content data class Image(val data: ByteArray, val mime: String) : Content
} }
enum class MessageOrigin { USER, SYSTEM, EVENT }
data class MessageContext(origin: MessageOrigin, description: String?, sourceId: String?, metadata: JsonElement?)
// :proto
sealed interface Message { sealed interface Message {
val id: String val id: String
val date: Instant val date: Instant
interface Body : Message { val content: List<Content> } class UserMessage(id, content: List<Content>, date, context: MessageContext?) : Message
interface System : Message class AssistantMessage(id, content: List<Content>, date, tokens: TurnTokens?, reasoning: String?) : Message
class UserMessage(...) : Body class ToolCall(...) : Message
class AssistantMessage(...) : Body class ToolResult(...) : Message
class ToolCall(...) : System class Error(...) : Message
class ToolResult(...) : System
} }
// :outbox-api — durable (перезапрашиваются по курсору `after`)
sealed interface Event { sealed interface Event {
enum ResponseType { TEXT, IMAGE } val date: Instant
class StartReasoning(...) : Event class UserMessage(date, id, content: List<Content>, context: MessageContext?) : Event
class StartResponse(val type: ResponseType) : Event class AssistantMessage(date, id, content: List<Content>, reasoning: String?, tokens: TurnTokens?) : Event
class AppendText(val body: String) : Event
class AppendImage(val body: ByteArray, val mime: String) : Event
class End(...) : Event
class Interrupted(...) : Event
class Error(val message: String, val code: Int? = null) : Event
class ToolCall(...) : Event class ToolCall(...) : Event
class ToolResult(...) : Event class ToolResult(...) : Event
class ToolFailed(...) : Event
class Interrupted(date) : Event
class Error(date, message, code) : Event
class ConversationClosing(...) : Event
class CompactionTriggered(...) : Event
}
// :outbox-api — online (live-only, НИКОГДА не сохраняются)
sealed interface OnlineEvent {
val date: Instant
enum ResponseType { TEXT, IMAGE }
class Working(date) : OnlineEvent
class End(date) : OnlineEvent
class StartReasoning(date) : OnlineEvent
class StartResponse(date, responseType: ResponseType) : OnlineEvent
class AppendText(date, body: String) : OnlineEvent
class AppendImage(date, body: ByteArray, mime: String) : OnlineEvent
} }
``` ```
Ход в терминах маркеров: онлайн `Working` → … → онлайн `End`; durable —
`UserMessage` в начале и `AssistantMessage`/`Interrupted`/`Error` в конце.
## Чего здесь НЕТ ## Чего здесь НЕТ
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server` - Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
+6 -5
View File
@@ -28,11 +28,12 @@ kotlin {
// public-сигнатуре Agent, поэтому api-висимости. // public-сигнатуре Agent, поэтому api-висимости.
// //
// Линейный граф зависимостей (без циклов): // Линейный граф зависимостей (без циклов):
// :proto ──► :outbox-api (нет обратной зависимости) // :proto ──► :content-api (Content/MessageContext/MessageOrigin/TurnTokens)
// :proto ──► :journal-api (нет обратной зависимости) // :proto ──► :outbox-api (нет обратной зависимости)
// Добились переносом AgentEvent/CommonEvent/Event из :proto в // :proto ──► :journal-api (нет обратной зависимости)
// :outbox-api — они теперь self-contained в outbox (не нужны // Общие типы содержимого живут в низкоуровневом :content-api,
// :proto-типы), а :proto использует их через :outbox-api. // чтобы ими пользовались proto/journal/outbox без дублей и циклов.
api(project(":content-api"))
api(project(":journal-api")) api(project(":journal-api"))
api(project(":outbox-api")) api(project(":outbox-api"))
} }
@@ -1,18 +0,0 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
class Text(val body: String) : Content
/**
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
*/
@Serializable
@SerialName("image")
class Image(val data: ByteArray, val mime: String) : Content
}
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -11,18 +13,18 @@ import kotlin.time.Instant
* *
* **Live-события** диалога НЕ часть этого интерфейса. Их два независимых * **Live-события** диалога НЕ часть этого интерфейса. Их два независимых
* потока: * потока:
* - **durable** ([pw.binom.agentik.outbox.Event]: Working / End / Interrupted / * - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage /
* Error / ToolCall / ToolResult / ToolFailed) — из * Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из
* [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору: * [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору:
* ``` * ```
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id) * agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
* .map { it.event } * .map { it.event }
* .collect { e -> ... } * .collect { e -> ... }
* ``` * ```
* - **online** ([pw.binom.agentik.outbox.OnlineEvent]: StartReasoning / * - **online** ([pw.binom.agentik.outbox.OnlineEvent]: Working / End /
* StartResponse / AppendText / AppendImage) — live-only стриминг ответа * StartReasoning / StartResponse / AppendText / AppendImage) — live-only
* из [pw.binom.agentik.outbox.OnlineOutbox], без catchup/курсора: * стриминг ответа из [pw.binom.agentik.outbox.OnlineOutbox], без
* `agent.onlineOutbox.onlineEvents(id)`. * catchup/курсора: `agent.onlineOutbox.onlineEvents(id)`.
* *
* Для cross-conversation view (admin / parent-agent / debug): * Для cross-conversation view (admin / parent-agent / debug):
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed): * `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
@@ -2,6 +2,9 @@ package pw.binom.agentik.proto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.content.Content
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.content.TurnTokens
import kotlin.time.Instant import kotlin.time.Instant
@Serializable @Serializable
@@ -33,7 +36,17 @@ sealed interface Message {
@Serializable @Serializable
@SerialName("assistant_message") @SerialName("assistant_message")
class AssistantMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message class AssistantMessage(
override val id: String,
val content: List<Content>,
override val date: Instant,
val tokens: TurnTokens? = null,
/**
* Текст размышлений модели (chain-of-thought / reasoning), если
* провайдер его отдаёт. Опционально.
*/
val reasoning: String? = null,
) : Message
@Serializable @Serializable
@SerialName("tool_call") @SerialName("tool_call")
@@ -1,94 +0,0 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Кто/что инициировал данный ход сообщения.
*
* Используется в [MessageContext] — каждый ход диалога может нести
* дополнительный контекст о природе триггера:
* - [USER] — обычное сообщение от пользователя в чате (дефолт, context=null).
* - [SYSTEM] — программное системное сообщение (старт агента, режим обслуживания,
* уведомление о завершении фоновой задачи).
* - [EVENT] — внешнее событие (cron, webhook, file-changed, и т.п.).
* В этом случае [MessageContext.sourceId] и [MessageContext.description]
* позволяют модели понять, что за источник её разбудил.
*
* Семантический контракт:
* - origin != USER ⇒ [MessageContext.description] обязателен и должен быть
* человекочитаемым (короткая фраза для модели).
* - origin == USER ⇒ context может быть `null` (дефолт), и если задан — поля
* интерпретируются как «дополнительная мета» (например, ui_client).
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
/**
* Контекст инициации сообщения: кто/что и почему вызвало этот ход.
*
* Примеры:
* ```
* // cron-задача утренней сводки
* MessageContext(
* origin = MessageOrigin.EVENT,
* description = "scheduled cron 'morning-briefing'",
* sourceId = "cron-42",
* metadata = buildJsonObject { put("scheduledAt", "2026-09-14T08:00:00Z") },
* )
*
* // обычное сообщение из IRC
* MessageContext(
* origin = MessageOrigin.USER,
* sourceId = "irc-channel:agentik",
* description = "PRIVMSG from nick",
* )
*
* // старт агента после рестарта
* MessageContext(
* origin = MessageOrigin.SYSTEM,
* description = "agent startup greeting",
* )
* ```
*
* Сериализация: snake_case для стабильного wire-формата ([origin] идёт как
* `user`/`system`/`event` благодаря @SerialName на enum).
*
* Forward-совместимо: добавление новых полей — non-breaking для старых
* клиентов, которые их игнорируют.
*/
@Serializable
data class MessageContext(
val origin: MessageOrigin,
/**
* Короткая человекочитаемая фраза для LLM: попадает в working memory
* как префикс `[origin] description (sourceId=…)` к user-сообщению,
* чтобы модель видела, что её разбудил не пользователь, а событие.
*/
val description: String? = null,
/**
* Идентификатор источника: id cron-job'а, webhook endpoint'а, имя канала IRC,
* id фонового события. Помогает модели и оператору при логировании понять,
* откуда пришёл ход.
*/
val sourceId: String? = null,
/**
* Произвольный структурированный payload о событии.
* Например: `{"scheduledAt": "...", "rule": "..."}` для cron,
* или `{"headers": {...}, "ip": "..."}` для webhook.
*
* Никогда не попадает в LLM-нагрузку как сырой JSON — используется
* только для логирования и пост-аналитики.
*/
val metadata: JsonElement? = null,
)
@@ -1,9 +1,9 @@
package pw.binom.agentik.server package pw.binom.agentik.server
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -21,7 +21,7 @@ import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
@@ -189,6 +189,15 @@ internal fun Route.agentikRoutes(agent: Agent) {
call.streamJsonSse(agent.onlineOutbox.onlineEvents(id), OnlineEvent.serializer()) call.streamJsonSse(agent.onlineOutbox.onlineEvents(id), OnlineEvent.serializer())
} }
/**
* `GET /online` — **live-only** SSE поток онлайн-событий всех диалогов
* агента (аналог `/events/all` для durable). Без `after`: онлайн-события
* не хранятся и не реплеятся.
*/
get("/online") {
call.streamJsonSse(agent.onlineOutbox.onlineEvents(), OnlineEvent.serializer())
}
get("/events") { get("/events") {
val after = call.parseAfter() ?: return@get val after = call.parseAfter() ?: return@get
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>; // agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
@@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.Content as JContent import pw.binom.agentik.content.Content as JContent
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
@@ -9,6 +9,7 @@ import pw.binom.agentik.outbox.OnlineOutbox
* онлайн-канал тестам не нужен, но интерфейс обязывает его отдать. * онлайн-канал тестам не нужен, но интерфейс обязывает его отдать.
*/ */
internal fun emptyOnlineOutbox(): OnlineOutbox = object : OnlineOutbox { internal fun emptyOnlineOutbox(): OnlineOutbox = object : OnlineOutbox {
override fun onlineEvents() = emptyFlow<OnlineEvent>()
override fun onlineEvents(conversationId: String) = emptyFlow<OnlineEvent>() override fun onlineEvents(conversationId: String) = emptyFlow<OnlineEvent>()
override fun close() {} override fun close() {}
} }
+6
View File
@@ -79,6 +79,12 @@ include(":reflection-api")
include(":journal-api") include(":journal-api")
include(":context-api") include(":context-api")
include(":outbox-api") include(":outbox-api")
// Общие типы содержимого сообщения (Content/MessageContext/MessageOrigin/
// TurnTokens). Вынесены в отдельный низкоуровневый модуль, чтобы ими
// пользовались И :proto, И :journal-api, И :outbox-api (message-события
// несут List<Content> + MessageContext) без дублирования типов и циклов
// в графе зависимостей.
include(":content-api")
// Bounded-tail event log с auto-TTL. Двухуровневое хранилище: этот модуль — // Bounded-tail event log с auto-TTL. Двухуровневое хранилище: этот модуль —
// короткий live tail + recent replay; полный audit log живёт в :message-store-api // короткий live tail + recent replay; полный audit log живёт в :message-store-api
// (там — MessageStore + ConversationStore). EventStore сам управляет eviction, // (там — MessageStore + ConversationStore). EventStore сам управляет eviction,
@@ -13,7 +13,7 @@ import pw.binom.a2a.server.AgentHandler
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -31,7 +31,7 @@ private val log = KotlinLogging.logger {}
* Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток * Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток
* ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only, * ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only,
* без catchup — события начала хода иначе можно упустить), завершение хода ждём * без catchup — события начала хода иначе можно упустить), завершение хода ждём
* по durable-событиям [Event.End] / [Event.Interrupted] / [Event.Error]. * по онлайн [OnlineEvent.End] и durable [Event.AssistantMessage] / [Event.Interrupted] / [Event.Error].
* *
* Ограничение v1: tool-события и картинки в A2A-ответ не транслируются; * Ограничение v1: tool-события и картинки в A2A-ответ не транслируются;
* при нескольких ходов в очереди за контекстом текст предыдущего хода * при нескольких ходов в очереди за контекстом текст предыдущего хода
@@ -53,14 +53,20 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
// Онлайн-поток — дельты ответа (live-only, без catchup). // Онлайн-поток — дельты ответа (live-only, без catchup).
val onlineJob = async { val onlineJob = async {
agent.onlineOutbox.onlineEvents(conv.id).collect { e -> agent.onlineOutbox.onlineEvents(conv.id).collect { e ->
if (e is OnlineEvent.AppendText) reply.append(e.body) when (e) {
is OnlineEvent.AppendText -> reply.append(e.body)
is OnlineEvent.End -> turnDone.complete(Unit)
else -> {}
}
} }
} }
// Durable-поток — терминатор хода (catchup + live). // Durable-поток — терминатор хода (catchup + live): целый ответ
// приходит [Event.AssistantMessage], обрыв — [Event.Interrupted],
// провал — [Event.Error].
val turnJob = async { val turnJob = async {
agent.outbox.conversationEvents(since, conv.id).collect { ce -> agent.outbox.conversationEvents(since, conv.id).collect { ce ->
when (val e = ce.event) { when (val e = ce.event) {
is Event.End, is Event.Interrupted -> turnDone.complete(Unit) is Event.AssistantMessage, is Event.Interrupted -> turnDone.complete(Unit)
is Event.Error -> is Event.Error ->
turnDone.completeExceptionally( turnDone.completeExceptionally(
IllegalStateException("agent turn failed: ${e.message}") IllegalStateException("agent turn failed: ${e.message}")
@@ -137,5 +137,5 @@ internal suspend fun recentTurns(workingMemoryStore: ContextStore, conversationI
} }
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */ /** Текстовое содержимое записей working memory (Text-контент, без картинок). */
internal fun List<pw.binom.agentik.journal.Content>.text(): String = internal fun List<pw.binom.agentik.content.Content>.text(): String =
filterIsInstance<pw.binom.agentik.journal.Content.Text>().joinToString("\n") { it.body } filterIsInstance<pw.binom.agentik.content.Content.Text>().joinToString("\n") { it.body }
@@ -380,10 +380,10 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
for (entry in filtered.takeLast(limit * 2)) { for (entry in filtered.takeLast(limit * 2)) {
val text = when (entry) { val text = when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User -> is pw.binom.agentik.context.WorkingMemoryEntry.User ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>() entry.content.filterIsInstance<pw.binom.agentik.content.Content.Text>()
.joinToString("\n") { it.body } .joinToString("\n") { it.body }
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>() entry.content.filterIsInstance<pw.binom.agentik.content.Content.Text>()
.joinToString("\n") { it.body } .joinToString("\n") { it.body }
else -> continue else -> continue
} }
@@ -6,7 +6,7 @@ import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.standalone.agent.memory.materializeReviewNote import pw.binom.agentik.standalone.agent.memory.materializeReviewNote
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.WorkingMemoryRow import pw.binom.agentik.context.WorkingMemoryRow
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
@@ -3,8 +3,8 @@ package pw.binom.agentik.standalone.agent
import mu.KotlinLogging import mu.KotlinLogging
import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import pw.binom.agentik.journal.MessageContext import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.journal.MessageOrigin import pw.binom.agentik.content.MessageOrigin
internal class ContextBuilder( internal class ContextBuilder(
private val memoryPrefetcher: MemoryPrefetcher?, private val memoryPrefetcher: MemoryPrefetcher?,
@@ -19,22 +19,22 @@ import mu.KotlinLogging
import pw.binom.agentik.memory.MemoryPrefetcher import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.proto.Content as ProtoContent import pw.binom.agentik.content.Content as ProtoContent
import pw.binom.agentik.proto.Conversation as ProtoConversation import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.outbox.Event as ProtoEvent import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.MutableOnlineOutbox import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Message as ProtoMessage import pw.binom.agentik.proto.Message as ProtoMessage
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext import pw.binom.agentik.content.MessageContext as ProtoMessageContext
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.MessageContext import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.journal.MessageOrigin import pw.binom.agentik.content.MessageOrigin
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
import pw.binom.agentik.journal.TurnTokens import pw.binom.agentik.content.TurnTokens
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
@@ -179,11 +179,11 @@ class ConversationLoop(
check(!state.isClosed) { "Conversation closed: $id" } check(!state.isClosed) { "Conversation closed: $id" }
val turnStarted = now() val turnStarted = now()
// Working-маркер — самый первый event хода. Эмитим синхронно через // Working-маркер — самый первый event хода. Эмитим синхронно через
// tryEmit (events.tryEmit → outbox.append, без сетевого I/O), чтобы // tryEmit (events.tryEmitOnline → без сетевого I/O), чтобы клиент
// клиент увидел «агент работает» ещё до turnLock.withLock { launch } // увидел «агент работает» ещё до turnLock.withLock { launch } и до
// и до первого токена от LLM. Терминатор — End/Interrupted/Error // первого токена от LLM. Working/End — онлайн-маркеры (live-only),
// (см. KDoc Event.Working). // терминатор durable-части — AssistantMessage/Interrupted/Error.
emitEvent(pw.binom.agentik.outbox.Event.Working(date = turnStarted)) emitOnline(OnlineEvent.Working(date = turnStarted))
val userMessageId = newId("msg") val userMessageId = newId("msg")
val storageContext = context?.toStorage() val storageContext = context?.toStorage()
@@ -206,6 +206,16 @@ class ConversationLoop(
), ),
now = turnStarted, now = turnStarted,
) )
// Durable-событие user-сообщения: позволяет восстановить историю
// по курсору outbox без отдельного запроса в journal.
emitEvent(
ProtoEvent.UserMessage(
date = turnStarted,
id = userMessageId,
content = userRecord.content,
context = storageContext,
)
)
} }
turnLock.withLock { turnLock.withLock {
@@ -448,6 +458,17 @@ class ConversationLoop(
) )
messageStore.append(assistantRecord) messageStore.append(assistantRecord)
// Durable-событие готового ответа агента.
emitEvent(
ProtoEvent.AssistantMessage(
date = assistantAt,
id = assistantId,
content = assistantContent,
reasoning = null,
tokens = turnTokens,
)
)
workingMemoryStore.append( workingMemoryStore.append(
conversationId = id, conversationId = id,
entry = WorkingMemoryEntry.Assistant( entry = WorkingMemoryEntry.Assistant(
@@ -489,7 +510,7 @@ class ConversationLoop(
if (wasInterrupted || interrupted.get()) { if (wasInterrupted || interrupted.get()) {
emitEvent(ProtoEvent.Interrupted(date = now())) emitEvent(ProtoEvent.Interrupted(date = now()))
} }
emitEvent(ProtoEvent.End(date = now())) emitOnline(OnlineEvent.End(date = now()))
interrupted.set(false) interrupted.set(false)
} }
@@ -541,9 +562,9 @@ internal fun ProtoContent.toStorage(): Content = when (this) {
internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext( internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
origin = when (origin) { origin = when (origin) {
pw.binom.agentik.proto.MessageOrigin.USER -> MessageOrigin.USER pw.binom.agentik.content.MessageOrigin.USER -> MessageOrigin.USER
pw.binom.agentik.proto.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM pw.binom.agentik.content.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM
pw.binom.agentik.proto.MessageOrigin.EVENT -> MessageOrigin.EVENT pw.binom.agentik.content.MessageOrigin.EVENT -> MessageOrigin.EVENT
}, },
description = description, description = description,
sourceId = sourceId, sourceId = sourceId,
@@ -552,9 +573,9 @@ internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
internal fun MessageContext.toProto(): ProtoMessageContext { internal fun MessageContext.toProto(): ProtoMessageContext {
val protoOrigin = when (origin) { val protoOrigin = when (origin) {
MessageOrigin.USER -> pw.binom.agentik.proto.MessageOrigin.USER MessageOrigin.USER -> pw.binom.agentik.content.MessageOrigin.USER
MessageOrigin.SYSTEM -> pw.binom.agentik.proto.MessageOrigin.SYSTEM MessageOrigin.SYSTEM -> pw.binom.agentik.content.MessageOrigin.SYSTEM
MessageOrigin.EVENT -> pw.binom.agentik.proto.MessageOrigin.EVENT MessageOrigin.EVENT -> pw.binom.agentik.content.MessageOrigin.EVENT
} }
return ProtoMessageContext( return ProtoMessageContext(
origin = protoOrigin, origin = protoOrigin,
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mu.KotlinLogging import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.reflection.ReflectionStore import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.ContextStore import pw.binom.agentik.context.ContextStore
@@ -9,7 +9,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.outbox.Event as ProtoEvent import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.skill.mining.SkillReadTool import pw.binom.agentik.skill.mining.SkillReadTool
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
@@ -186,7 +186,7 @@ class ChatAgentTest {
pw.binom.agentik.journal.MessageRecord.UserMessage( pw.binom.agentik.journal.MessageRecord.UserMessage(
id = "m1", id = "m1",
conversationId = id, conversationId = id,
content = listOf(pw.binom.agentik.journal.Content.Text("hi")), content = listOf(pw.binom.agentik.content.Content.Text("hi")),
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000), createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
), ),
) )
@@ -214,10 +214,10 @@ class ChatAgentTest {
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
assertEquals(2, msgs.size) assertEquals(2, msgs.size)
assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let { assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let {
(it[0] as pw.binom.agentik.journal.Content.Text).body (it[0] as pw.binom.agentik.content.Content.Text).body
}) })
assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let { assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let {
(it[0] as pw.binom.agentik.journal.Content.Text).body (it[0] as pw.binom.agentik.content.Content.Text).body
}) })
} }
@@ -341,12 +341,18 @@ class ChatAgentTest {
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) } agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
} }
val online = mutableListOf<OnlineEvent>()
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
}
conv.send(listOf(Content.Text("hi"))) conv.send(listOf(Content.Text("hi")))
delay(50) delay(50)
job.cancel() job.cancel()
onlineJob.cancel()
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events") assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events") assertTrue(events.any { it is ProtoEvent.UserMessage }, "events=$events")
assertTrue(online.any { it is OnlineEvent.End }, "online=$online")
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
assertEquals(2, msgs.size) assertEquals(2, msgs.size)
@@ -363,14 +369,11 @@ class ChatAgentTest {
} }
@Test @Test
fun `Working is first durable event, streaming goes to online channel`() = runTest { fun `message events are durable, Working-and-End and streaming go online`() = runTest {
// Working-маркер обязан прийти самым первым durable-событием хода, до // Durable-поток несёт целые сообщения (UserMessage/AssistantMessage).
// End. Это позволяет UI показать спиннер сразу же при отправке, не // Working/End — маркеры фаз (онлайн, live-only), как и стриминг
// дожидаясь первого токена от LLM. // (StartReasoning / StartResponse / AppendText), который приходит
// // из [OnlineOutbox], а НЕ из durable-потока.
// Стриминг (StartReasoning / StartResponse / AppendText) — теперь
// онлайн-события (live-only, не сохраняются) и приходят из
// [OnlineOutbox], а НЕ из durable-потока.
val agent = newAgent() val agent = newAgent()
fakeLlm.reply = "ok" fakeLlm.reply = "ok"
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
@@ -388,17 +391,20 @@ class ChatAgentTest {
durableJob.cancel() durableJob.cancel()
onlineJob.cancel() onlineJob.cancel()
// Working — первый durable event хода (индекс 0). // durable: первое событие хода — user-сообщение; терминатор успешного
// хода — готовое assistant-сообщение.
assertTrue(durable.isNotEmpty(), "no events captured: $durable") assertTrue(durable.isNotEmpty(), "no events captured: $durable")
val first = durable.first() val first = durable.first()
assertIs<ProtoEvent.Working>(first) assertIs<ProtoEvent.UserMessage>(first)
assertTrue(durable.any { it is ProtoEvent.End }, "no End: $durable") assertTrue(durable.any { it is ProtoEvent.AssistantMessage }, "no AssistantMessage: $durable")
// Ранее стриминговые маркеры — теперь в онлайн-канале. // Working/End и стриминговые маркеры — онлайн-канал (live-only).
assertTrue(online.any { it is OnlineEvent.Working }, "no Working: $online")
assertTrue(online.any { it is OnlineEvent.StartReasoning }, "no StartReasoning: $online") assertTrue(online.any { it is OnlineEvent.StartReasoning }, "no StartReasoning: $online")
assertTrue(online.any { it is OnlineEvent.StartResponse }, "no StartResponse: $online") assertTrue(online.any { it is OnlineEvent.StartResponse }, "no StartResponse: $online")
assertTrue(online.any { it is OnlineEvent.End }, "no End: $online")
// Working не дублируется (emit'ится один раз на send()). // Working не дублируется (emit'ится один раз на send()).
assertEquals(1, durable.count { it is ProtoEvent.Working }, "Working emitted >1 times: $durable") assertEquals(1, online.count { it is OnlineEvent.Working }, "Working emitted >1 times: $online")
} }
@Test @Test
@@ -417,6 +423,10 @@ class ChatAgentTest {
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) } agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
} }
val online = mutableListOf<OnlineEvent>()
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
}
val sendJob = launch { val sendJob = launch {
try { try {
@@ -430,6 +440,7 @@ class ChatAgentTest {
conv.interrupt() conv.interrupt()
sendJob.join() sendJob.join()
eventsJob.cancel() eventsJob.cancel()
onlineJob.cancel()
// audit: только user (assistant не успел сгенериться) // audit: только user (assistant не успел сгенериться)
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
@@ -441,9 +452,9 @@ class ChatAgentTest {
assertEquals(1, wm.size) assertEquals(1, wm.size)
assertTrue(wm[0].entry is WorkingMemoryEntry.User) assertTrue(wm[0].entry is WorkingMemoryEntry.User)
// events: должны включать Interrupted + End // durable: включая Interrupted; End — онлайн-маркер.
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events") assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events") assertTrue(online.any { it is OnlineEvent.End }, "online=$online")
} }
@Test @Test
@@ -504,10 +515,10 @@ class ChatAgentTest {
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён") assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
assertTrue(exchanges[0].resultText.contains("echo")) assertTrue(exchanges[0].resultText.contains("echo"))
// events должны включать ToolCall + ToolResult. End — обязательно (turn завершился). // events должны включать ToolCall + ToolResult. AssistantMessage — обязательно (turn завершился).
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events") assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events") assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events") assertTrue(events.any { it is ProtoEvent.AssistantMessage }, "events=$events")
} }
@Test @Test
@@ -615,7 +626,7 @@ class ChatAgentTest {
val agent = newAgent(llm = toolLlm, tools = listOf(echoTool)) val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool"))) conv.send(listOf(pw.binom.agentik.content.Content.Text("call the tool")))
// sendStreamContents вызывается дважды: первый раз с user-сообщением // sendStreamContents вызывается дважды: первый раз с user-сообщением
// (LLM отвечает tool_call), второй раз — после addToolResult — для // (LLM отвечает tool_call), второй раз — после addToolResult — для
@@ -12,7 +12,7 @@ import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.standalone.persistence.SqliteStores import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.proto.Content as ProtoContent import pw.binom.agentik.content.Content as ProtoContent
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
@@ -1,9 +1,9 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.standalone.agent
import pw.binom.agentik.journal.MessageContext import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.journal.MessageOrigin.EVENT import pw.binom.agentik.content.MessageOrigin.EVENT
import pw.binom.agentik.journal.MessageOrigin.SYSTEM import pw.binom.agentik.content.MessageOrigin.SYSTEM
import pw.binom.agentik.journal.MessageOrigin.USER import pw.binom.agentik.content.MessageOrigin.USER
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -25,7 +25,7 @@ import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.memory.md.openMdMemorySystem import pw.binom.agentik.memory.md.openMdMemorySystem
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
@@ -1,9 +1,9 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.journal.MessageContext import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.journal.MessageOrigin import pw.binom.agentik.content.MessageOrigin
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.context.WorkingMemoryEntry import pw.binom.agentik.context.WorkingMemoryEntry
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList