Изменение выносит типы контента в :content-api и разделяет потоки событий.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user