Изменение выносит типы контента в :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
+56 -26
View File
@@ -59,57 +59,87 @@ target-specific артефакты + общий `kotlinMultiplatform`.
## Основные типы
`:proto` — **тонкий** контракт: сами интерфейсы `Agent`/`Conversation`/`Message`,
а общие типы содержимого и события живут в нижележащих модулях:
`Content`/`MessageContext`/`MessageOrigin`/`TurnTokens` — в `:content-api`,
`Event`/`OnlineEvent`/`OutboxStore`/`OnlineOutbox` — в `:outbox-api`.
```kotlin
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`
+6 -5
View File
@@ -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,89 +0,0 @@
package pw.binom.agentik.proto
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" }
}
}