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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user