Изменение выносит типы контента в :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
+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,
}
@@ -0,0 +1,19 @@
package pw.binom.agentik.content
import kotlinx.serialization.Serializable
/**
* Token usage одного assistant turn'а.
*/
@Serializable
data class TurnTokens(
val input: Int,
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" }
}
}
@@ -0,0 +1,89 @@
package pw.binom.agentik.content
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Тесты сериализации MessageOrigin/MessageContext.
*
* Гарантируем:
* 1) enum origin → snake_case discriminator (`user`/`system`/`event`);
* 2) MessageContext → стабильный JSON-формат с полями `origin`, `description`,
* `sourceId`, `metadata`;
* 3) парсинг round-trip без потерь.
*/
class MessageContextTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `USER origin serializes as user`() {
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.USER)
assertEquals("\"user\"", s)
}
@Test
fun `SYSTEM origin serializes as system`() {
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.SYSTEM)
assertEquals("\"system\"", s)
}
@Test
fun `EVENT origin serializes as event`() {
val s = json.encodeToString(MessageOrigin.serializer(), MessageOrigin.EVENT)
assertEquals("\"event\"", s)
}
@Test
fun `origin deserializes from snake_case`() {
assertEquals(MessageOrigin.USER, json.decodeFromString(MessageOrigin.serializer(), "\"user\""))
assertEquals(MessageOrigin.SYSTEM, json.decodeFromString(MessageOrigin.serializer(), "\"system\""))
assertEquals(MessageOrigin.EVENT, json.decodeFromString(MessageOrigin.serializer(), "\"event\""))
}
@Test
fun `USER context with no fields roundtrips`() {
val ctx = MessageContext(origin = MessageOrigin.USER)
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
// description/sourceId/metadata отсутствуют → не должны попасть в JSON (explicitNulls=false + defaults)
assertEquals("""{"origin":"user"}""", encoded)
val decoded = json.decodeFromString(MessageContext.serializer(), encoded)
assertEquals(ctx, decoded)
assertNull(decoded.description)
assertNull(decoded.sourceId)
assertNull(decoded.metadata)
}
@Test
fun `EVENT context with all fields roundtrips`() {
val ctx = MessageContext(
origin = MessageOrigin.EVENT,
description = "scheduled cron morning-briefing",
sourceId = "cron-42",
metadata = buildJsonObject {
put("scheduledAt", JsonPrimitive("2026-09-14T08:00:00Z"))
put("rule", JsonPrimitive("0 8 * * *"))
},
)
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
val decoded = json.decodeFromString(MessageContext.serializer(), encoded)
assertEquals(ctx, decoded)
assertEquals(MessageOrigin.EVENT, decoded.origin)
assertEquals("scheduled cron morning-briefing", decoded.description)
assertEquals("cron-42", decoded.sourceId)
assertEquals(ctx.metadata, decoded.metadata)
}
@kotlin.experimental.ExperimentalNativeApi
@Test
fun `origin field name is origin in JSON`() {
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot")
val encoded = json.encodeToString(MessageContext.serializer(), ctx)
// Поле должно называться ровно `origin` — клиенты могут на него полагаться.
assert(encoded.contains("\"origin\":\"system\"")) { "expected origin field, got: $encoded" }
}
}