Kotlin SDK¶
The Kotlin SDK is the library ankka-kotlin, a layer over the Java SDK. A Kotlin service is
hosted by ankka's runtime in the same JVM: there is no sidecar. Every Java type — the base classes, the
annotations, the clients, the effects — is used from Kotlin as it is, and the Kotlin library adds what
Kotlin's own form needs: data classes in the codec, OrNull forms of every Optional, suspending handlers
and calls, and blocks for effects, routes and tools. The Java SDK's page is the full list of its API; this
page says what is different in Kotlin. Your first service in Kotlin
builds one from the template.
Installing¶
The SDK is on Maven Central, published by every ankka release at the release's version:
| Artifact | Holds |
|---|---|
com.thinkmorestupidless:ankka-kotlin |
the Kotlin layer, bringing ankka-java with it |
com.thinkmorestupidless:ankka-kotlin-testkit |
the test kits' Kotlin forms, bringing ankka-java-testkit |
com.thinkmorestupidless:ankka-kotlin-agent |
agents, for a service that declares them |
The template's Gradle build, from ankka init cart --language kotlin, sets the two compiler options the SDK
needs, on JDK 21:
kotlin {
jvmToolchain(21)
compilerOptions {
javaParameters.set(true)
freeCompilerArgs.add("-Xjspecify-annotations=strict")
}
}
javaParameters keeps each parameter's name in the class file, which binds an endpoint's path parameters.
-Xjspecify-annotations=strict reads the Java SDK's nullness annotations as Kotlin types, so a null
passed where the SDK takes a value does not compile. Pin ankkaVersion in gradle.properties to the
platform you deploy to, and say the same in the descriptor's runtime.
Data classes¶
The codec reads a Kotlin data class by its primary constructor, as it reads a Java record, under the
rules every ankka language shares (Serialization). A sealed interface whose
cases are data classes or objects is a sum, its case written as "type" first; an object case has no
fields. An enum is {"type":"<CONSTANT>"}.
/** A quantity of one product in a cart. */
@Manifest("line-item")
data class LineItem(val productId: String, val name: String, val quantity: Int)
/** A cart: its lines, ordered by product, and whether it has been checked out. */
@Manifest("shopping-cart")
data class Cart(val cartId: String, val items: List<LineItem>, val checkedOut: Boolean) {
/** Adds [item], merging it into the line for the same product. */
fun addItem(item: LineItem): Cart {
val merged = items.find { it.productId == item.productId }?.let { item.copy(quantity = it.quantity + item.quantity) } ?: item
return copy(items = (without(item.productId) + merged).sortedBy { it.productId })
}
fun removeItem(productId: String) = copy(items = without(productId))
fun contains(productId: String) = items.any { it.productId == productId }
val totalQuantity: Int
get() = items.sumOf { it.quantity }
private fun without(productId: String) = items.filterNot { it.productId == productId }
companion object {
fun empty(cartId: String) = Cart(cartId, emptyList(), false)
}
}
/** What happened to a cart: the journal holds these, as the Scala and Java carts' journals do. */
@Manifest("shopping-cart-event")
sealed interface CartEvent {
data class ItemAdded(val item: LineItem) : CartEvent
data class ItemRemoved(val productId: String) : CartEvent
data object CheckedOut : CartEvent
data object Discarded : CartEvent
}
Reading follows the constructor: a field that is absent takes the parameter's default when it has one,
null when its type is nullable, and otherwise fails, naming it (is missing). Every constructor parameter
must be a property, val, so the codec can write it back, or the service does not start, naming the
parameter (declare it val). The refusals are the Java SDK's: Float, Short, Byte and Char, a map
whose key is not a String, a field named type, a generic data class, and any other class. @Manifest on
the type names its manifest; without it, the simple name in kebab case. kotlinx.serialization is not used;
for a type the codec refuses, hand the service a serializer with withSerializer, as in Java.
Nullability¶
A nullable property in a data class is how a Kotlin value says "none": it is written null and read from
null or from an absent field, where Java uses Optional. Where the Java SDK answers an Optional, the
Kotlin SDK has a form that answers a nullable: Request.principalOrNull, queryOrNull(name),
headerOrNull(name), Response.bodyOrNull, EntityContext.attributionOrNull, installationOrNull,
metadataOrNull(key), ViewQueries.getOrNull(key), ViewRows.getOrNull(key),
CommandException.workflowFailureOrNull, SecretStore.getOrNull(name), Bucket.getOrNull(name) and
the others in com.thinkmorestupidless.ankka.kotlin. A protected method of a base class —
principal() in an endpoint, rowState() in a view — keeps its Optional; .orNull() turns any
Optional into a nullable.
Where the Java SDK takes a value that may be absent, Kotlin passes null: ServiceClient.request takes a
nullable body and a nullable content type, for a request with none.
Suspension¶
A handler may be a suspend fun: an entity's or a workflow's @Command and @Query, a workflow's @Step,
an endpoint's annotated route, a timed action's @Action, a route in a routes { } block and an MCP tool's
handler. It runs on the handler's own virtual thread, so a suspension waiting for a reply holds no
platform thread, and to the platform it is the same handler as a plain one: the same wire name, the same
effects, the same failure. A view's or a consumer's onChange, and a keyed view's @Source handlers, are
plain functions.
The suspending forms of the calls:
| Call | Answers |
|---|---|
invocation.invokeSuspending(input), invokeSuspending() |
a component's reply |
viewQueries.getSuspending(key), allSuspending(limit), countSuspending(), askSuspending(query, values) |
a row or null, rows, a count |
viewQueries.askFlow(query, values) |
a cold Flow of rows, run once per collection |
serviceClient.getSuspending<R>(path), postSuspending(path, body), putSuspending(path, body), deleteSuspending(path) |
another service's answer |
flow.asPublisher() |
a Flow as the Flow.Publisher an SSE route returns |
The request context, the trace and the call's origin are thread-locals of the handler's thread. Stay on it:
a component called from inside withContext(Dispatchers.IO) is called with none of them. Return to the
handler's thread with withContext(AnkkaDispatcher) after work done elsewhere.
Effect blocks¶
Every component's effects { } takes the component's Java effects as its receiver, so a block builds
exactly the effect the chained form builds:
@Command("add-item")
suspend fun addItem(item: LineItem): Effect<Done> = effects { persist(CartEvent.ItemAdded(item)).thenDone() }
A block that ends in reply(...) or error(...) is a ReadOnlyEffect, which a @Query returns. A
workflow's steps build with stepEffects { }. thenReply { state -> ... } is infix on a persist and an
update, and transitionTo and thenTransitionTo take a Kotlin function reference, suspending or not:
updateState(s).transitionTo(Checkout::charge).
Method references and wire names¶
The wire name is the annotation's string, as in Java, and a handler is named in code by a function
reference: call(ShoppingCart::addItem), thenTransitionTo(Checkout::charge),
timers().schedule(name, delay, Reminder::remind, id). Renaming the function changes nothing on the wire.
A reference to a suspend fun is a KSuspendFunction, which the Java SDK's method-reference types do not
accept; EntityRef.call has a Kotlin form for one, and commandRef(...) and stepRef(...) make a
reference of either kind wherever one is taken. The declaration checks at start are the Java SDK's.
Event sourced entity¶
| Part | API |
|---|---|
| Base class | EventSourcedEntity<S, E>, annotated @ComponentId |
| Must define | override fun applyEvent(state: S, event: E): S |
| May define | override fun emptyState(): S |
| Handlers | @Command, @Query on functions, plain or suspend, each taking one parameter or none |
| Effects | effects { persist(e).thenReply { state -> … } }, persist(...).thenDone(), deleteEntity(), reply(r), error(message), error(problem); the rest as in Java |
/** A customer's cart, one per cart id. Its handlers' wire names are the Scala cart's. */
@ComponentId("shopping-cart")
class ShoppingCart : EventSourcedEntity<Cart, CartEvent>() {
override fun emptyState() = Cart.empty(context().entityId())
// A suspend fun, as any handler may be: it waits without holding a thread.
@Command("add-item")
suspend fun addItem(item: LineItem): Effect<Done> = effects {
when {
currentState().checkedOut -> error(checkedOut)
item.quantity <= 0 -> error("quantity must be greater than zero, was ${item.quantity}")
else -> persist(CartEvent.ItemAdded(item)).thenDone()
}
}
@Command("remove-item")
fun removeItem(productId: String): Effect<Done> = effects {
when {
currentState().checkedOut -> error(checkedOut)
!currentState().contains(productId) -> error(Problem.notFound("cart does not contain '$productId'"))
else -> persist(CartEvent.ItemRemoved(productId)).thenDone()
}
}
@Command("checkout")
fun checkout(): Effect<Cart> = effects {
when {
currentState().checkedOut -> error(checkedOut)
currentState().items.isEmpty() -> error("cannot check out an empty cart")
else -> persist(CartEvent.CheckedOut).thenReplyState()
}
}
@Command("discard")
fun discard(): Effect<Done> = effects {
if (currentState().checkedOut) error(checkedOut) else persist(CartEvent.Discarded).thenDelete().thenDone()
}
@Query("get-cart")
fun getCart(): ReadOnlyEffect<Cart> = effects { reply(currentState()) }
@Query("total-quantity")
fun totalQuantity(): ReadOnlyEffect<Int> = effects { reply(currentState().totalQuantity) }
override fun applyEvent(state: Cart, event: CartEvent): Cart =
when (event) {
is CartEvent.ItemAdded -> state.addItem(event.item)
is CartEvent.ItemRemoved -> state.removeItem(event.productId)
CartEvent.CheckedOut -> state.copy(checkedOut = true)
CartEvent.Discarded -> state
}
private val checkedOut: Problem
get() = Problem.conflict("cart is already checked out")
}
A refusal is returned, never thrown. A handler that throws fails its command as a fault: nothing is
persisted, the caller receives 500 with internal error rather than your message, and a trace shows a
fault. Where a rule is broken, return error(...) from the block, with the Problem whose status says why.
See Error codes.
Key value entity¶
| Part | API |
|---|---|
| Base class | KeyValueEntity<S> |
| Effects | effects { updateState(s).thenReply(r) }, updateState(s).thenReply { state -> … }, deleteEntity(), reply(r), error(...) |
/** A key value entity holding a name. */
@ComponentId("profile")
class Profile : KeyValueEntity<Profile.ProfileState>() {
@Manifest("profile")
data class ProfileState(val name: String)
override fun emptyState(): ProfileState = ProfileState("")
@Command("set")
fun set(name: String): Effect<String> = effects {
if (name.isEmpty()) error("a name is needed") else updateState(ProfileState(name)).thenReply("done")
}
@Query("get")
fun get(): ReadOnlyEffect<String> = effects { reply(currentState().name.ifEmpty { "none" }) }
@Command("delete")
fun delete(): Effect<String> = effects { deleteEntity().thenReply("done") }
}
View¶
| Part | API |
|---|---|
| Base class | View<Src, Row> |
| Must define | source(); onChange(change) returning effects { updateRow(row) }, deleteRow() or ignore() |
| Declared queries | val properties of type ViewQuery in the companion object |
| Querying | viewClient().forView(View::class.java): getOrNull(key), all(), ask(query, values), and the suspending forms |
/**
* Every cart as a row, to answer what the entity cannot: which carts hold a product, which are
* checked out. The row's JSON is the Scala cart's `cart-row`.
*/
@ComponentId("cart-rows")
class CartRows : View<CartEvent, CartRows.CartRow>() {
@Manifest("cart-row")
data class CartRow(
val cartId: String,
val quantities: Map<String, Int>,
val checkedOut: Boolean,
val names: Map<String, String>,
)
override fun source(): ChangeSource<CartEvent> = ChangeSource.of(ShoppingCart::class.java)
override fun onChange(event: CartEvent): ViewEffect<CartRow> = effects {
val row = rowState().orNull() ?: CartRow(updateContext().subject(), emptyMap(), false, emptyMap())
when (event) {
is CartEvent.ItemAdded ->
updateRow(
row.copy(
quantities = row.quantities + (event.item.productId to (row.quantities[event.item.productId] ?: 0) + event.item.quantity),
names = row.names + (event.item.productId to event.item.name),
)
)
is CartEvent.ItemRemoved ->
updateRow(row.copy(quantities = row.quantities - event.productId, names = row.names - event.productId))
CartEvent.CheckedOut -> updateRow(row.copy(checkedOut = true))
// The deletion that follows removes the row: a discarded cart leaves the listing.
CartEvent.Discarded -> ignore()
}
}
}
A view with a recursive declared query, and a watchable one, in its companion object:
@ComponentId("tree-rows")
class TreeRows : View<TreeNode.TreeEvent, TreeRows.TreeRow>() {
@Manifest("tree-row")
data class TreeRow(val key: String, val under: String?)
override fun source(): ChangeSource<TreeNode.TreeEvent> = ChangeSource.of(TreeNode::class.java)
override fun onChange(event: TreeNode.TreeEvent): ViewEffect<TreeRow> = effects {
when (event) {
is TreeNode.TreeEvent.Placed -> updateRow(TreeRow(updateContext().subject(), event.under))
}
}
companion object {
private val TABLE = ViewQuery.tableOf(TreeRows::class.java)
/** Every row under the row `row`, to any depth, by key: the same in every language. */
val UNDER: ViewQuery =
ViewQuery.declare(
"under",
"""
WITH RECURSIVE below AS (
SELECT row_key, payload FROM $TABLE WHERE payload::jsonb->>'under' = :row
UNION
SELECT n.row_key, n.payload FROM $TABLE n JOIN below b ON n.payload::jsonb->>'under' = b.row_key
)
SELECT payload FROM below ORDER BY row_key
""".trimIndent(),
)
/** Every row, by key, watchable: the same statement in every language. */
val ALL_ROWS: ViewQuery = ViewQuery.declare("all-rows", "SELECT row_key, payload FROM $TABLE ORDER BY row_key").watched()
}
}
See Views.
Keyed view¶
KeyedView<Row>, with a function annotated @Source(...) for each source, taking the change and its
KeyedChange<Row>; effects { updateRow(key, row) } and the rest of the Java keyed view's effects.
@ComponentId("joined-rows")
class JoinedRows : KeyedView<JoinedRows.JoinedRow>() {
/** A row the left writes under the key it names, holding a right entity's id. */
@Manifest("joined-row")
data class JoinedRow(val key: String, val holding: String, val notes: List<String>) {
fun noting(who: String): JoinedRow = copy(notes = notes + who)
}
/** The left names a row `key|holding`, and writes it from what it held, noting itself. */
@Source(JoinedLeft::class)
fun onLeft(event: Noted, change: KeyedChange<JoinedRow>): KeyedViewEffect<JoinedRow> {
val (key, holding) = event.text.split("|", limit = 2)
val held = change.rows().getOrNull(key)?.notes ?: emptyList()
return effects { updateRow(key, JoinedRow(key, holding, held).noting("left")) }
}
/** The right finds every row holding it by asking the view's own query, and notes itself. */
@Source(JoinedRight::class)
fun onRight(event: Noted, change: KeyedChange<JoinedRow>): KeyedViewEffect<JoinedRow> {
val theirs = change.rows().ask(OF_RIGHT, mapOf("holding" to change.subject()))
return effects { updateRows(theirs.associate { it.key to it.noting("right") }) }
}
companion object {
/** The rows holding one right entity, by key: the same statement in every language. */
val OF_RIGHT: ViewQuery =
ViewQuery.declare(
"of-right",
"SELECT payload FROM ${ViewQuery.tableOf(JoinedRows::class.java)} WHERE payload::jsonb->>'holding' = :holding ORDER BY row_key",
)
}
}
Consumer¶
Consumer<Src, Out>, Out being Void for one that publishes nothing. publishes() keeps the Java
signature, an Optional<Publication>. Effects: effects { publish(message) }, publish(message, key),
publishAll(messages), publishMessages(outgoings), done(), ignore().
@ComponentId("checkout-fanout")
class CheckoutFanout : Consumer<CartEvent, Fanned>() {
override fun source(): ChangeSource<CartEvent> = ChangeSource.of(ShoppingCartEntity::class.java)
override fun publishes(): Optional<Publication> = Optional.of(Publication.to("conformance-fanout"))
override fun onChange(event: CartEvent): ConsumerEffect<Fanned> = effects {
when (event) {
is CartEvent.ItemAdded -> publishAll(emptyList())
is CartEvent.ItemRemoved -> publish(Fanned(0))
CartEvent.CheckedOut ->
publishMessages(
listOf(
Outgoing.of(Fanned(1)),
Outgoing.of(Fanned(2)).withKey("second:${messageContext().subject()}"),
Outgoing.of(Fanned(3)).withMetadata("x-n", "3"),
)
)
CartEvent.Discarded -> ignore()
}
}
}
Delivery is at least once, so what a handler does must be safe to do twice. See Consumers.
Graph consumer¶
GraphConsumer<Src>, with source(), topic(), and onChange building its deltas in an effects { }
block from graph()'s nodes, edges and tombstones.
@ComponentId("cart-graph")
class CartGraph : GraphConsumer<CartEvent>() {
override fun source(): ChangeSource<CartEvent> = ChangeSource.of(ShoppingCartEntity::class.java)
override fun topic(): String = "conformance-graph"
private fun cart(id: String, checkedOut: Boolean): GraphElement =
graph().node("cart:$id", listOf("Cart"), mapOf("cartId" to id, "checkedOut" to checkedOut))
override fun onChange(event: CartEvent): GraphEffect = effects {
val id = messageContext().subject()
when (event) {
is CartEvent.ItemAdded, is CartEvent.ItemRemoved -> publish(cart(id, false))
CartEvent.CheckedOut ->
publish(
cart(id, true),
graph().node("checkout:$id", listOf("Checkout"), mapOf("cartId" to id)),
graph().edge("checked-out:$id", "CHECKED_OUT", "cart:$id", "checkout:$id", emptyMap()),
)
CartEvent.Discarded -> ignore()
}
}
override fun onDelete(): GraphEffect = effects { publish(graph().tombstoneNode("cart:${messageContext().subject()}")) }
}
See Publish a graph.
Workflow¶
Workflow<S>, its commands building with effects { } and its steps, each annotated @Step and plain or
suspend, with stepEffects { }. A suspending step that waits for a component holds no thread while it
waits. Settings and recovery are the Java SDK's WorkflowSettings and RecoverStrategy, naming a step
with stepRef(Workflow::step).
/** A checkout carried out as a workflow: what it is checking out, and what the cart held once it was. */
@Manifest("checkout")
data class Checkout(val cartId: String, val ordered: Cart?)
/**
* Checks a cart out in a step. The step is a suspend fun: while it waits for the cart's answer the
* workflow holds no thread.
*/
@ComponentId("checkout")
class CheckoutWorkflow : Workflow<Checkout>() {
@Command("start")
fun start(cartId: String): Effect<Done> = effects {
updateState(Checkout(cartId, null)).transitionTo(CheckoutWorkflow::checkOut).thenDone()
}
@Query("get")
fun get(): ReadOnlyEffect<Checkout> = effects { reply(currentState()) }
@Step("check-out")
suspend fun checkOut(): StepEffect {
val ordered =
context().componentClient().forEventSourcedEntity(currentState().cartId).call(ShoppingCart::checkout).invokeSuspending()
return stepEffects { updateState(currentState().copy(ordered = ordered)).thenEnd() }
}
}
See Workflows.
Timed action and timers¶
TimedAction, with a function annotated @Action for each handler, plain or suspend, returning
effects { done() }, or error(...), which has the timer retried.
@ComponentId("reminder")
class Reminder : TimedAction() {
@Action("remind")
fun remind(id: String): TimedActionEffect {
record(id, "reminded")
return effects { done() }
}
/** Records the due time it was run for, as the runtime told it. */
@Action("tick")
fun tick(id: String): TimedActionEffect {
record(id, "due:${context().dueTime().toEpochMilli()}")
return effects { done() }
}
private fun record(id: String, what: String) {
context().componentClient().forEventSourcedEntity(id).call(ConformanceEntity::record).invoke(what)
}
}
Scheduling is timers().schedule(name, delay, Reminder::remind, input), as in Java. See
Timers.
Agent¶
Agents are the Java SDK's (Agent, AutonomousAgent, the agents module, every builder and client in
the Java SDK), with ankka-kotlin-agent's extensions in
com.thinkmorestupidless.ankka.kotlin.agent so that they read as Kotlin:
| Part | Kotlin form |
|---|---|
| Handlers | @Command and @Stream on a suspend fun or a plain one, answering AgentEffect<R> or StreamEffect |
| Effects | effects { systemMessage(t).userMessage(t)…thenReply() }; thenReply<R>() reads the answer as R, a data class or any type the SDK encodes; tools<T>() offers the @Tool methods of T, built with its constructor that takes nothing |
| Tools | a @Tool method may be a suspend fun; a lone data class parameter's properties are the arguments, each described by @Describe on its constructor parameter |
| Calling | componentClient().forAgent(sessionId).call(Agent::handler).invokeSuspending(input); .ask(Agent::handler) for the outcome; .streamFlow(streamRef(Agent::handler), input), a Flow<String>; agentRef(Agent::handler) names a handler wherever the Java API takes a reference, a plain fun handler's included |
| Autonomous agents | a task type's result is a data class; AutonomousRef.notificationsFlow(), a Flow<Notification> |
An agent written with suspend handlers and a suspend tool taking a data class:
data class Place(@Describe("the city to forecast") val city: String, val days: Int = 1)
/** A tool class whose tool is a suspend fun taking a data class, built by its own constructor. */
class WeatherTools {
@Tool("forecast", description = "The forecast for a city")
suspend fun forecast(place: Place): String {
delay(1)
ASKED.add("${place.city}/${place.days}")
return "${place.city}: 21C"
}
companion object {
val ASKED: MutableList<String> = CopyOnWriteArrayList()
}
}
/** An agent written as a Kotlin developer writes one: suspend handlers and the effects DSL. */
@ComponentId("forecaster")
class Forecaster : Agent() {
@Command("ask")
suspend fun ask(question: String): AgentEffect<String> {
delay(1)
return effects {
systemMessage("You answer questions about the weather.")
.userMessage(question)
.tools<WeatherTools>()
.thenReply()
}
}
@Command("structured")
fun structured(question: String): AgentEffect<Forecast> =
effects { userMessage(question).thenReply<Forecast>() }
}
The service gives the module its default model, as a Java service does:
fun guideService(model: ModelProvider): ServiceBuilder =
Ankka.service("guide")
.withModule(Agents.module().withModelProvider(model))
.register(Guide::class.java)
.register(Forecaster::class.java)
val reply = runBlocking {
kit.componentClient().forAgent("s-1").call(Forecaster::ask).invokeSuspending("How warm is Oslo?")
}
The Java SDK's AgentTestKit and TestModelProvider drive one turn with no runtime; agentRef names a
suspend handler to it:
val model =
TestModelProvider()
.expectToolCall("forecast", """{"city":"Oslo","days":2}""")
.expectText("Oslo will be 21C")
val kit = AgentTestKit.of(Forecaster::class.java, model)
val result = kit.call(agentRef(Forecaster::ask), "How warm is Oslo?")
assertEquals("Oslo will be 21C", result.reply())
assertEquals(listOf("Oslo/2"), WeatherTools.ASKED)
assertContains(model.lastRequest(), "Oslo: 21C")
See Agents.
Autonomous agent¶
A task type's result is a data class, whose properties are the schema the model is shown:
/** An answer, and the pages the agent read to reach it. */
data class Answer(val answer: String, val sources: List<String>)
/** `"answer"` is the wire name, written into every task of this type. */
val ANSWER: TaskType<Answer> =
Task.named("answer")
.describedAs("Answer a question from the guide, citing the pages you read")
.resultConformsTo(Answer::class.java)
.rule("cites-sources") {
if (it.sources.isEmpty()) RuleResult.rejected("say which pages you read in sources") else RuleResult.accepted()
}
/** The guide's pages, read by a suspending tool. */
class GuideTools {
@Tool("read_page", description = "Reads one page of the guide.")
suspend fun readPage(@Describe("The page's name.") page: String): String = "the $page page says: be brief"
}
/** Answers questions from the guide, on its own, until it completes the task with an [Answer]. */
@ComponentId("guide")
class Guide : AutonomousAgent() {
override fun definition(): AutonomousDefinition =
AutonomousDefinition.describedAs("Answers questions from the guide")
.instructions("Read the guide rather than guessing. Be brief.")
.guardrails(Guardrail.maxInputLength(2000))
.accepts(ANSWER, 5)
override fun tools(): List<Any> = listOf(GuideTools())
}
Tasks and instances are reached through the Java SDK's AgentClients: runSingleTask, tasks,
forTask and forAutonomousAgent, as the Java SDK lists them. In the
whole-service kit the scripted model is the module's default, and a task is worked to its end:
val model =
TestModelProvider()
.expectToolCall("read_page", """{"page":"style"}""")
.expectCompleteTask("""{"answer":"Be brief.","sources":["style"]}""")
AnkkaTestKit.start(guideService(model)).use { kit ->
val client = kit.componentClient()
val taskId = AgentClients.runSingleTask(client, Guide::class.java, ANSWER, "How should I write?")
val done = AgentClients.forTask(client, taskId).await(ANSWER, Duration.ofMinutes(1))
assertEquals(Answer("Be brief.", listOf("style")), done.result().orElseThrow())
}
See Autonomous agents.
Judgments, blueprints and model providers¶
Judgments, blueprints and model providers are the Java SDK's, unchanged: Question, effects { judgment()… },
Blueprint, BlueprintRegistry, ModelProviders and JudgmentProviders, as
the Java SDK lists them. A blueprint's handler is a Kotlin lambda,
BlueprintHandler.of("keep") { run, input -> … }. See Judgments,
Blueprints and Model providers.
HTTP endpoint¶
An endpoint is the Java SDK's HttpEndpoint, its routes declared either way. Annotated functions, plain or
suspend:
class CheckoutsSeenEndpoint : HttpEndpoint() {
override fun acl(): Acl = Acl.allowAll()
@Get("/checkouts-seen/{cartId}")
fun get(cartId: String): CheckoutsSeen.CheckoutSeen =
viewClient().forView(CheckoutsSeen::class.java).getOrNull(cartId)
?: throw ProblemException(Problem.notFound("no checkout of '$cartId' has been read"))
}
Or in code, by implementing RouteRegistrar and answering routes { }: get, delete and
post(template) { … }, and post<B>, put<B> and patch<B>(template) { body -> … } for a body read as
B. Each block is suspending, runs on the request's thread, and reads path(name) and request:
/** The cart over HTTP, route for route as the Scala cart serves it, declared in code. */
class CartEndpoint : HttpEndpoint(), RouteRegistrar {
override fun acl(): Acl = Acl.allowAll()
override fun routes() = routes {
get("/carts/{cartId}") { cart(path("cartId")).call(ShoppingCart::getCart).invokeSuspending() }
get("/carts/{cartId}/total") { cart(path("cartId")).call(ShoppingCart::totalQuantity).invokeSuspending() }
post<LineItem>("/carts/{cartId}/items") { item ->
cart(path("cartId")).call(ShoppingCart::addItem).invokeSuspending(item)
}
delete("/carts/{cartId}/items/{productId}") {
cart(path("cartId")).call(ShoppingCart::removeItem).invokeSuspending(path("productId"))
}
post("/carts/{cartId}/checkout") { cart(path("cartId")).call(ShoppingCart::checkout).invokeSuspending() }
delete("/carts/{cartId}") { cart(path("cartId")).call(ShoppingCart::discard).invokeSuspending() }
}
private fun cart(cartId: String): EntityRef = componentClient().forEventSourcedEntity(cartId)
}
An MCP endpoint's tools take handling<A> { args -> … }, and command and query take Kotlin function
references:
/**
* The cart as an MCP server at `/mcp`: a customer's assistant reads a cart, adds to it and checks
* it out through these tools rather than the cart's routes.
*/
class CartMcpEndpoint : McpEndpoint() {
data class AddItemArgs(
@Describe("The cart's id") val cartId: String,
@Describe("The product's id") val productId: String,
@Describe("How many; greater than zero") val quantity: Int,
)
init {
tool("get_cart").describedAs("Reads one cart: its lines, and whether it is checked out").query(ShoppingCart::getCart, "cartId")
tool("add_item").describedAs("Adds a quantity of a product to a cart").handling<AddItemArgs> { args ->
componentClient()
.forEventSourcedEntity(args.cartId)
.call(ShoppingCart::addItem)
.invokeSuspending(LineItem(args.productId, args.productId, args.quantity))
}
tool("checkout").describedAs("Checks a cart out, answering what it held").command(ShoppingCart::checkout, "cartId")
}
// A request carrying one of the cart's client keys, as the key's subject (CartKeysEndpoint).
override fun acl(): Acl = Acl.clientKeys()
}
gRPC endpoints are the Java SDK's GrpcEndpoint, over grpc-java's descriptors. See
HTTP endpoints and MCP endpoints.
Calling components¶
As in Java, with the suspending forms:
componentClient().forEventSourcedEntity(id).call(ShoppingCart::addItem).invokeSuspending(item). A refusal
is a CommandException; workflowFailureOrNull names a failed workflow's step. See
Calling components.
Configuration¶
The same ANKKA_* variables as every ankka service (Runtime configuration); a variable
the descriptor's env sets is read with System.getenv.
Secret store¶
secrets().put(name, value), getOrNull(name) and delete(name), wherever the Java SDK offers a secret
store: an endpoint, a consumer, a workflow, a timed action and the running service, never an entity. See
Secrets a service keeps.
Personal data¶
A data class property of type Personal<T> is a personal field (Personal data):
personal(subject("player/8c1f"), value) makes one and valueOrNull reads it, null once the data subject
is erased. A field read from another project's topic and kept, through copy() or as it is, is written again
under that project's key.
The erasure handler and asking for an erasure are the Java SDK's: withErasureHandler { ctx, subject ->
ErasureReport.of(ctx.storage().bucket("kyc").erase(subject)) } on the builder, and erasures().ask(subject)
on an endpoint or a consumer, ErasureAsk.inProject(project) for another project.
Calling other services¶
services().of(name) or of(project, name) answers the Java SDK's ServiceClient; its raw request takes
a nullable body and content type.
val client = services().of(service)
return try {
if (request().queryOrNull("mode") == "typed") {
ServiceCallRecord("response", 200, body = client.getText(path, headers))
} else {
val bytes = if (body.isEmpty()) null else body.toByteArray()
val contentType = if (body.isEmpty()) null else request().headerOrNull("Content-Type")
val answer = client.request(method, path, bytes, contentType, headers)
val named = answer.headers.firstOrNull { it.key.equals("x-answer", ignoreCase = true) }?.value ?: ""
ServiceCallRecord("response", answer.status, answer.contentType, answer.text(), named)
}
} catch (e: ServiceCallFailed) {
ServiceCallRecord("failed", e.status(), body = e.body())
} catch (e: ServiceUnresolvable) {
ServiceCallRecord("unresolvable", message = e.message.orEmpty())
} catch (e: ServiceIdentityMismatch) {
ServiceCallRecord("mismatch", message = e.message.orEmpty())
} catch (e: ServiceUnanswered) {
ServiceCallRecord("unanswered", message = e.message.orEmpty())
} catch (e: CommandException) {
ServiceCallRecord("refused", message = e.problem().detail)
}
}
The exceptions are the Java SDK's: ServiceUnresolvable, ServiceIdentityMismatch and ServiceUnanswered,
each a ServiceUnreachable; ServiceCallFailed for an answer outside 2xx to a typed call; CommandException
for a refusal by the runtime. See Calling other services.
Running a service¶
fun service(): ServiceBuilder =
Ankka.service("shopping-cart")
.register(ShoppingCart::class.java)
.register(CartRows::class.java)
.register(CheckoutNotifier::class.java)
.register(CheckoutsSeen::class.java)
.register(CheckoutWorkflow::class.java)
.withEndpoint(CartEndpoint::class.java)
.withEndpoint(CheckoutsSeenEndpoint::class.java)
.withEndpoint(CartKeysEndpoint::class.java)
.withMcpEndpoint(CartMcpEndpoint::class.java)
fun main() {
service().start().whenTerminated().toCompletableFuture().join()
}
Ankka.service(name), register(...) and start() are the Java SDK's; start() throws ServiceProblems
listing every declaration problem before anything listens.
Testing¶
The Java SDK's kits, with Kotlin entry points in com.thinkmorestupidless.ankka.kotlin.testkit:
| Kit | Drives |
|---|---|
eventSourcedTestKit(Entity::class[, id]), keyValueEntityTestKit(...), workflowTestKit(...), viewTestKit(...), consumerTestKit(...) |
one component, no runtime |
ankkaTestKit(service(), settings) { … } |
the whole service against a throwaway Postgres, closed when the block ends |
eventually(within = 10.seconds) { … } |
a suspending retry of an assertion, inside the whole-service kit |
A whole-service test of a suspending workflow step:
@Test
fun `a checkout's suspending step checks the cart out and keeps what it held`() =
ankkaTestKit(service()) {
val widget = LineItem("widget", "Widget", 2)
runBlocking {
componentClient().forEventSourcedEntity("c1").call(ShoppingCart::addItem).invokeSuspending(widget)
componentClient().forWorkflow("checkout-c1").call(CheckoutWorkflow::start).invoke("c1")
eventually {
val checkout = componentClient().forWorkflow("checkout-c1").call(CheckoutWorkflow::get).invoke()
assertEquals(Cart("c1", listOf(widget), true), checkout.ordered)
}
}
See Testing.
Developing the SDK¶
The SDK is a Gradle build in sdks/kotlin of the ankka repository, built against the Java SDK of the
same commit:
just kotlin # the Kotlin SDK's checks
just kotlin-conformance # the conformance suite, against the Kotlin reference service
just template-kotlin # the Kotlin template, generated and tested
The reference service the conformance suite drives is sdks/kotlin/samples/reference. See
Adding a language SDK.