Изменение выносит типы контента в :content-api и разделяет потоки событий.
This commit is contained in:
@@ -5,7 +5,7 @@ import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
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 kotlin.time.Instant
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import pw.binom.agentik.cli.defaultCliHttpClient
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.content.Content
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||
|
||||
@@ -5,7 +5,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
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.outbox.Event
|
||||
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.proto.Agent
|
||||
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.outbox.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ package pw.binom.agentik.tui
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -40,6 +40,8 @@ val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").t
|
||||
// beforeEvaluate — поздно).
|
||||
val moduleDescriptions: Map<String, String> = mapOf(
|
||||
"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.",
|
||||
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
|
||||
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
|
||||
|
||||
+81
-37
@@ -72,9 +72,11 @@ dependencies {
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.time.Clock
|
||||
|
||||
@@ -87,21 +89,37 @@ fun main() = runBlocking {
|
||||
token = "s3cret", // или null, если не нужен
|
||||
)
|
||||
|
||||
// 2. Открыть диалог, отправить сообщение.
|
||||
val conv = agent.createConversation(temp = false)
|
||||
conv.send(listOf(Content.Text("Привет")))
|
||||
|
||||
// 3. Собирать 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
|
||||
// 2. Два независимых потока событий диалога:
|
||||
// durable (outbox) — целые события, с курсором после переподключения;
|
||||
// online (OnlineOutbox) — стриминг ответа, только live (без курсора).
|
||||
launch {
|
||||
agent.outbox.conversationEvents(after = Clock.System.now(), conversationId = conv.id)
|
||||
.collect { ce ->
|
||||
when (val ev = ce.event) {
|
||||
is Event.AssistantMessage -> println("[answer ready: ${ev.content}]")
|
||||
is Event.Interrupted -> println("[interrupted]")
|
||||
is Event.Error -> println("[error: ${ev.message}]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
conv.close()
|
||||
agent.close()
|
||||
@@ -109,7 +127,14 @@ fun main() = runBlocking {
|
||||
```
|
||||
|
||||
**Это весь клиент.** `: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'а — всё скрыто
|
||||
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||
@@ -172,9 +197,11 @@ history.forEach { rec ->
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val agent = AgentikAgent(
|
||||
id = "agentik",
|
||||
@@ -183,16 +210,30 @@ val agent = AgentikAgent(
|
||||
)
|
||||
|
||||
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
|
||||
// durable-поток (с курсором): terminal-события хода.
|
||||
launch {
|
||||
agent.outbox.conversationEvents(after = kotlin.time.Clock.System.now(), conversationId = conv.id)
|
||||
.collect { ce ->
|
||||
when (ce.event) {
|
||||
is Event.AssistantMessage -> println("\n--- answer ready ---")
|
||||
is Event.Error -> error("agent error: ${(ce.event as Event.Error).message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ChatSession(
|
||||
@@ -235,10 +276,11 @@ class ChatSession(
|
||||
after = Instant.DISTANT_PAST,
|
||||
).collect { cache.append(it) }
|
||||
}
|
||||
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||
// 2. Live: на каждом завершённом ходе (durable AssistantMessage)
|
||||
// просим у сервера новые записи.
|
||||
scope.launch {
|
||||
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||
if (ev is Event.End) {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conversationId).collect { ce ->
|
||||
if (ce.event is Event.AssistantMessage) {
|
||||
val newest = cache.let {
|
||||
// last-seen курсор — последний createdAt в кэше
|
||||
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||
@@ -342,22 +384,24 @@ escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступе
|
||||
появится в кэше через refresh-блок выше.
|
||||
|
||||
```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) {
|
||||
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
|
||||
is OnlineEvent.Working -> println("[working]")
|
||||
is OnlineEvent.StartResponse -> println("[start]")
|
||||
is OnlineEvent.AppendText -> print(ev.body)
|
||||
is OnlineEvent.AppendImage -> showImage(ev.body)
|
||||
is OnlineEvent.End -> println("[end]")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Инструментальные вызовы и целый ответ — durable-поток
|
||||
(`agent.outbox.conversationEvents`): `Event.ToolCall`/`Event.ToolResult` и
|
||||
`Event.AssistantMessage`/`Event.Interrupted`/`Event.Error`.
|
||||
|
||||
## Прерывание хода
|
||||
|
||||
```kotlin
|
||||
|
||||
@@ -10,10 +10,10 @@ import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
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.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,11 +13,16 @@ import pw.binom.agentik.outbox.OnlineOutbox
|
||||
* HTTP-реализация [OnlineOutbox] (= [pw.binom.agentik.outbox.OnlineOutbox]),
|
||||
* ходящая в `:server`-фасад.
|
||||
*
|
||||
* **Endpoint**: [onlineEvents] → `GET {baseUrl}/conversations/{id}/online`
|
||||
* (live-only SSE, без `after`). Сервер не реплеит — подписка получает
|
||||
* только то, что эмитится после подключения. Reconnect-логика здесь не
|
||||
* нужна: при обрыве поток просто закрывается, а потерянные дельты
|
||||
* восстанавливаются из durable-истории ([HttpEventStore] + журнал).
|
||||
* **Endpoints**:
|
||||
* - [onlineEvents] (без аргумента) → `GET {baseUrl}/online` (live-only SSE,
|
||||
* все диалоги агента);
|
||||
* - [onlineEvents] с `conversationId` → `GET {baseUrl}/conversations/{id}/online`
|
||||
* (live-only SSE, один диалог).
|
||||
*
|
||||
* Сервер не реплеит — подписка получает только то, что эмитится после
|
||||
* подключения. Reconnect-логика здесь не нужна: при обрыве поток просто
|
||||
* закрывается, а потерянные дельты восстанавливаются из durable-истории
|
||||
* ([HttpEventStore] + журнал).
|
||||
*/
|
||||
internal class HttpOnlineOutbox(
|
||||
private val httpClient: HttpClient,
|
||||
@@ -26,8 +31,12 @@ internal class HttpOnlineOutbox(
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = flow {
|
||||
val url = "$agentUrl/conversations/$conversationId/online"
|
||||
override fun onlineEvents(): Flow<OnlineEvent> = stream("$agentUrl/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() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
|
||||
@@ -66,7 +66,7 @@ private fun testEvent(dateMs: Long): CommonEvent =
|
||||
CommonEvent.Conversation(
|
||||
date = Instant.fromEpochMilliseconds(dateMs),
|
||||
conversationId = "test",
|
||||
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||
event = Event.Interrupted(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
@@ -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 + натив).
|
||||
@@ -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,
|
||||
}
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.journal
|
||||
package pw.binom.agentik.content
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -11,6 +11,7 @@ data class TurnTokens(
|
||||
val output: Int,
|
||||
) {
|
||||
val total: Int get() = input + output
|
||||
|
||||
init {
|
||||
require(input >= 0) { "input tokens must be non-negative, got $input" }
|
||||
require(output >= 0) { "output tokens must be non-negative, got $output" }
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.proto
|
||||
package pw.binom.agentik.content
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -2,8 +2,8 @@ package pw.binom.agentik.context
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.journal.Content
|
||||
import pw.binom.agentik.journal.MessageContext
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
|
||||
/**
|
||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ package pw.binom.agentik.context.ksqlite
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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 kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
+19
-12
@@ -47,28 +47,35 @@ agentik
|
||||
## 3. `:proto` — интерфейсы
|
||||
|
||||
`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`
|
||||
- `suspend getConversation(id): Conversation?`
|
||||
- `suspend getConversations(offset, limit): List<Conversation>`
|
||||
- `getConversations(offset = 0): Flow<Conversation>` — cold-flow paging через suspend-версию, `PAGE_SIZE = 100`.
|
||||
- `events(after: Instant): Flow<AgentEvent>` — replay-free, бэкфилл через snapshot.
|
||||
- `deleteConversation(id): Boolean`
|
||||
- `suspend deleteConversation(id): Boolean`
|
||||
- `suspend renameConversation(id, title): Instant?`
|
||||
|
||||
`Conversation`:
|
||||
- `isSupportImageInput / Output / isTemporal: Boolean`
|
||||
- `isSupportImageInput / Output / isTemporal: Boolean`, `title: String?`
|
||||
- `updatedAt: Instant`
|
||||
- `send(content: List<Content>)` — write-only, ничего не возвращает.
|
||||
- `send(content: List<Content>, context: MessageContext? = null)` — write-only, ничего не возвращает.
|
||||
- `interrupt()` — отмена активного хода.
|
||||
- `events(after): Flow<Event>` — live, replay-free.
|
||||
- `getMessages(after, offset, limit)` + `getMessages(after): Flow<Message>` — paging.
|
||||
- `getMessages(after, offset, limit): List<Message>` — paging.
|
||||
- `rename(title)` — мутация, бампит `updatedAt`.
|
||||
- `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`.
|
||||
`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 и кэширует историю.
|
||||
|
||||
+6
-5
@@ -63,9 +63,9 @@
|
||||
│ 3. ensureLiteConversation: │
|
||||
│ first turn → create from WM; │
|
||||
│ next turns → reuse (KV-cache) │
|
||||
│ 4. sendStreamContents → emit │
|
||||
│ StartResponse / AppendText / │
|
||||
│ End │
|
||||
│ 4. sendStreamContents → online: │
|
||||
│ StartResponse/AppendText/End; │
|
||||
│ durable: AssistantMessage │
|
||||
│ 5. audit + WM: append AssistantMessage│
|
||||
└──────────────────────────────────────┘
|
||||
│ │
|
||||
@@ -151,7 +151,8 @@ fun main() {
|
||||
| `updatedAt: Instant` | последний `send`/`rename` |
|
||||
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
|
||||
| `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)` | страница истории |
|
||||
| `rename(title)` | переименовать |
|
||||
| `close()` | освободить ресурсы |
|
||||
@@ -173,7 +174,7 @@ fun main() {
|
||||
|
||||
Подробный контракт — в комментариях к `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`
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":content-api"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
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.Serializable
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import pw.binom.agentik.content.TurnTokens
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -36,6 +39,12 @@ sealed interface MessageRecord {
|
||||
override val content: List<Content>,
|
||||
override val createdAt: Instant,
|
||||
val tokens: TurnTokens? = null,
|
||||
/**
|
||||
* Текст размышлений модели (chain-of-thought / reasoning), если провайдер
|
||||
* его отдаёт. Опционально: null, если размышлений не было или провайдер
|
||||
* их не раскрывает.
|
||||
*/
|
||||
val reasoning: String? = null,
|
||||
) : Body
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -4,6 +4,9 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
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 {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -17,15 +20,17 @@ data class MessageBodyPayload(
|
||||
@SerialName("context")
|
||||
val context: MessageContext? = null,
|
||||
val tokens: TurnTokens? = null,
|
||||
val reasoning: String? = null,
|
||||
)
|
||||
|
||||
fun encodeBodyPayload(
|
||||
content: List<Content>,
|
||||
context: MessageContext? = null,
|
||||
tokens: TurnTokens? = null,
|
||||
reasoning: String? = null,
|
||||
): String = bodyJson.encodeToString(
|
||||
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)
|
||||
@@ -34,14 +39,15 @@ data class BodyDecoded(
|
||||
val content: List<Content>,
|
||||
val context: MessageContext?,
|
||||
val tokens: TurnTokens? = null,
|
||||
val reasoning: String? = null,
|
||||
)
|
||||
|
||||
private fun readPayload(json: String): BodyDecoded {
|
||||
return try {
|
||||
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) {
|
||||
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
||||
BodyDecoded(arr, null, null)
|
||||
BodyDecoded(arr, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ class InMemoryJournalStoreTest {
|
||||
MessageRecord.UserMessage(
|
||||
id = id,
|
||||
conversationId = convId,
|
||||
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
|
||||
content = listOf(pw.binom.agentik.content.Content.Text(text)),
|
||||
createdAt = at,
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
|
||||
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
||||
content = record.content,
|
||||
tokens = record.tokens,
|
||||
reasoning = record.reasoning,
|
||||
)
|
||||
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||
CallPayload.serializer(),
|
||||
@@ -49,7 +50,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
}
|
||||
"assistant" -> {
|
||||
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" -> {
|
||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@ package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import kotlinx.coroutines.flow.toList
|
||||
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.TurnTokens
|
||||
import pw.binom.agentik.content.TurnTokens
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
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.MessageRecord
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
@@ -20,8 +20,10 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены
|
||||
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization).
|
||||
// :proto не нужен (Event/OnlineEvent/AgentEvent/CommonEvent живут
|
||||
// здесь). Message-события несут общие типы содержимого из
|
||||
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
|
||||
api(project(":content-api"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
|
||||
@@ -2,29 +2,25 @@ package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
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
|
||||
|
||||
/**
|
||||
* Элемент live-потока `Conversation.events(after)`.
|
||||
* Элемент **durable**-потока диалога.
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
||||
* как курсор («где остановился») при обрыве/переподключении и для разрешения
|
||||
* порядка при равных timestamps.
|
||||
*
|
||||
* Базовая структура хода:
|
||||
* `Working` → `End` | `Interrupted` | `Error`, между ними — целые события
|
||||
* ([ToolCall]/[ToolResult]/[ToolFailed]).
|
||||
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
|
||||
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
|
||||
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
|
||||
* Их можно перезапросить по курсору (`after`).
|
||||
*
|
||||
* **Стриминг ответа (дельты текста/картинок и маркеры фаз) — НЕ здесь.**
|
||||
* Дельты токенов живут в [OnlineEvent] (live-only, не сохраняются).
|
||||
* [Event] — только «целые» (durable) события, пригодные к перезапросу
|
||||
* по курсору.
|
||||
*
|
||||
* `Working` — маркер «агент принял запрос и пошёл обрабатывать», эмитится
|
||||
* синхронно в `Conversation.send()` ДО старта LLM-цикла (и возможной долгой
|
||||
* очереди turnLock'а). Парный терминатор не нужен: `End`/`Interrupted`/`Error`
|
||||
* уже закрывают ход. UI использует `Working` чтобы показать спиннер ещё до
|
||||
* первого токена ответа.
|
||||
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
|
||||
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
|
||||
*
|
||||
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
|
||||
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
|
||||
@@ -39,24 +35,35 @@ sealed interface Event {
|
||||
val date: Instant
|
||||
|
||||
/**
|
||||
* Маркер «агент принял запрос и пошёл обрабатывать». Эмитится **до**
|
||||
* [End]/[Interrupted]/[Error], синхронно из `Conversation.send()`,
|
||||
* чтобы UI мог показать спиннер ещё до первого токена ответа.
|
||||
* Терминатор хода ([End]/[Interrupted]/[Error]) — парный.
|
||||
* Целое пользовательское сообщение хода. Эмитится в тот же момент, когда
|
||||
* запись попадает в журнал (для персистентных диалогов). [id] совпадает
|
||||
* с id соответствующего `MessageRecord.UserMessage`/`Message.UserMessage`.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("working")
|
||||
data class Working(override val date: Instant) : Event
|
||||
@SerialName("user_message")
|
||||
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
|
||||
@SerialName("end")
|
||||
data class End(override val date: Instant) : Event
|
||||
|
||||
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
|
||||
@Serializable
|
||||
@SerialName("interrupted")
|
||||
data class Interrupted(override val date: Instant) : Event
|
||||
@SerialName("assistant_message")
|
||||
data class AssistantMessage(
|
||||
override val date: Instant,
|
||||
val id: String,
|
||||
val content: List<Content>,
|
||||
val reasoning: String? = null,
|
||||
val tokens: TurnTokens? = null,
|
||||
) : Event
|
||||
|
||||
/**
|
||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||
@@ -96,6 +103,14 @@ sealed interface Event {
|
||||
val result: String?,
|
||||
) : 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
|
||||
|
||||
/**
|
||||
* **Онлайн-события** диалога: стриминг ответа агента «в моменте» —
|
||||
* дельты текста/картинок и маркеры фаз хода.
|
||||
* **Онлайн-события** диалога: live-поток «в моменте» — маркеры фаз хода и
|
||||
* стриминг ответа агента (дельты текста/картинок).
|
||||
*
|
||||
* Принципиальное отличие от [Event] (durable):
|
||||
* - **Никогда и нигде не сохраняются** — ни в буфер [OnlineOutbox],
|
||||
@@ -14,13 +14,13 @@ import kotlin.time.Instant
|
||||
* - **Только онлайн-подписка**: события, эмитнутые до подписки
|
||||
* (или в момент обрыва соединения), не реплеятся и не восстанавливаются.
|
||||
* Потерянный фрагмент не страшен — целый результат хода приходит
|
||||
* durable-событием ([Event.End]) и/или лежит в журнале.
|
||||
* durable-событием ([Event.AssistantMessage]) и/или лежит в журнале.
|
||||
* - **Нет курсора**: у потока нет `after`/`lastSeen` — курсор там, где
|
||||
* есть что реплеить.
|
||||
*
|
||||
* Зачем разделять: дельты токенов — высокочастотный мусор, который,
|
||||
* попав в durable store, копится в RAM (standalone-outbox растёт unbounded)
|
||||
* и засоряет историю. В [Event] остаются только «целые» события,
|
||||
* Зачем разделять: маркеры фаз и дельты токенов — высокочастотный мусор,
|
||||
* который, попав в durable store, копится в RAM (standalone-outbox растёт
|
||||
* unbounded) и засоряет историю. В [Event] остаются только «целые» события,
|
||||
* пригодные к перезапросу по курсору.
|
||||
*/
|
||||
@Serializable
|
||||
@@ -34,6 +34,20 @@ sealed interface OnlineEvent {
|
||||
@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]). */
|
||||
@Serializable
|
||||
@SerialName("start_reasoning")
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
*
|
||||
* Это осознанный компромисс: дельты токенов — высокочастотный мусор,
|
||||
* который в durable-сторе копился бы в RAM и засорял историю. Потеря
|
||||
* фрагмента при обрыве не критична — целый ответ приходит [Event.End]
|
||||
* фрагмента при обрыве не критична — целый ответ приходит [Event.AssistantMessage]
|
||||
* и/или лежит в [pw.binom.agentik.journal.JournalStore].
|
||||
*
|
||||
* Read-only view: запись — через [MutableOnlineOutbox].
|
||||
|
||||
+5
-1
@@ -60,6 +60,10 @@ class InMemoryOnlineOutbox(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 1
|
||||
// Достаточно, чтобы не терять подряд идущие маркеры фаз
|
||||
// (Working/StartReasoning/StartResponse) и первые дельты, пока
|
||||
// подписчик не возобновил сбор. Дельты токенов при переполнении
|
||||
// всё равно дропаются (DROP_OLDEST) — потеря фрагмента допустима.
|
||||
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 64
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -164,7 +164,7 @@ class InMemoryOutboxStoreTest {
|
||||
store.append(CommonEvent.Conversation(
|
||||
date = now,
|
||||
conversationId = "c-1",
|
||||
event = Event.Working(date = now),
|
||||
event = Event.Interrupted(date = now),
|
||||
))
|
||||
|
||||
// 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 {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now)))
|
||||
store.append(CommonEvent.Conversation(now, "c-2", Event.Working(now)))
|
||||
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.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
|
||||
|
||||
// Test the filter logic by manually filtering snapshot.
|
||||
val c1 = store.snapshot()
|
||||
@@ -200,7 +200,7 @@ class InMemoryOutboxStoreTest {
|
||||
date = now,
|
||||
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 agents = all.filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
+56
-26
@@ -59,57 +59,87 @@ target-specific артефакты + общий `kotlinMultiplatform`.
|
||||
|
||||
## Основные типы
|
||||
|
||||
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
|
||||
а общие типы содержимого и события живут в нижележащих модулях:
|
||||
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
|
||||
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
|
||||
|
||||
```kotlin
|
||||
interface Agent {
|
||||
fun id: String
|
||||
suspend fun createConversation(title: String? = null): Conversation
|
||||
interface Agent : AutoCloseable {
|
||||
val id: String
|
||||
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 getConversations(offset: Int = 0): Flow<Conversation>
|
||||
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
|
||||
suspend fun deleteConversation(id: String): Boolean
|
||||
suspend fun renameConversation(id: String, title: String?): Instant?
|
||||
}
|
||||
|
||||
interface Conversation : AutoCloseable {
|
||||
val id: String
|
||||
val updatedAt: Instant
|
||||
val isTemporal: Boolean
|
||||
val title: String?
|
||||
val isSupportImageInput: Boolean
|
||||
val isSupportImageOutput: Boolean
|
||||
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
|
||||
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
|
||||
suspend fun getMessages(offset: Int = 0): Flow<Message>
|
||||
suspend fun rename(title: String): Boolean
|
||||
fun interrupt()
|
||||
suspend fun send(content: List<Content>, context: MessageContext? = null) // fire-and-forget
|
||||
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
|
||||
suspend fun rename(title: String)
|
||||
suspend fun interrupt()
|
||||
}
|
||||
|
||||
// :content-api
|
||||
sealed interface Content {
|
||||
class Text(val body: String) : Content
|
||||
class Image(val data: ByteArray, val mime: String) : Content
|
||||
data class Text(val body: 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 {
|
||||
val id: String
|
||||
val date: Instant
|
||||
interface Body : Message { val content: List<Content> }
|
||||
interface System : Message
|
||||
class UserMessage(...) : Body
|
||||
class AssistantMessage(...) : Body
|
||||
class ToolCall(...) : System
|
||||
class ToolResult(...) : System
|
||||
class UserMessage(id, content: List<Content>, date, context: MessageContext?) : Message
|
||||
class AssistantMessage(id, content: List<Content>, date, tokens: TurnTokens?, reasoning: String?) : Message
|
||||
class ToolCall(...) : Message
|
||||
class ToolResult(...) : Message
|
||||
class Error(...) : Message
|
||||
}
|
||||
|
||||
// :outbox-api — durable (перезапрашиваются по курсору `after`)
|
||||
sealed interface Event {
|
||||
enum ResponseType { TEXT, IMAGE }
|
||||
class StartReasoning(...) : Event
|
||||
class StartResponse(val type: ResponseType) : 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
|
||||
val date: Instant
|
||||
class UserMessage(date, id, content: List<Content>, context: MessageContext?) : Event
|
||||
class AssistantMessage(date, id, content: List<Content>, reasoning: String?, tokens: TurnTokens?) : Event
|
||||
class ToolCall(...) : 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`
|
||||
|
||||
@@ -28,11 +28,12 @@ kotlin {
|
||||
// public-сигнатуре Agent, поэтому api-висимости.
|
||||
//
|
||||
// Линейный граф зависимостей (без циклов):
|
||||
// :proto ──► :outbox-api (нет обратной зависимости)
|
||||
// :proto ──► :journal-api (нет обратной зависимости)
|
||||
// Добились переносом AgentEvent/CommonEvent/Event из :proto в
|
||||
// :outbox-api — они теперь self-contained в outbox (не нужны
|
||||
// :proto-типы), а :proto использует их через :outbox-api.
|
||||
// :proto ──► :content-api (Content/MessageContext/MessageOrigin/TurnTokens)
|
||||
// :proto ──► :outbox-api (нет обратной зависимости)
|
||||
// :proto ──► :journal-api (нет обратной зависимости)
|
||||
// Общие типы содержимого живут в низкоуровневом :content-api,
|
||||
// чтобы ими пользовались proto/journal/outbox без дублей и циклов.
|
||||
api(project(":content-api"))
|
||||
api(project(":journal-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 pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -11,18 +13,18 @@ import kotlin.time.Instant
|
||||
*
|
||||
* **Live-события** диалога НЕ часть этого интерфейса. Их два независимых
|
||||
* потока:
|
||||
* - **durable** ([pw.binom.agentik.outbox.Event]: Working / End / Interrupted /
|
||||
* Error / ToolCall / ToolResult / ToolFailed) — из
|
||||
* - **durable** ([pw.binom.agentik.outbox.Event]: UserMessage / AssistantMessage /
|
||||
* Interrupted / Error / ToolCall / ToolResult / ToolFailed) — из
|
||||
* [pw.binom.agentik.outbox.OutboxStore], перезапрашивается по курсору:
|
||||
* ```
|
||||
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
|
||||
* .map { it.event }
|
||||
* .collect { e -> ... }
|
||||
* ```
|
||||
* - **online** ([pw.binom.agentik.outbox.OnlineEvent]: StartReasoning /
|
||||
* StartResponse / AppendText / AppendImage) — live-only стриминг ответа
|
||||
* из [pw.binom.agentik.outbox.OnlineOutbox], без catchup/курсора:
|
||||
* `agent.onlineOutbox.onlineEvents(id)`.
|
||||
* - **online** ([pw.binom.agentik.outbox.OnlineEvent]: Working / End /
|
||||
* StartReasoning / StartResponse / AppendText / AppendImage) — live-only
|
||||
* стриминг ответа из [pw.binom.agentik.outbox.OnlineOutbox], без
|
||||
* catchup/курсора: `agent.onlineOutbox.onlineEvents(id)`.
|
||||
*
|
||||
* Для cross-conversation view (admin / parent-agent / debug):
|
||||
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
|
||||
|
||||
@@ -2,6 +2,9 @@ package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
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
|
||||
|
||||
@Serializable
|
||||
@@ -33,7 +36,17 @@ sealed interface Message {
|
||||
|
||||
@Serializable
|
||||
@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
|
||||
@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
|
||||
|
||||
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.MessageContext
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.proto.Agent
|
||||
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.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
@@ -189,6 +189,15 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
||||
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") {
|
||||
val after = call.parseAfter() ?: return@get
|
||||
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||
|
||||
@@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
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.MessageRecord
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||
|
||||
@@ -9,6 +9,7 @@ import pw.binom.agentik.outbox.OnlineOutbox
|
||||
* онлайн-канал тестам не нужен, но интерфейс обязывает его отдать.
|
||||
*/
|
||||
internal fun emptyOnlineOutbox(): OnlineOutbox = object : OnlineOutbox {
|
||||
override fun onlineEvents() = emptyFlow<OnlineEvent>()
|
||||
override fun onlineEvents(conversationId: String) = emptyFlow<OnlineEvent>()
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ include(":reflection-api")
|
||||
include(":journal-api")
|
||||
include(":context-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. Двухуровневое хранилище: этот модуль —
|
||||
// короткий live tail + recent replay; полный audit log живёт в :message-store-api
|
||||
// (там — 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.OnlineEvent
|
||||
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 java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -31,7 +31,7 @@ private val log = KotlinLogging.logger {}
|
||||
* Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток
|
||||
* ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only,
|
||||
* без catchup — события начала хода иначе можно упустить), завершение хода ждём
|
||||
* по durable-событиям [Event.End] / [Event.Interrupted] / [Event.Error].
|
||||
* по онлайн [OnlineEvent.End] и durable [Event.AssistantMessage] / [Event.Interrupted] / [Event.Error].
|
||||
*
|
||||
* Ограничение v1: tool-события и картинки в A2A-ответ не транслируются;
|
||||
* при нескольких ходов в очереди за контекстом текст предыдущего хода
|
||||
@@ -53,14 +53,20 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
||||
// Онлайн-поток — дельты ответа (live-only, без catchup).
|
||||
val onlineJob = async {
|
||||
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 {
|
||||
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
||||
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 ->
|
||||
turnDone.completeExceptionally(
|
||||
IllegalStateException("agent turn failed: ${e.message}")
|
||||
|
||||
@@ -137,5 +137,5 @@ internal suspend fun recentTurns(workingMemoryStore: ContextStore, conversationI
|
||||
}
|
||||
|
||||
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
||||
internal fun List<pw.binom.agentik.journal.Content>.text(): String =
|
||||
filterIsInstance<pw.binom.agentik.journal.Content.Text>().joinToString("\n") { it.body }
|
||||
internal fun List<pw.binom.agentik.content.Content>.text(): String =
|
||||
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)) {
|
||||
val text = when (entry) {
|
||||
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 }
|
||||
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 }
|
||||
else -> continue
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.memory.MemoryReviewer
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
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.WorkingMemoryRow
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ package pw.binom.agentik.standalone.agent
|
||||
import mu.KotlinLogging
|
||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.agentik.journal.MessageContext
|
||||
import pw.binom.agentik.journal.MessageOrigin
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import pw.binom.agentik.content.MessageOrigin
|
||||
|
||||
internal class ContextBuilder(
|
||||
private val memoryPrefetcher: MemoryPrefetcher?,
|
||||
|
||||
+39
-18
@@ -19,22 +19,22 @@ import mu.KotlinLogging
|
||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||
import pw.binom.agentik.memory.MemoryReviewer
|
||||
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.outbox.Event as ProtoEvent
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import pw.binom.agentik.proto.Message as ProtoMessage
|
||||
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
||||
import pw.binom.agentik.journal.Content
|
||||
import pw.binom.agentik.content.MessageContext as ProtoMessageContext
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.MessageContext
|
||||
import pw.binom.agentik.journal.MessageOrigin
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import pw.binom.agentik.content.MessageOrigin
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
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.ContextStore
|
||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||
@@ -179,11 +179,11 @@ class ConversationLoop(
|
||||
check(!state.isClosed) { "Conversation closed: $id" }
|
||||
val turnStarted = now()
|
||||
// Working-маркер — самый первый event хода. Эмитим синхронно через
|
||||
// tryEmit (events.tryEmit → outbox.append, без сетевого I/O), чтобы
|
||||
// клиент увидел «агент работает» ещё до turnLock.withLock { launch }
|
||||
// и до первого токена от LLM. Терминатор — End/Interrupted/Error
|
||||
// (см. KDoc Event.Working).
|
||||
emitEvent(pw.binom.agentik.outbox.Event.Working(date = turnStarted))
|
||||
// tryEmit (events.tryEmitOnline → без сетевого I/O), чтобы клиент
|
||||
// увидел «агент работает» ещё до turnLock.withLock { launch } и до
|
||||
// первого токена от LLM. Working/End — онлайн-маркеры (live-only),
|
||||
// терминатор durable-части — AssistantMessage/Interrupted/Error.
|
||||
emitOnline(OnlineEvent.Working(date = turnStarted))
|
||||
val userMessageId = newId("msg")
|
||||
val storageContext = context?.toStorage()
|
||||
|
||||
@@ -206,6 +206,16 @@ class ConversationLoop(
|
||||
),
|
||||
now = turnStarted,
|
||||
)
|
||||
// Durable-событие user-сообщения: позволяет восстановить историю
|
||||
// по курсору outbox без отдельного запроса в journal.
|
||||
emitEvent(
|
||||
ProtoEvent.UserMessage(
|
||||
date = turnStarted,
|
||||
id = userMessageId,
|
||||
content = userRecord.content,
|
||||
context = storageContext,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
turnLock.withLock {
|
||||
@@ -448,6 +458,17 @@ class ConversationLoop(
|
||||
)
|
||||
messageStore.append(assistantRecord)
|
||||
|
||||
// Durable-событие готового ответа агента.
|
||||
emitEvent(
|
||||
ProtoEvent.AssistantMessage(
|
||||
date = assistantAt,
|
||||
id = assistantId,
|
||||
content = assistantContent,
|
||||
reasoning = null,
|
||||
tokens = turnTokens,
|
||||
)
|
||||
)
|
||||
|
||||
workingMemoryStore.append(
|
||||
conversationId = id,
|
||||
entry = WorkingMemoryEntry.Assistant(
|
||||
@@ -489,7 +510,7 @@ class ConversationLoop(
|
||||
if (wasInterrupted || interrupted.get()) {
|
||||
emitEvent(ProtoEvent.Interrupted(date = now()))
|
||||
}
|
||||
emitEvent(ProtoEvent.End(date = now()))
|
||||
emitOnline(OnlineEvent.End(date = now()))
|
||||
|
||||
interrupted.set(false)
|
||||
}
|
||||
@@ -541,9 +562,9 @@ internal fun ProtoContent.toStorage(): Content = when (this) {
|
||||
|
||||
internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
|
||||
origin = when (origin) {
|
||||
pw.binom.agentik.proto.MessageOrigin.USER -> MessageOrigin.USER
|
||||
pw.binom.agentik.proto.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM
|
||||
pw.binom.agentik.proto.MessageOrigin.EVENT -> MessageOrigin.EVENT
|
||||
pw.binom.agentik.content.MessageOrigin.USER -> MessageOrigin.USER
|
||||
pw.binom.agentik.content.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM
|
||||
pw.binom.agentik.content.MessageOrigin.EVENT -> MessageOrigin.EVENT
|
||||
},
|
||||
description = description,
|
||||
sourceId = sourceId,
|
||||
@@ -552,9 +573,9 @@ internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
|
||||
|
||||
internal fun MessageContext.toProto(): ProtoMessageContext {
|
||||
val protoOrigin = when (origin) {
|
||||
MessageOrigin.USER -> pw.binom.agentik.proto.MessageOrigin.USER
|
||||
MessageOrigin.SYSTEM -> pw.binom.agentik.proto.MessageOrigin.SYSTEM
|
||||
MessageOrigin.EVENT -> pw.binom.agentik.proto.MessageOrigin.EVENT
|
||||
MessageOrigin.USER -> pw.binom.agentik.content.MessageOrigin.USER
|
||||
MessageOrigin.SYSTEM -> pw.binom.agentik.content.MessageOrigin.SYSTEM
|
||||
MessageOrigin.EVENT -> pw.binom.agentik.content.MessageOrigin.EVENT
|
||||
}
|
||||
return ProtoMessageContext(
|
||||
origin = protoOrigin,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
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.context.WorkingMemoryEntry
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
|
||||
+34
-23
@@ -9,7 +9,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
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.skill.mining.SkillReadTool
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
@@ -186,7 +186,7 @@ class ChatAgentTest {
|
||||
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
||||
id = "m1",
|
||||
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),
|
||||
),
|
||||
)
|
||||
@@ -214,10 +214,10 @@ class ChatAgentTest {
|
||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||
assertEquals(2, msgs.size)
|
||||
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 {
|
||||
(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) {
|
||||
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")))
|
||||
delay(50)
|
||||
job.cancel()
|
||||
onlineJob.cancel()
|
||||
|
||||
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()
|
||||
assertEquals(2, msgs.size)
|
||||
@@ -363,14 +369,11 @@ class ChatAgentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Working is first durable event, streaming goes to online channel`() = runTest {
|
||||
// Working-маркер обязан прийти самым первым durable-событием хода, до
|
||||
// End. Это позволяет UI показать спиннер сразу же при отправке, не
|
||||
// дожидаясь первого токена от LLM.
|
||||
//
|
||||
// Стриминг (StartReasoning / StartResponse / AppendText) — теперь
|
||||
// онлайн-события (live-only, не сохраняются) и приходят из
|
||||
// [OnlineOutbox], а НЕ из durable-потока.
|
||||
fun `message events are durable, Working-and-End and streaming go online`() = runTest {
|
||||
// Durable-поток несёт целые сообщения (UserMessage/AssistantMessage).
|
||||
// Working/End — маркеры фаз (онлайн, live-only), как и стриминг
|
||||
// (StartReasoning / StartResponse / AppendText), который приходит
|
||||
// из [OnlineOutbox], а НЕ из durable-потока.
|
||||
val agent = newAgent()
|
||||
fakeLlm.reply = "ok"
|
||||
val conv = agent.createConversation(temp = false)
|
||||
@@ -388,17 +391,20 @@ class ChatAgentTest {
|
||||
durableJob.cancel()
|
||||
onlineJob.cancel()
|
||||
|
||||
// Working — первый durable event хода (индекс 0).
|
||||
// durable: первое событие хода — user-сообщение; терминатор успешного
|
||||
// хода — готовое assistant-сообщение.
|
||||
assertTrue(durable.isNotEmpty(), "no events captured: $durable")
|
||||
val first = durable.first()
|
||||
assertIs<ProtoEvent.Working>(first)
|
||||
assertTrue(durable.any { it is ProtoEvent.End }, "no End: $durable")
|
||||
// Ранее стриминговые маркеры — теперь в онлайн-канале.
|
||||
assertIs<ProtoEvent.UserMessage>(first)
|
||||
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.StartResponse }, "no StartResponse: $online")
|
||||
assertTrue(online.any { it is OnlineEvent.End }, "no End: $online")
|
||||
|
||||
// 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
|
||||
@@ -417,6 +423,10 @@ class ChatAgentTest {
|
||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
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 {
|
||||
try {
|
||||
@@ -430,6 +440,7 @@ class ChatAgentTest {
|
||||
conv.interrupt()
|
||||
sendJob.join()
|
||||
eventsJob.cancel()
|
||||
onlineJob.cancel()
|
||||
|
||||
// audit: только user (assistant не успел сгенериться)
|
||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||
@@ -441,9 +452,9 @@ class ChatAgentTest {
|
||||
assertEquals(1, wm.size)
|
||||
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.End }, "events=$events")
|
||||
assertTrue(online.any { it is OnlineEvent.End }, "online=$online")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -504,10 +515,10 @@ class ChatAgentTest {
|
||||
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
|
||||
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.ToolResult }, "events=$events")
|
||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
||||
assertTrue(events.any { it is ProtoEvent.AssistantMessage }, "events=$events")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -615,7 +626,7 @@ class ChatAgentTest {
|
||||
val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
|
||||
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-сообщением
|
||||
// (LLM отвечает tool_call), второй раз — после addToolResult — для
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import pw.binom.agentik.standalone.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||
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.LiteConversationConfig
|
||||
import pw.binom.litert.LiteLlm
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import pw.binom.agentik.journal.MessageContext
|
||||
import pw.binom.agentik.journal.MessageOrigin.EVENT
|
||||
import pw.binom.agentik.journal.MessageOrigin.SYSTEM
|
||||
import pw.binom.agentik.journal.MessageOrigin.USER
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import pw.binom.agentik.content.MessageOrigin.EVENT
|
||||
import pw.binom.agentik.content.MessageOrigin.SYSTEM
|
||||
import pw.binom.agentik.content.MessageOrigin.USER
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import pw.binom.agentik.memory.MemorySystemGuidance
|
||||
import pw.binom.agentik.memory.NewMemoryNote
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
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.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
import pw.binom.agentik.journal.MessageContext
|
||||
import pw.binom.agentik.journal.MessageOrigin
|
||||
import pw.binom.agentik.content.MessageContext
|
||||
import pw.binom.agentik.content.MessageOrigin
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
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 kotlinx.coroutines.flow.toList
|
||||
|
||||
Reference in New Issue
Block a user