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
+23
View File
@@ -0,0 +1,23 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
implementation(project(":proto"))
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.server
import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/**
* HTTP-снимок [Conversation] — те же поля, что у интерфейса, но без методов.
* Сериализуется в JSON и обратно.
*/
@Serializable
data class ConversationSnapshot(
val id: String,
val isSupportImageInput: Boolean,
val isSupportImageOutput: Boolean,
val isTemporal: Boolean,
val title: String? = null,
val updatedAt: Instant,
)
internal fun Conversation.snapshot(): ConversationSnapshot = ConversationSnapshot(
id = id,
isSupportImageInput = isSupportImageInput,
isSupportImageOutput = isSupportImageOutput,
isTemporal = isTemporal,
title = title,
updatedAt = updatedAt,
)
@Serializable
internal data class RequestCreateConversation(val temp: Boolean)
@Serializable
internal data class RequestRename(val title: String)
@@ -0,0 +1,43 @@
package pw.binom.agentik.server
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.Route
import io.ktor.server.routing.route
import pw.binom.agentik.proto.Agent
/**
* Встраивает HTTP/SSE-фасад протокола agentik в твой Ktor-роутинг.
*
* Использование:
* ```
* embeddedServer(Netty, port = 8080) {
* routing {
* agentikAgent(MyAgent()) // все роуты под /agentik
* agentikAgent(MyAgent(), "/api/chat") // или под произвольным префиксом
* }
* }.start(wait = true)
* ```
*
* Под префиксом [path] монтируются:
* - `POST /conversations` — создать диалог
* - `GET /conversations` — список
* - `GET /conversations/{id}` — один диалог
* - `PATCH /conversations/{id}` — переименовать
* - `DELETE /conversations/{id}` — удалить
* - `POST /conversations/{id}/messages` — `send` (202 Accepted)
* - `POST /conversations/{id}/interrupt` — `interrupt`
* - `GET /conversations/{id}/messages` — история
* - `GET /conversations/{id}/events` — SSE: события хода
* - `GET /events` — SSE: события агента
* - `GET /health` — `"ok"`
*/
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
route(path) {
install(ContentNegotiation) {
json(agentikJson)
}
agentikRoutes(agent)
}
}
@@ -0,0 +1,160 @@
package pw.binom.agentik.server
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.response.respondTextWriter
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
internal fun Route.agentikRoutes(agent: Agent) {
get("/health") {
call.respondText("ok")
}
// ---- Agent: множество диалогов ----
get("/conversations") {
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Agent.PAGE_SIZE
call.respond(agent.getConversations(offset, limit).map { it.snapshot() })
}
post("/conversations") {
val req = call.receive<RequestCreateConversation>()
val c = agent.createConversation(req.temp)
call.respond(HttpStatusCode.Created, c.snapshot())
}
get("/conversations/{id}") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) call.respond(HttpStatusCode.NotFound)
else call.respond(c.snapshot())
}
delete("/conversations/{id}") {
val id = call.parameters["id"]!!
call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound)
}
// ---- Conversation ----
patch("/conversations/{id}") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@patch
}
val req = call.receive<RequestRename>()
c.rename(req.title)
call.respond(c.snapshot())
}
post("/conversations/{id}/messages") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@post
}
val content = call.receive<List<Content>>()
c.send(content)
call.respond(HttpStatusCode.Accepted)
}
post("/conversations/{id}/interrupt") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@post
}
c.interrupt()
call.respond(HttpStatusCode.Accepted)
}
get("/conversations/{id}/messages") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val after = call.parseAfter() ?: return@get
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Conversation.PAGE_SIZE
call.respond(c.getMessages(after, offset, limit))
}
// ---- SSE ----
get("/conversations/{id}/events") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val after = call.parseAfter() ?: return@get
call.streamJsonSse(c.events(after), Event.serializer())
}
get("/events") {
val after = call.parseAfter() ?: return@get
call.streamJsonSse(agent.events(after), AgentEvent.serializer())
}
}
// ---------- helpers ----------
/**
* Парсит query-параметр `after` как ISO-8601 [Instant]. Отсутствие = [Instant.DISTANT_PAST].
* При невалидном значении отвечает 400 и возвращает `null`.
*/
private suspend fun ApplicationCall.parseAfter(): Instant? {
val raw = request.queryParameters["after"]
if (raw == null) return Instant.DISTANT_PAST
return try {
Instant.parse(raw)
} catch (_: IllegalArgumentException) {
respond(HttpStatusCode.BadRequest, "Invalid 'after' (expected ISO-8601): $raw")
null
}
}
private suspend fun <T> ApplicationCall.streamJsonSse(
flow: Flow<T>,
serializer: KSerializer<T>,
json: Json = agentikJson,
) {
respondTextWriter(contentType = ContentType.Text.EventStream) {
flow.catch { /* клиент отвалился — глушим */ }
.collect { value ->
val s = json.encodeToString(serializer, value)
write("data: ")
write(s)
write("\n\n")
flush()
}
}
}
@@ -0,0 +1,30 @@
package pw.binom.agentik.server
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlin.time.Instant
internal object InstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
internal val agentikJson: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
serializersModule = SerializersModule {
contextual(Instant::class, InstantSerializer)
}
}