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