feat: AG-UI as in-process KMP API with Ktor client/server transports
Model the AG-UI protocol as an in-process API of Java-style interfaces and classes, mirroring the official AG-UI Java SDK, and build it as a Kotlin Multiplatform library with two Ktor transport modules. Modules: - api: BaseEvent sealed hierarchy (all AG-UI event types), messages, state, tool, context, RunAgentInput/Parameters, Agent/AgentSubscriber/ EventDispatcher, AguiJson (kotlinx.serialization, "type" discriminator), plus a SerializersModule registering every event subtype polymorphically. - client: HttpAgent over Ktor SSE (POST RunAgentInput -> stream BaseEvent), implementing the Agent interface. - server: Application.agui()/Route.aguiAgent() exposing an Agent behind a POST + SSE endpoint via SSEServerContent, plus a runnable sample. Tests: api serialization round-trips, client SSE decode against a real test-host server, server endpoint integration.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package com.agui.server
|
||||
|
||||
import com.agui.api.agent.Agent
|
||||
import com.agui.api.event.BaseEvent
|
||||
import com.agui.api.event.RunErrorEvent
|
||||
import com.agui.api.run.RunAgentInput
|
||||
import com.agui.api.serialization.AguiJson
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.sse.SSEServerContent
|
||||
import io.ktor.server.sse.ServerSSESession
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Exposes [agent] over the AG-UI HTTP + SSE transport on [path].
|
||||
*
|
||||
* The endpoint accepts a `POST` with a [RunAgentInput] JSON body and replies with
|
||||
* `text/event-stream`: each server-sent event carries one [BaseEvent] as its `data` payload.
|
||||
*
|
||||
* If the agent's flow throws, a [RunErrorEvent] is emitted before the stream closes.
|
||||
*
|
||||
* This function installs nothing globally: no content negotiation, no plugins — just an
|
||||
* endpoint inside the application's routing. Add other routes freely.
|
||||
*/
|
||||
public fun Application.agui(
|
||||
agent: Agent,
|
||||
path: String = "/agui",
|
||||
json: Json = AguiJson,
|
||||
) {
|
||||
routing {
|
||||
aguiAgent(agent, path, json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level variant for composing into an existing routing block. Unlike
|
||||
* [Application.agui] it does not create its own routing context.
|
||||
*/
|
||||
public fun Route.aguiAgent(
|
||||
agent: Agent,
|
||||
path: String = "/agui",
|
||||
json: Json = AguiJson,
|
||||
) {
|
||||
post(path) {
|
||||
val input: RunAgentInput = decodeInput(call, json)
|
||||
val handler: suspend (ServerSSESession) -> Unit = { session ->
|
||||
try {
|
||||
agent.run(input).collect { event ->
|
||||
session.send(json.encodeToString<BaseEvent>(event))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
session.send(json.encodeToString<BaseEvent>(RunErrorEvent(e.message ?: "Agent error")))
|
||||
}
|
||||
}
|
||||
call.respond(SSEServerContent(call, handler))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeInput(call: ApplicationCall, json: Json): RunAgentInput {
|
||||
val text = call.receiveText()
|
||||
if (text.isBlank()) {
|
||||
return RunAgentInput(threadId = "", runId = "")
|
||||
}
|
||||
return try {
|
||||
json.decodeFromString(RunAgentInput.serializer(), text)
|
||||
} catch (e: SerializationException) {
|
||||
throw IllegalArgumentException("Malformed AG-UI RunAgentInput: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.agui.server.demo
|
||||
|
||||
import com.agui.api.agent.Agent
|
||||
import com.agui.api.event.RunFinishedEvent
|
||||
import com.agui.api.event.RunStartedEvent
|
||||
import com.agui.api.event.StateSnapshotEvent
|
||||
import com.agui.api.event.TextMessageContentEvent
|
||||
import com.agui.api.event.TextMessageEndEvent
|
||||
import com.agui.api.event.TextMessageStartEvent
|
||||
import com.agui.server.agui
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* A minimal AG-UI server that echoes the last user message back.
|
||||
*
|
||||
* Run it with: gradlew :server:run (or start the embedded server)
|
||||
* then: curl -N -X POST http://localhost:8080/agui -H "Content-Type: application/json" \
|
||||
* -d '{"threadId":"t","runId":"r","messages":[{"id":"m","role":"user","content":"hi"}]}'
|
||||
*/
|
||||
private val echoAgent: Agent = Agent { input ->
|
||||
flow {
|
||||
emit(RunStartedEvent(threadId = input.threadId, runId = input.runId))
|
||||
emit(StateSnapshotEvent(snapshot = input.state))
|
||||
val reply = (input.messages.lastOrNull()?.content ?: "world").ifBlank { "world" }
|
||||
val messageId = "msg-${input.runId}"
|
||||
emit(TextMessageStartEvent(messageId = messageId))
|
||||
emit(TextMessageContentEvent(messageId = messageId, delta = "Hello, $reply!"))
|
||||
emit(TextMessageEndEvent(messageId = messageId))
|
||||
emit(RunFinishedEvent(threadId = input.threadId, runId = input.runId))
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
|
||||
agui(echoAgent, path = "/agui")
|
||||
}.start(wait = true)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.agui.server
|
||||
|
||||
import com.agui.api.agent.Agent
|
||||
import com.agui.api.event.RunErrorEvent
|
||||
import com.agui.api.event.RunFinishedEvent
|
||||
import com.agui.api.event.RunStartedEvent
|
||||
import com.agui.api.event.StateSnapshotEvent
|
||||
import com.agui.api.event.TextMessageContentEvent
|
||||
import com.agui.api.event.TextMessageEndEvent
|
||||
import com.agui.api.event.TextMessageStartEvent
|
||||
import com.agui.api.message.Message
|
||||
import com.agui.api.message.MessageRole
|
||||
import com.agui.api.run.RunAgentInput
|
||||
import com.agui.api.state.mutableState
|
||||
import com.agui.client.HttpAgent
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.ktor.server.application.application
|
||||
import io.ktor.server.testing.testApplication
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AguiServerTest {
|
||||
|
||||
private val agent: Agent = Agent { input ->
|
||||
flow {
|
||||
emit(RunStartedEvent(threadId = input.threadId, runId = input.runId))
|
||||
emit(StateSnapshotEvent(snapshot = input.state))
|
||||
val messageId = "msg-${input.runId}"
|
||||
emit(TextMessageStartEvent(messageId = messageId))
|
||||
val last = input.messages.lastOrNull()
|
||||
emit(TextMessageContentEvent(messageId = messageId, delta = "Hello ${last?.content ?: "world"}"))
|
||||
emit(TextMessageEndEvent(messageId = messageId))
|
||||
emit(RunFinishedEvent(threadId = input.threadId, runId = input.runId))
|
||||
}
|
||||
}
|
||||
|
||||
private val failingAgent: Agent = Agent { _ ->
|
||||
flow { throw IllegalStateException("boom") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POST runs an agent and streams events via SSE`() = runTest {
|
||||
testApplication {
|
||||
application {
|
||||
agui(agent)
|
||||
}
|
||||
val httpAgent = HttpAgent(client = createClient { install(SSE) }, endpointUrl = "/agui")
|
||||
val input = RunAgentInput(
|
||||
threadId = "t1",
|
||||
runId = "r1",
|
||||
messages = listOf(Message(id = "m0", role = MessageRole.USER, content = "hi")),
|
||||
state = mutableState("counter" to JsonPrimitive(42)),
|
||||
)
|
||||
val events = httpAgent.run(input).toList()
|
||||
|
||||
assertEquals(RunStartedEvent(threadId = "t1", runId = "r1"), events.first())
|
||||
assertEquals(RunFinishedEvent(threadId = "t1", runId = "r1"), events.last())
|
||||
val snapshot = events.filterIsInstance<StateSnapshotEvent>().single()
|
||||
assertEquals(mutableState("counter" to JsonPrimitive(42)), snapshot.snapshot)
|
||||
assertTrue(events.any { it is TextMessageContentEvent && it.delta == "Hello hi" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an agent failure is signalled with a RUN_ERROR event`() = runTest {
|
||||
testApplication {
|
||||
application {
|
||||
agui(failingAgent)
|
||||
}
|
||||
val httpAgent = HttpAgent(client = createClient { install(SSE) }, endpointUrl = "/agui")
|
||||
val events = httpAgent.run(RunAgentInput(threadId = "t1", runId = "r1")).toList()
|
||||
val error = events.single() as RunErrorEvent
|
||||
assertEquals("boom", error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user