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