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