diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ea8a8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +.idea/ +*.iml +.kotlin/ +.kotlinbin/ +local.properties +.cortexkit/ \ No newline at end of file diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..56cbd68 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(21) + jvm() + macosX64() + macosArm64() + iosX64() + iosArm64() + iosSimulatorArm64() + linuxX64() + linuxArm64() + mingwX64() + + sourceSets { + commonMain.dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} \ No newline at end of file diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/agent/AbstractAgent.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/AbstractAgent.kt new file mode 100644 index 0000000..f450170 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/AbstractAgent.kt @@ -0,0 +1,51 @@ +package pw.binom.agui.api.agent + +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.message.Message +import pw.binom.agui.api.run.RunAgentInput +import pw.binom.agui.api.run.RunAgentParameters +import pw.binom.agui.api.run.randomRunId +import pw.binom.agui.api.run.toParameters +import pw.binom.agui.api.run.toRunAgentInput +import pw.binom.agui.api.state.State +import pw.binom.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 = mutableListOf() + + /** Subclasses produce the event stream for a single run. */ + public abstract fun runAgent(parameters: RunAgentParameters): Flow + + /** Adapts [input] to [runAgent], binding the run to *this* agent's identity and state. */ + final override fun run(input: RunAgentInput): Flow = + 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, + ) +} \ No newline at end of file diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/agent/Agent.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/Agent.kt new file mode 100644 index 0000000..d0e597b --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/Agent.kt @@ -0,0 +1,21 @@ +package pw.binom.agui.api.agent + +import pw.binom.agui.api.event.BaseEvent +import pw.binom.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 — [pw.binom.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 +} \ No newline at end of file diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/agent/AgentSubscriber.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/AgentSubscriber.kt new file mode 100644 index 0000000..b575c64 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/AgentSubscriber.kt @@ -0,0 +1,85 @@ +package pw.binom.agui.api.agent + +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.event.CustomEvent +import pw.binom.agui.api.event.MessagesSnapshotEvent +import pw.binom.agui.api.event.RawEvent +import pw.binom.agui.api.event.RunErrorEvent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.StateDeltaEvent +import pw.binom.agui.api.event.StateSnapshotEvent +import pw.binom.agui.api.event.StepFinishedEvent +import pw.binom.agui.api.event.StepStartedEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.agui.api.event.ToolCallArgsEvent +import pw.binom.agui.api.event.ToolCallEndEvent +import pw.binom.agui.api.event.ToolCallResultEvent +import pw.binom.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) { + } +} \ No newline at end of file diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/agent/EventDispatcher.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/EventDispatcher.kt new file mode 100644 index 0000000..064e492 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/agent/EventDispatcher.kt @@ -0,0 +1,56 @@ +package pw.binom.agui.api.agent + +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.event.CustomEvent +import pw.binom.agui.api.event.MessagesSnapshotEvent +import pw.binom.agui.api.event.RawEvent +import pw.binom.agui.api.event.RunErrorEvent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.StateDeltaEvent +import pw.binom.agui.api.event.StateSnapshotEvent +import pw.binom.agui.api.event.StepFinishedEvent +import pw.binom.agui.api.event.StepStartedEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.agui.api.event.ToolCallArgsEvent +import pw.binom.agui.api.event.ToolCallEndEvent +import pw.binom.agui.api.event.ToolCallResultEvent +import pw.binom.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.dispatchTo(subscriber: AgentSubscriber) { + collect { event -> EventDispatcher.dispatch(event, subscriber) } +} \ No newline at end of file diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/context/Context.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/context/Context.kt new file mode 100644 index 0000000..9df3e76 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/context/Context.kt @@ -0,0 +1,14 @@ +package pw.binom.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, +) diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/event/BaseEvent.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/event/BaseEvent.kt new file mode 100644 index 0000000..c13e632 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/event/BaseEvent.kt @@ -0,0 +1,173 @@ +package pw.binom.agui.api.event + +import pw.binom.agui.api.message.Message +import pw.binom.agui.api.message.MessageRole +import pw.binom.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. + */ +@Serializable +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, + 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 diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/message/Message.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/message/Message.kt new file mode 100644 index 0000000..eb26df7 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/message/Message.kt @@ -0,0 +1,48 @@ +package pw.binom.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 = 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 = "{}", +) diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/run/Run.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/run/Run.kt new file mode 100644 index 0000000..5f70caa --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/run/Run.kt @@ -0,0 +1,66 @@ +package pw.binom.agui.api.run + +import pw.binom.agui.api.context.Context +import pw.binom.agui.api.message.Message +import pw.binom.agui.api.state.State +import pw.binom.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 = emptyList(), + val tools: List = emptyList(), + val context: List = emptyList(), + val forwardedProps: Map = 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 = emptyList(), + val tools: List = emptyList(), + val messages: List = emptyList(), + val forwardedProps: Map = 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) diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/serialization/AguiJson.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/serialization/AguiJson.kt new file mode 100644 index 0000000..3cc28d5 --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/serialization/AguiJson.kt @@ -0,0 +1,66 @@ +package pw.binom.agui.api.serialization + +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.event.CustomEvent +import pw.binom.agui.api.event.MessagesSnapshotEvent +import pw.binom.agui.api.event.RawEvent +import pw.binom.agui.api.event.RunErrorEvent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.StateDeltaEvent +import pw.binom.agui.api.event.StateSnapshotEvent +import pw.binom.agui.api.event.StepFinishedEvent +import pw.binom.agui.api.event.StepStartedEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.agui.api.event.ToolCallArgsEvent +import pw.binom.agui.api.event.ToolCallEndEvent +import pw.binom.agui.api.event.ToolCallResultEvent +import pw.binom.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 +} diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/state/State.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/state/State.kt new file mode 100644 index 0000000..77f4cdd --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/state/State.kt @@ -0,0 +1,19 @@ +package pw.binom.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 [pw.binom.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 + +/** Mutable form of [State] used while an agent builds up its own state. */ +public typealias MutableState = MutableMap + +/** Builds a [MutableState] from the given `key to json` pairs. */ +public fun mutableState(vararg pairs: Pair): MutableState = + if (pairs.isEmpty()) linkedMapOf() else linkedMapOf(*pairs) diff --git a/api/src/commonMain/kotlin/pw/binom/agui/api/tool/Tool.kt b/api/src/commonMain/kotlin/pw/binom/agui/api/tool/Tool.kt new file mode 100644 index 0000000..afd5f1a --- /dev/null +++ b/api/src/commonMain/kotlin/pw/binom/agui/api/tool/Tool.kt @@ -0,0 +1,17 @@ +package pw.binom.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()), +) diff --git a/api/src/commonTest/kotlin/pw/binom/agui/api/AgentTest.kt b/api/src/commonTest/kotlin/pw/binom/agui/api/AgentTest.kt new file mode 100644 index 0000000..15e6087 --- /dev/null +++ b/api/src/commonTest/kotlin/pw/binom/agui/api/AgentTest.kt @@ -0,0 +1,91 @@ +package pw.binom.agui.api + +import pw.binom.agui.api.agent.AbstractAgent +import pw.binom.agui.api.agent.AgentSubscriber +import pw.binom.agui.api.agent.EventDispatcher +import pw.binom.agui.api.agent.dispatchTo +import pw.binom.agui.api.context.Context +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.event.CustomEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.run.RunAgentInput +import pw.binom.agui.api.run.RunAgentParameters +import pw.binom.agui.api.run.randomRunId +import pw.binom.agui.api.run.toParameters +import pw.binom.agui.api.run.toRunAgentInput +import pw.binom.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() + val sub = object : AgentSubscriber { + override fun onRunStarted(event: RunStartedEvent) { + seen += "started" + } + + override fun onCustom(event: CustomEvent) { + seen += "custom" + } + } + flowOf( + 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()) + } +} \ No newline at end of file diff --git a/api/src/commonTest/kotlin/pw/binom/agui/api/EventSerializationTest.kt b/api/src/commonTest/kotlin/pw/binom/agui/api/EventSerializationTest.kt new file mode 100644 index 0000000..d52e6eb --- /dev/null +++ b/api/src/commonTest/kotlin/pw/binom/agui/api/EventSerializationTest.kt @@ -0,0 +1,87 @@ +package pw.binom.agui.api + +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.event.CustomEvent +import pw.binom.agui.api.event.MessagesSnapshotEvent +import pw.binom.agui.api.event.RawEvent +import pw.binom.agui.api.event.RunErrorEvent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.StateDeltaEvent +import pw.binom.agui.api.event.StateSnapshotEvent +import pw.binom.agui.api.event.StepFinishedEvent +import pw.binom.agui.api.event.StepStartedEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.agui.api.event.ToolCallArgsEvent +import pw.binom.agui.api.event.ToolCallEndEvent +import pw.binom.agui.api.event.ToolCallResultEvent +import pw.binom.agui.api.event.ToolCallStartEvent +import pw.binom.agui.api.message.Message +import pw.binom.agui.api.message.MessageRole +import pw.binom.agui.api.serialization.AguiJson +import pw.binom.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 = 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(event) + val decoded = AguiJson.decodeFromString(json) + assertEquals(event, decoded, "round-trip failed for $event") + } + } + + @Test + fun `events are discriminated by their AG-UI type name`() { + val json = AguiJson.encodeToString(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(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(wire) + assertEquals(RunErrorEvent(message = "boom"), event) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..886c1ef --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false +} + +allprojects { + group = "pw.binom.agui" + version = "0.1.0-SNAPSHOT" +} \ No newline at end of file diff --git a/client/build.gradle.kts b/client/build.gradle.kts new file mode 100644 index 0000000..732cd76 --- /dev/null +++ b/client/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(21) + jvm() + macosX64() + macosArm64() + iosX64() + iosArm64() + iosSimulatorArm64() + linuxX64() + linuxArm64() + mingwX64() + + 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) + } + nativeMain.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) + } + } +} \ No newline at end of file diff --git a/client/src/commonMain/kotlin/pw/binom/agui/client/HttpAgent.kt b/client/src/commonMain/kotlin/pw/binom/agui/client/HttpAgent.kt new file mode 100644 index 0000000..4035dbc --- /dev/null +++ b/client/src/commonMain/kotlin/pw/binom/agui/client/HttpAgent.kt @@ -0,0 +1,48 @@ +package pw.binom.agui.client + +import pw.binom.agui.api.agent.Agent +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.run.RunAgentInput +import pw.binom.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 = flow { + client.sse( + urlString = endpointUrl, + request = { + method = HttpMethod.Post + contentType(ContentType.Application.Json) + accept(ContentType.Text.EventStream) + setBody(json.encodeToString(input)) + }, + ) { + incoming.collect { sse -> + sse.data?.let { emit(json.decodeFromString(it)) } + } + } + } +} \ No newline at end of file diff --git a/client/src/jvmMain/kotlin/pw/binom/agui/client/HttpAgentFactory.kt b/client/src/jvmMain/kotlin/pw/binom/agui/client/HttpAgentFactory.kt new file mode 100644 index 0000000..669b9a0 --- /dev/null +++ b/client/src/jvmMain/kotlin/pw/binom/agui/client/HttpAgentFactory.kt @@ -0,0 +1,26 @@ +package pw.binom.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, +) \ No newline at end of file diff --git a/client/src/jvmTest/kotlin/pw/binom/agui/client/HttpAgentTest.kt b/client/src/jvmTest/kotlin/pw/binom/agui/client/HttpAgentTest.kt new file mode 100644 index 0000000..632d642 --- /dev/null +++ b/client/src/jvmTest/kotlin/pw/binom/agui/client/HttpAgentTest.kt @@ -0,0 +1,61 @@ +package pw.binom.agui.client + +import pw.binom.agui.api.agent.Agent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.agui.api.message.Message +import pw.binom.agui.api.message.MessageRole +import pw.binom.agui.api.run.RunAgentInput +import pw.binom.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 [pw.binom.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(events[1]) + assertIs(events[2]).let { assertEquals("pong", it.delta) } + assertEquals(RunFinishedEvent(threadId = "t1", runId = "r1"), events.last()) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..7168719 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[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-cio = { module = "io.ktor:ktor-server-cio", 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" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 0000000..7a18d40 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(21) + jvm() + macosX64() + macosArm64() + iosX64() + iosArm64() + iosSimulatorArm64() + linuxX64() + linuxArm64() + mingwX64() + + 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) + } + nativeMain.dependencies { + implementation(libs.ktor.server.cio) + } + 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")) + } + } +} \ No newline at end of file diff --git a/server/src/commonMain/kotlin/pw/binom/agui/server/Agui.kt b/server/src/commonMain/kotlin/pw/binom/agui/server/Agui.kt new file mode 100644 index 0000000..95b638a --- /dev/null +++ b/server/src/commonMain/kotlin/pw/binom/agui/server/Agui.kt @@ -0,0 +1,81 @@ +package pw.binom.agui.server + +import pw.binom.agui.api.agent.Agent +import pw.binom.agui.api.event.BaseEvent +import pw.binom.agui.api.event.RunErrorEvent +import pw.binom.agui.api.run.RunAgentInput +import pw.binom.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(event)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + session.send(json.encodeToString(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) + } +} \ No newline at end of file diff --git a/server/src/jvmMain/kotlin/pw/binom/agui/server/demo/SampleServer.kt b/server/src/jvmMain/kotlin/pw/binom/agui/server/demo/SampleServer.kt new file mode 100644 index 0000000..a0a962d --- /dev/null +++ b/server/src/jvmMain/kotlin/pw/binom/agui/server/demo/SampleServer.kt @@ -0,0 +1,39 @@ +package pw.binom.agui.server.demo + +import pw.binom.agui.api.agent.Agent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.StateSnapshotEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.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) +} \ No newline at end of file diff --git a/server/src/jvmTest/kotlin/pw/binom/agui/server/AguiServerTest.kt b/server/src/jvmTest/kotlin/pw/binom/agui/server/AguiServerTest.kt new file mode 100644 index 0000000..032abea --- /dev/null +++ b/server/src/jvmTest/kotlin/pw/binom/agui/server/AguiServerTest.kt @@ -0,0 +1,81 @@ +package pw.binom.agui.server + +import pw.binom.agui.api.agent.Agent +import pw.binom.agui.api.event.RunErrorEvent +import pw.binom.agui.api.event.RunFinishedEvent +import pw.binom.agui.api.event.RunStartedEvent +import pw.binom.agui.api.event.StateSnapshotEvent +import pw.binom.agui.api.event.TextMessageContentEvent +import pw.binom.agui.api.event.TextMessageEndEvent +import pw.binom.agui.api.event.TextMessageStartEvent +import pw.binom.agui.api.message.Message +import pw.binom.agui.api.message.MessageRole +import pw.binom.agui.api.run.RunAgentInput +import pw.binom.agui.api.state.mutableState +import pw.binom.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().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) + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..751f727 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "agui" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +include(":api", ":client", ":server") \ No newline at end of file