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
+8
View File
@@ -0,0 +1,8 @@
.gradle/
build/
.idea/
*.iml
.kotlin/
.kotlinbin/
local.properties
.cortexkit/
+20
View File
@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,51 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.message.Message
import com.agui.api.run.RunAgentInput
import com.agui.api.run.RunAgentParameters
import com.agui.api.run.randomRunId
import com.agui.api.run.toParameters
import com.agui.api.run.toRunAgentInput
import com.agui.api.state.State
import com.agui.api.state.mutableState
import kotlinx.coroutines.flow.Flow
/**
* Convenience base class for building a stateful, local [Agent].
*
* It owns the agent's [agentId], [threadId], a mutable [state] and its own [messages],
* and adapts the wire-level [run] to the higher-level [runAgent] taking only
* [RunAgentParameters].
*
* Subclasses override [runAgent]; typically they call [toRunAgentInput] to obtain the full
* [RunAgentInput] for the run and return a flow of events.
*/
public abstract class AbstractAgent(
public val agentId: String,
public val threadId: String,
) : Agent {
/** The agent's mutable state, exposed as an immutable [State] view. */
public val state: State = mutableState()
/** The agent's own running message list, merged into the input if none is supplied. */
public val messages: MutableList<Message> = mutableListOf()
/** Subclasses produce the event stream for a single run. */
public abstract fun runAgent(parameters: RunAgentParameters): Flow<BaseEvent>
/** Adapts [input] to [runAgent], binding the run to *this* agent's identity and state. */
final override fun run(input: RunAgentInput): Flow<BaseEvent> =
runAgent(input.toParameters())
/** Builds a [RunAgentInput] from [parameters], this agent's [threadId] and [state]. */
public fun toRunAgentInput(
parameters: RunAgentParameters,
state: State = this.state,
): RunAgentInput = parameters.toRunAgentInput(
threadId = threadId,
runId = parameters.runId ?: randomRunId(),
state = state,
)
}
@@ -0,0 +1,21 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.run.RunAgentInput
import kotlinx.coroutines.flow.Flow
/**
* The core AG-UI abstraction: an entity that, given a complete [RunAgentInput], produces the
* agent's stream of [BaseEvent]s.
*
* This is the network-agnostic heart of the protocol. A concrete [Agent] can be:
*
* - remote — [com.agui.client.HttpAgent] streams events over SSE;
* - local — a real agent implementation;
* - composed/decorated — logging, auth, retry, observability wrappers.
*/
public fun interface Agent {
/** Runs the agent for [input] and returns the resulting event stream. */
public fun run(input: RunAgentInput): Flow<BaseEvent>
}
@@ -0,0 +1,85 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
/**
* Observer for a stream of [BaseEvent]s.
*
* Every method is a no-op by default, so implementations only override the events
* they care about. Dispatch a [BaseEvent] to this subscriber with
* [EventDispatcher.dispatch], or collect a whole flow with `dispatchTo`.
*/
public interface AgentSubscriber {
/** Called for every event, before the type-specific callback. */
public fun onEvent(event: BaseEvent) {
}
public fun onRunStarted(event: RunStartedEvent) {
}
public fun onRunFinished(event: RunFinishedEvent) {
}
public fun onRunError(event: RunErrorEvent) {
}
public fun onStepStarted(event: StepStartedEvent) {
}
public fun onStepFinished(event: StepFinishedEvent) {
}
public fun onTextMessageStart(event: TextMessageStartEvent) {
}
public fun onTextMessageContent(event: TextMessageContentEvent) {
}
public fun onTextMessageEnd(event: TextMessageEndEvent) {
}
public fun onToolCallStart(event: ToolCallStartEvent) {
}
public fun onToolCallArgs(event: ToolCallArgsEvent) {
}
public fun onToolCallEnd(event: ToolCallEndEvent) {
}
public fun onToolCallResult(event: ToolCallResultEvent) {
}
public fun onStateSnapshot(event: StateSnapshotEvent) {
}
public fun onStateDelta(event: StateDeltaEvent) {
}
public fun onMessagesSnapshot(event: MessagesSnapshotEvent) {
}
public fun onRaw(event: RawEvent) {
}
public fun onCustom(event: CustomEvent) {
}
}
@@ -0,0 +1,56 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
import kotlinx.coroutines.flow.Flow
/**
* Routes each [BaseEvent] to the matching [AgentSubscriber] callback.
*/
public object EventDispatcher {
/** Dispatches a single [event] to [subscriber]: always [AgentSubscriber.onEvent], then the typed callback. */
public fun dispatch(event: BaseEvent, subscriber: AgentSubscriber) {
subscriber.onEvent(event)
when (event) {
is RunStartedEvent -> subscriber.onRunStarted(event)
is RunFinishedEvent -> subscriber.onRunFinished(event)
is RunErrorEvent -> subscriber.onRunError(event)
is StepStartedEvent -> subscriber.onStepStarted(event)
is StepFinishedEvent -> subscriber.onStepFinished(event)
is TextMessageStartEvent -> subscriber.onTextMessageStart(event)
is TextMessageContentEvent -> subscriber.onTextMessageContent(event)
is TextMessageEndEvent -> subscriber.onTextMessageEnd(event)
is ToolCallStartEvent -> subscriber.onToolCallStart(event)
is ToolCallArgsEvent -> subscriber.onToolCallArgs(event)
is ToolCallEndEvent -> subscriber.onToolCallEnd(event)
is ToolCallResultEvent -> subscriber.onToolCallResult(event)
is StateSnapshotEvent -> subscriber.onStateSnapshot(event)
is StateDeltaEvent -> subscriber.onStateDelta(event)
is MessagesSnapshotEvent -> subscriber.onMessagesSnapshot(event)
is RawEvent -> subscriber.onRaw(event)
is CustomEvent -> subscriber.onCustom(event)
}
}
}
/** Collects [this] flow and dispatches each event to [subscriber]. */
public suspend fun Flow<BaseEvent>.dispatchTo(subscriber: AgentSubscriber) {
collect { event -> EventDispatcher.dispatch(event, subscriber) }
}
@@ -0,0 +1,14 @@
package com.agui.api.context
import kotlinx.serialization.Serializable
/**
* A single piece of context supplied for a run. Mirrors the AG-UI `Context` type.
*
* [description] is a human-readable label; [value] carries the opaque payload.
*/
@Serializable
public data class Context(
val description: String,
val value: String,
)
@@ -0,0 +1,172 @@
package com.agui.api.event
import com.agui.api.message.Message
import com.agui.api.message.MessageRole
import com.agui.api.state.State
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Root of the AG-UI event stream.
*
* AG-UI is a push protocol: an agent streams [BaseEvent]s to a client. The concrete
* subclasses mirror the AG-UI protocol event catalogue and are (de)serialized
* polymorphically by the `type` discriminator field.
*/
public sealed interface BaseEvent {
/** Event creation timestamp in epoch milliseconds, if provided. */
public val timestamp: Long?
}
// region Lifecycle
@Serializable
@SerialName("RUN_STARTED")
public data class RunStartedEvent(
val threadId: String,
val runId: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("RUN_FINISHED")
public data class RunFinishedEvent(
val threadId: String? = null,
val runId: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("RUN_ERROR")
public data class RunErrorEvent(
val message: String,
val code: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("STEP_STARTED")
public data class StepStartedEvent(
val stepName: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("STEP_FINISHED")
public data class StepFinishedEvent(
val stepName: String,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region Text message
@Serializable
@SerialName("TEXT_MESSAGE_START")
public data class TextMessageStartEvent(
val messageId: String,
val role: MessageRole = MessageRole.ASSISTANT,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TEXT_MESSAGE_CONTENT")
public data class TextMessageContentEvent(
val messageId: String,
val delta: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TEXT_MESSAGE_END")
public data class TextMessageEndEvent(
val messageId: String,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region Tool call
@Serializable
@SerialName("TOOL_CALL_START")
public data class ToolCallStartEvent(
val toolCallId: String,
val toolCallName: String,
val parentMessageId: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TOOL_CALL_ARGS")
public data class ToolCallArgsEvent(
val toolCallId: String,
val delta: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TOOL_CALL_END")
public data class ToolCallEndEvent(
val toolCallId: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TOOL_CALL_RESULT")
public data class ToolCallResultEvent(
val toolCallId: String,
val messageId: String? = null,
val role: MessageRole = MessageRole.TOOL,
val content: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region State / messages
@Serializable
@SerialName("STATE_SNAPSHOT")
public data class StateSnapshotEvent(
val snapshot: State,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("STATE_DELTA")
public data class StateDeltaEvent(
val delta: JsonElement,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("MESSAGES_SNAPSHOT")
public data class MessagesSnapshotEvent(
val messages: List<Message>,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region Special
@Serializable
@SerialName("RAW")
public data class RawEvent(
val event: JsonElement,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("CUSTOM")
public data class CustomEvent(
val name: String,
val value: JsonElement? = null,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
@@ -0,0 +1,48 @@
package com.agui.api.message
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A message role. Serialized using the AG-UI wire value (e.g. `"assistant"`, `"tool"`).
*/
@Serializable
public enum class MessageRole {
@SerialName("developer") DEVELOPER,
@SerialName("system") SYSTEM,
@SerialName("assistant") ASSISTANT,
@SerialName("user") USER,
@SerialName("tool") TOOL,
}
/**
* A single chat message. Mirrors the AG-UI `Message` type, which is a base message
* optionally carrying tool calls (assistant) or a tool result id (tool).
*/
@Serializable
public data class Message(
val id: String,
val role: MessageRole,
val content: String = "",
val toolCalls: List<ToolCall> = emptyList(),
val toolCallId: String? = null,
)
/** A tool invocation requested by an assistant message. */
@Serializable
public data class ToolCall(
val id: String,
val type: String = "function",
val function: ToolFunction = ToolFunction(),
)
/** The function part of a [ToolCall]. */
@Serializable
public data class ToolFunction(
val name: String = "",
val arguments: String = "{}",
)
@@ -0,0 +1,66 @@
package com.agui.api.run
import com.agui.api.context.Context
import com.agui.api.message.Message
import com.agui.api.state.State
import com.agui.api.tool.Tool
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlin.random.Random
/**
* The complete request payload of an AG-UI run.
*
* This is exactly what a client POSTs to an agent and what a server receives.
*/
@Serializable
public data class RunAgentInput(
val threadId: String,
val runId: String,
val state: State = emptyMap(),
val messages: List<Message> = emptyList(),
val tools: List<Tool> = emptyList(),
val context: List<Context> = emptyList(),
val forwardedProps: Map<String, JsonElement> = emptyMap(),
)
/**
* Parameters a *caller* supplies for a single run, before it is turned into a
* [RunAgentInput]. Unlike [RunAgentInput] it carries no thread/identity — the agent fills
* those in.
*/
public data class RunAgentParameters(
val context: List<Context> = emptyList(),
val tools: List<Tool> = emptyList(),
val messages: List<Message> = emptyList(),
val forwardedProps: Map<String, JsonElement> = emptyMap(),
val runId: String? = null,
)
/** Builds a [RunAgentInput] from [parameters] plus an explicit thread id, run id and state. */
public fun RunAgentParameters.toRunAgentInput(
threadId: String,
runId: String = this.runId ?: randomRunId(),
state: State = emptyMap(),
): RunAgentInput = RunAgentInput(
threadId = threadId,
runId = runId,
state = state,
messages = messages,
tools = tools,
context = context,
forwardedProps = forwardedProps,
)
/** The inverse of [toRunAgentInput]: projects a [RunAgentInput] back to caller parameters. */
public fun RunAgentInput.toParameters(): RunAgentParameters = RunAgentParameters(
context = context,
tools = tools,
messages = messages,
forwardedProps = forwardedProps,
runId = runId,
)
/** Generates a reasonably-unique run identifier, suitable for the [RunAgentInput.runId]. */
public fun randomRunId(prefix: String = "run"): String =
prefix + "-" + Random.nextLong().toULong().toString(16)
@@ -0,0 +1,66 @@
package com.agui.api.serialization
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
/**
* Serializers module registering every [BaseEvent] subtype for polymorphic
* (de)serialization. A `sealed interface` does not auto-register its subtypes,
* so they are declared explicitly here.
*/
public val AguiEventsSerializersModule: SerializersModule = SerializersModule {
polymorphic(BaseEvent::class) {
subclass(RunStartedEvent::class, RunStartedEvent.serializer())
subclass(RunFinishedEvent::class, RunFinishedEvent.serializer())
subclass(RunErrorEvent::class, RunErrorEvent.serializer())
subclass(StepStartedEvent::class, StepStartedEvent.serializer())
subclass(StepFinishedEvent::class, StepFinishedEvent.serializer())
subclass(TextMessageStartEvent::class, TextMessageStartEvent.serializer())
subclass(TextMessageContentEvent::class, TextMessageContentEvent.serializer())
subclass(TextMessageEndEvent::class, TextMessageEndEvent.serializer())
subclass(ToolCallStartEvent::class, ToolCallStartEvent.serializer())
subclass(ToolCallArgsEvent::class, ToolCallArgsEvent.serializer())
subclass(ToolCallEndEvent::class, ToolCallEndEvent.serializer())
subclass(ToolCallResultEvent::class, ToolCallResultEvent.serializer())
subclass(StateSnapshotEvent::class, StateSnapshotEvent.serializer())
subclass(StateDeltaEvent::class, StateDeltaEvent.serializer())
subclass(MessagesSnapshotEvent::class, MessagesSnapshotEvent.serializer())
subclass(RawEvent::class, RawEvent.serializer())
subclass(CustomEvent::class, CustomEvent.serializer())
}
}
/**
* The canonical [Json] configuration used to (de)serialize the AG-UI wire protocol.
*
* - Polymorphic [BaseEvent]s are (de)serialized by their `type` discriminator
* field, with every subtype registered in [AguiEventsSerializersModule].
* - Unknown keys are ignored, so newer protocol versions stay backward compatible.
* - Null fields are omitted to keep the wire payload compact.
*/
public val AguiJson: Json = Json {
serializersModule = AguiEventsSerializersModule
classDiscriminator = "type"
ignoreUnknownKeys = true
explicitNulls = false
}
@@ -0,0 +1,19 @@
package com.agui.api.state
import kotlinx.serialization.json.JsonElement
/**
* A loosely-typed, platform-agnostic bag of agent state carried in AG-UI
* `STATE_SNAPSHOT`/`STATE_DELTA` events and in [com.agui.api.run.RunAgentInput.state].
*
* Because the value is a JSON element, it can be any JSON scalar, object or array — the
* protocol does not prescribe a concrete schema.
*/
public typealias State = Map<String, JsonElement>
/** Mutable form of [State] used while an agent builds up its own state. */
public typealias MutableState = MutableMap<String, JsonElement>
/** Builds a [MutableState] from the given `key to json` pairs. */
public fun mutableState(vararg pairs: Pair<String, JsonElement>): MutableState =
if (pairs.isEmpty()) linkedMapOf() else linkedMapOf(*pairs)
@@ -0,0 +1,17 @@
package com.agui.api.tool
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* A tool the agent can call during a run. Mirrors the AG-UI `Tool` type.
*
* [parameters] is a JSON Schema object describing the tool's arguments; it is kept as a
* [JsonObject] so any schema shape can be carried verbatim.
*/
@Serializable
public data class Tool(
val name: String,
val description: String = "",
val parameters: JsonObject = JsonObject(emptyMap()),
)
@@ -0,0 +1,91 @@
package com.agui.api
import com.agui.api.agent.AbstractAgent
import com.agui.api.agent.AgentSubscriber
import com.agui.api.agent.EventDispatcher
import com.agui.api.agent.dispatchTo
import com.agui.api.context.Context
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.run.RunAgentInput
import com.agui.api.run.RunAgentParameters
import com.agui.api.run.randomRunId
import com.agui.api.run.toParameters
import com.agui.api.run.toRunAgentInput
import com.agui.api.tool.Tool
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AgentTest {
@Test
fun `RunAgentParameters convert to RunAgentInput and back`() {
val params = RunAgentParameters(
runId = "r1",
context = listOf(Context(description = "c", value = "v")),
tools = listOf(Tool(name = "t")),
)
val input = params.toRunAgentInput(threadId = "t1")
assertEquals("t1", input.threadId)
assertEquals("r1", input.runId)
assertEquals(listOf(Context(description = "c", value = "v")), input.context)
assertEquals(params, input.toParameters())
}
@Test
fun `randomRunId is prefixed and non-empty`() {
val id = randomRunId()
val (prefix, rest) = id.split('-', limit = 2)
assertEquals("run", prefix)
assertTrue(rest.isNotEmpty(), "id should have a suffix, got: $id")
}
@Test
fun `EventDispatcher routes to the matching subscriber callback`() = runTest {
val seen = mutableListOf<String>()
val sub = object : AgentSubscriber {
override fun onRunStarted(event: RunStartedEvent) {
seen += "started"
}
override fun onCustom(event: CustomEvent) {
seen += "custom"
}
}
flowOf<BaseEvent>(
RunStartedEvent(threadId = "t1", runId = "r1"),
CustomEvent(name = "x"),
RunStartedEvent(threadId = "t1", runId = "r1"),
).dispatchTo(sub)
assertEquals(listOf("started", "custom", "started"), seen)
}
@Test
fun `onEvent is called for every dispatched event`() {
var count = 0
val sub = object : AgentSubscriber {
override fun onEvent(event: BaseEvent) {
count++
}
}
EventDispatcher.dispatch(RunStartedEvent(threadId = "t1", runId = "r1"), sub)
EventDispatcher.dispatch(CustomEvent(name = "x"), sub)
assertEquals(2, count)
}
@Test
fun `AbstractAgent binds a run to its own identity`() = runTest {
val agent = object : AbstractAgent(agentId = "calculator", threadId = "thread-1") {
override fun runAgent(parameters: RunAgentParameters) = flowOf(
RunStartedEvent(threadId = threadId, runId = parameters.runId ?: "?"),
)
}
val events = agent.run(RunAgentInput(threadId = "other-thread", runId = "r9")).toList()
assertEquals(RunStartedEvent(threadId = "thread-1", runId = "r9"), events.single())
}
}
@@ -0,0 +1,87 @@
package com.agui.api
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
import com.agui.api.message.Message
import com.agui.api.message.MessageRole
import com.agui.api.serialization.AguiJson
import com.agui.api.state.mutableState
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EventSerializationTest {
private val events: List<BaseEvent> = listOf(
RunStartedEvent(threadId = "t1", runId = "r1", timestamp = 1L),
RunFinishedEvent(threadId = "t1", runId = "r1"),
RunErrorEvent(message = "boom", code = "E1"),
StepStartedEvent(stepName = "step1"),
StepFinishedEvent(stepName = "step1"),
TextMessageStartEvent(messageId = "m1", role = MessageRole.USER),
TextMessageContentEvent(messageId = "m1", delta = "hello"),
TextMessageEndEvent(messageId = "m1"),
ToolCallStartEvent(toolCallId = "tc1", toolCallName = "getWeather", parentMessageId = "m1"),
ToolCallArgsEvent(toolCallId = "tc1", delta = """{"city":""""),
ToolCallEndEvent(toolCallId = "tc1"),
ToolCallResultEvent(toolCallId = "tc1", messageId = "r1", content = "20C"),
StateSnapshotEvent(snapshot = mutableState("a" to JsonPrimitive(1))),
StateDeltaEvent(delta = buildJsonObject { put("a", 2) }),
MessagesSnapshotEvent(messages = listOf(Message(id = "m0", role = MessageRole.ASSISTANT, content = "hi"))),
RawEvent(event = buildJsonObject { put("anything", true) }),
CustomEvent(name = "my.custom", value = buildJsonArray { add(JsonPrimitive("x")) }),
)
@Test
fun `every event round-trips through the wire JSON`() {
for (event in events) {
val json = AguiJson.encodeToString<BaseEvent>(event)
val decoded = AguiJson.decodeFromString<BaseEvent>(json)
assertEquals(event, decoded, "round-trip failed for $event")
}
}
@Test
fun `events are discriminated by their AG-UI type name`() {
val json = AguiJson.encodeToString<BaseEvent>(RunStartedEvent(threadId = "t1", runId = "r1"))
assertTrue(json.contains("\"type\":\"RUN_STARTED\""), json)
assertTrue(json.contains("\"threadId\":\"t1\""), json)
assertTrue(json.contains("\"runId\":\"r1\""), json)
}
@Test
fun `an AG-UI wire event decodes with defaults applied`() {
val wire = """{"type":"TEXT_MESSAGE_START","messageId":"m1"}"""
val event = AguiJson.decodeFromString<BaseEvent>(wire)
assertEquals(TextMessageStartEvent(messageId = "m1"), event)
}
@Test
fun `unknown fields are ignored for forward compatibility`() {
val wire = """{"type":"RUN_ERROR","message":"boom","failureDetails":{}}"""
val event = AguiJson.decodeFromString<BaseEvent>(wire)
assertEquals(RunErrorEvent(message = "boom"), event)
}
}
+9
View File
@@ -0,0 +1,9 @@
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
allprojects {
group = "com.agui"
version = "0.1.0-SNAPSHOT"
}
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
sourceSets {
commonMain.dependencies {
api(project(":api"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
jvmTest.dependencies {
implementation(libs.kotlinx.coroutines.test)
implementation(project(":server"))
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.server.sse)
}
}
}
@@ -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())
}
}
}
+25
View File
@@ -0,0 +1,25 @@
[versions]
kotlin = "2.2.0"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.8.1"
ktor = "3.1.3"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
sourceSets {
commonMain.dependencies {
api(project(":api"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.sse)
}
jvmMain.dependencies {
implementation(libs.ktor.server.netty)
}
jvmTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(project(":client"))
}
}
}
@@ -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)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
rootProject.name = "agui"
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
include(":api", ":client", ":server")