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:
2026-09-09 22:11:08 +03:00
parent a5d6c38573
commit fed7f7ab71
30 changed files with 1619 additions and 0 deletions
@@ -0,0 +1,48 @@
package com.agui.client
import com.agui.api.agent.Agent
import com.agui.api.event.BaseEvent
import com.agui.api.run.RunAgentInput
import com.agui.api.serialization.AguiJson
import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.sse
import io.ktor.client.request.setBody
import io.ktor.client.request.accept
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* A remote [Agent] that speaks the AG-UI transport: a `POST` of [RunAgentInput] as
* `application/json` to [endpointUrl], with the agent's [BaseEvent]s streamed back over
* Server-Sent Events, one JSON event per `data` line.
*
* The [HttpClient] is owned by the caller ([HttpAgent] does not close it).
*/
public class HttpAgent(
public val client: HttpClient,
public val endpointUrl: String,
public val json: Json = AguiJson,
) : Agent {
override fun run(input: RunAgentInput): Flow<BaseEvent> = flow {
client.sse(
urlString = endpointUrl,
request = {
method = HttpMethod.Post
contentType(ContentType.Application.Json)
accept(ContentType.Text.EventStream)
setBody(json.encodeToString<RunAgentInput>(input))
},
) {
incoming.collect { sse ->
sse.data?.let { emit(json.decodeFromString<BaseEvent>(it)) }
}
}
}
}
@@ -0,0 +1,26 @@
package com.agui.client
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
/**
* Creates an [HttpAgent] backed by a default CIO [HttpClient] with the SSE plugin installed
* and its lifecycle managed automatically.
*
* [configure] is applied to the client configuration; it runs before the SSE plugin is
* installed, so the plugin cannot be accidentally uninstalled. When you need full control
* over the [HttpClient] (custom engine, plugins, shared pools), construct [HttpAgent]
* directly instead.
*/
public fun createHttpAgent(
endpointUrl: String,
configure: HttpClientConfig<*>.() -> Unit = {},
): HttpAgent = HttpAgent(
client = HttpClient(CIO) {
configure()
install(SSE)
},
endpointUrl = endpointUrl,
)
@@ -0,0 +1,61 @@
package com.agui.client
import com.agui.api.agent.Agent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
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.server.agui
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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* Exercises [HttpAgent] against the real AG-UI endpoint exposed by [com.agui.server.agui],
* using the Ktor test host so the SSE transport is exercised end-to-end over HTTP.
*/
class HttpAgentTest {
private val agent: Agent = Agent { input ->
flow {
emit(RunStartedEvent(threadId = input.threadId, runId = input.runId))
val messageId = "m-${input.runId}"
emit(TextMessageStartEvent(messageId = messageId))
emit(TextMessageContentEvent(messageId = messageId, delta = "pong"))
emit(TextMessageEndEvent(messageId = messageId))
emit(RunFinishedEvent(threadId = input.threadId, runId = input.runId))
}
}
@Test
fun `run POSTs the input and decodes the SSE stream into typed events`() = 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 = "ping")),
)
val events = httpAgent.run(input).toList()
assertEquals(5, events.size)
assertEquals(RunStartedEvent(threadId = "t1", runId = "r1"), events.first())
assertIs<TextMessageStartEvent>(events[1])
assertIs<TextMessageContentEvent>(events[2]).let { assertEquals("pong", it.delta) }
assertEquals(RunFinishedEvent(threadId = "t1", runId = "r1"), events.last())
}
}
}