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:
2026-09-12 01:10:27 +03:00
parent d45a35a4af
commit a3581abf84
36 changed files with 1978 additions and 0 deletions
@@ -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
}