Bring up :proto protocol + :server (Ktor) + :client (HTTP) modules; wire :server into standalone with EchoProtoAgent
Major additions: * :proto (KMP submodule) — in-house stateful protocol replacing AG-UI. Agent owns conversation transcript; Conversation.events(after) is a live, replay-free stream; backfill via Conversation.getMessages(after, offset, limit). Each Event carries an Instant date for client-side resume tracking. Sealed hierarchies (Content/Message/Event/AgentEvent) annotated @Serializable with snake_case @SerialName JSON discriminators so the wire format is decoupled from Kotlin class names. * :server (JVM, Ktor 3.1.3) — REST+SSE facade for Agent. Public entry: Route.agentikAgent(agent, path = "/agentik"). Endpoints: create/list/get/patch/delete conversations, POST messages (202), POST interrupt, GET messages, GET conversation events (SSE), GET agent events (SSE), GET /health. Custom Instant serializer for kotlin.time.Instant registered contextually on agentikJson (ISO-8601, ignoreUnknownKeys=true, explicitNulls=false). * :client (JVM, Ktor HTTP Client + CIO) — mirror of :server returning a pw.binom.agentik.proto.Agent backed by HTTP calls. Custom SSE parser since ktor-client-sse is not on the 3.1.3 client classpath. * standalone — EchoProtoAgent (in-memory Agent for :proto), EchoAgent (existing AG-UI echo), both mounted on the same Netty embedded server on port 8080 (/agui and /agentik); A2A stays on its own CIO engine on 8081. EchoProtoAgent smoke-tested end-to-end against :server: all 11 endpoints, including live SSE delivery of StartResponse/AppendText/End event triplets and Agent-level Created/Deleted events. Design notes pinned in: * agentik/IRC-QUESTIONS.md — closed 13-item checklist for the upcoming :irc-server transport (channel = conversation, CTCP for structural events, draft/chathistory for backfill, ImageStore side-channel, etc). * docs/ARCHITECTURE.md — overall layout snapshot.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор AG-UI api.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.datetime)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Ядро собственного протокола agentik (замена AG-UI). Явно stateful.
|
||||
*
|
||||
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
|
||||
* [createConversation] возвращает [Conversation], который сам хранит историю
|
||||
* и которому отправляют ходы через [Conversation.send].
|
||||
*/
|
||||
public interface Agent {
|
||||
|
||||
/** Идентификатор агента. */
|
||||
val id: String
|
||||
|
||||
/** Создаёт новый stateful-диалог с агентом. */
|
||||
fun createConversation(temp: Boolean): Conversation
|
||||
|
||||
/** Диалог по идентификатору; `null`, если не найден. */
|
||||
suspend fun getConversation(id: String): Conversation?
|
||||
|
||||
/** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */
|
||||
suspend fun deleteConversation(id: String): Boolean
|
||||
|
||||
/** Страница диалогов: не более [limit] штук, начиная с [offset]-го. */
|
||||
suspend fun getConversations(offset: Int, limit: Int): List<Conversation>
|
||||
|
||||
/** Все диалоги, начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
|
||||
fun getConversations(offset: Int = 0): Flow<Conversation> = flow {
|
||||
var skip = offset
|
||||
while (true) {
|
||||
val page = getConversations(skip, PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
page.forEach { emit(it) }
|
||||
skip += page.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-подписка на изменения в множестве диалогов агента: создание,
|
||||
* удаление, переименование (см. [AgentEvent]). События внутри конкретного
|
||||
* диалога приходят через [Conversation.events].
|
||||
*
|
||||
* **Не реплеит** прошлое — для снимка множества используй [getConversations]
|
||||
* или [getConversation].
|
||||
*/
|
||||
fun events(after: Instant): Flow<AgentEvent>
|
||||
|
||||
companion object {
|
||||
|
||||
const val PAGE_SIZE: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Live-события уровня [Agent]: изменения в множестве диалогов
|
||||
* (создание, удаление, переименование). События, происходящие **внутри**
|
||||
* конкретного диалога, приходят через [Conversation.events], а не сюда.
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
||||
* идентична [Conversation.events]: поток **не реплеит** прошлое, для бэкфилла
|
||||
* используются `getConversations`/`getConversation`.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface AgentEvent {
|
||||
/** Момент эмиссии события в UTC. */
|
||||
val date: Instant
|
||||
|
||||
/**
|
||||
* Создан новый диалог. Передаётся его id — handle можно получить через
|
||||
* [Agent.getConversation]. Подписчик после [Created] может сразу открыть
|
||||
* live-подписку на этот диалог через [Conversation.events].
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("created")
|
||||
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
||||
|
||||
/**
|
||||
* Диалог удалён. Переданный [Conversation]-handle реализация обязана
|
||||
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
||||
* пользоваться handle нельзя.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("deleted")
|
||||
data class Deleted(override val date: Instant, val id: String) : AgentEvent
|
||||
|
||||
/** У диалога сменился заголовок. */
|
||||
@Serializable
|
||||
@SerialName("renamed")
|
||||
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый
|
||||
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
||||
* [Conversation].
|
||||
*/
|
||||
interface Conversation : AutoCloseable {
|
||||
val id: String
|
||||
|
||||
val isSupportImageInput: Boolean
|
||||
val isSupportImageOutput: Boolean
|
||||
|
||||
/**
|
||||
* Признак временного диалога: не персистится между перезапусками агента,
|
||||
* живёт только в памяти текущего процесса.
|
||||
*/
|
||||
val isTemporal: Boolean
|
||||
|
||||
val title: String?
|
||||
|
||||
/**
|
||||
* Момент последнего изменения диалога (любой [send], [rename] и т.п.) в UTC.
|
||||
* Используется для сортировки списка диалогов по свежести.
|
||||
*/
|
||||
val updatedAt: Instant
|
||||
|
||||
suspend fun rename(title: String)
|
||||
|
||||
/**
|
||||
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток
|
||||
* событий ответа приходит через [events].
|
||||
*
|
||||
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
||||
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
||||
*/
|
||||
suspend fun send(content: List<Content>)
|
||||
|
||||
/**
|
||||
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
||||
* in-flight tool может доехать или отвалиться). В [events] эмитится
|
||||
* [Event.Interrupted], затем может начаться следующий ход из очереди.
|
||||
*
|
||||
* Если хода нет — no-op.
|
||||
*/
|
||||
suspend fun interrupt()
|
||||
|
||||
/**
|
||||
* Live-подписка на всё, что происходит в диалоге, начиная с [after].
|
||||
*
|
||||
* **Не реплеит** события, произошедшие до [after] — для бэкфилла
|
||||
* используй [getMessages]. Если [after] — момент последнего виденного
|
||||
* клиентом события, поток продолжается «с того места».
|
||||
*
|
||||
* Подписки независимы: каждый вызов возвращает свой [Flow], отмена одного
|
||||
* не влияет на других подписчиков и на сам диалог.
|
||||
*/
|
||||
fun events(after: Instant): Flow<Event>
|
||||
|
||||
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
|
||||
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
|
||||
|
||||
/** Все сообщения после [after] начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
|
||||
fun getMessages(after: Instant, offset: Int = 0): Flow<Message> = flow {
|
||||
var skip = offset
|
||||
while (true) {
|
||||
val page = getMessages(after, skip, PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
page.forEach { emit(it) }
|
||||
skip += page.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно.
|
||||
* После [close] дальнейшие вызовы [send]/[interrupt]/[events]/[getMessages]/[rename] не определены.
|
||||
*/
|
||||
override fun close()
|
||||
|
||||
companion object {
|
||||
|
||||
const val PAGE_SIZE: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Элемент live-потока [Conversation.events].
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
||||
* порядка при равных timestamps.
|
||||
*
|
||||
* Базовая структура хода:
|
||||
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
||||
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface Event {
|
||||
/** Момент эмиссии события в UTC. */
|
||||
val date: Instant
|
||||
|
||||
@Serializable
|
||||
enum class ResponseType {
|
||||
@SerialName("text") TEXT,
|
||||
@SerialName("image") IMAGE
|
||||
}
|
||||
|
||||
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
|
||||
@Serializable
|
||||
@SerialName("start_reasoning")
|
||||
data class StartReasoning(override val date: Instant) : Event
|
||||
|
||||
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
|
||||
@Serializable
|
||||
@SerialName("start_response")
|
||||
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
||||
|
||||
/** Ход завершён нормально. Соответствующий [Message.AssistantMessage] появится в `getMessages`. */
|
||||
@Serializable
|
||||
@SerialName("end")
|
||||
data class End(override val date: Instant) : Event
|
||||
|
||||
/** Ход прерван через [Conversation.interrupt]. Частичный ответ НЕ сохраняется в истории. */
|
||||
@Serializable
|
||||
@SerialName("interrupted")
|
||||
data class Interrupted(override val date: Instant) : Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("append_text")
|
||||
data class AppendText(override val date: Instant, val body: String) : Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("append_image")
|
||||
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
|
||||
|
||||
/**
|
||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||
* [id] совпадает с id соответствующего [Message.ToolCall] в истории
|
||||
* после завершения хода.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("tool_call")
|
||||
data class ToolCall(
|
||||
override val date: Instant,
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val toolName: String,
|
||||
val toolArgs: String,
|
||||
) : Event
|
||||
|
||||
/**
|
||||
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
||||
* [id] совпадает с [ToolCall.id], к которому относится результат, и
|
||||
* с id [Message.ToolResult] в истории.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("tool_result")
|
||||
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event
|
||||
|
||||
/**
|
||||
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||
* прийти, но ход считается проваленным.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("error")
|
||||
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
sealed interface Message {
|
||||
/**
|
||||
* Уникальный идентификатор сообщения в рамках диалога. Стабилен между
|
||||
* стримом [Event] и историей: id, пришедший в [Event.ToolCall], равен
|
||||
* id соответствующего [ToolCall] в истории после завершения хода.
|
||||
*/
|
||||
val id: String
|
||||
|
||||
/**
|
||||
* Дата сообщения в UTC
|
||||
*/
|
||||
val date: Instant
|
||||
|
||||
@Serializable
|
||||
@SerialName("user_message")
|
||||
class UserMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
|
||||
|
||||
@Serializable
|
||||
@SerialName("assistant_message")
|
||||
class AssistantMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
|
||||
|
||||
@Serializable
|
||||
@SerialName("tool_call")
|
||||
class ToolCall(
|
||||
override val id: String,
|
||||
val title: String?,
|
||||
val toolName: String,
|
||||
val toolArgs: String,
|
||||
override val date: Instant
|
||||
) : Message
|
||||
|
||||
@Serializable
|
||||
@SerialName("tool_result")
|
||||
class ToolResult(override val id: String, val result: String?, override val date: Instant) : Message
|
||||
}
|
||||
Reference in New Issue
Block a user