Skip to content

Composition Branch

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Composition: Branch (.branch{})

Overview

A Branch routes execution based on the runtime type of a sealed class or interface. A source agent produces one of several variants, and the branch dispatches each variant to a dedicated handler agent or pipeline.

classifier.branch {
    on<Circle>()    then circleHandler
    on<Rectangle>() then rectangleHandler
    on<Triangle>()  then triangleHandler
}

This gives you type-safe conditional routing. The source agent's output type should be a sealed interface or sealed class, and each on<Variant>() clause handles one concrete subtype.

Type Signature

class Branch<IN, OUT>(
    private val source: Agent<IN, *>,
    private val routes: Map<KClass<*>, (Any?) -> OUT>,
)

// Create a Branch from an Agent with sealed output
fun <IN, SEALED, OUT> Agent<IN, SEALED>.branch(
    block: BranchBuilder<OUT>.() -> Unit
): Branch<IN, OUT>

A Branch<IN, OUT> is invokable. When you call branch(input), it invokes the source agent, inspects the result's runtime class, and dispatches to the matching handler.

The BranchBuilder DSL

Inside the branch { } block, you use a builder DSL:

class BranchBuilder<OUT> {
    // Select a variant type to handle
    inline fun <reified T : Any> on(): OnClause<T>

    inner class OnClause<T : Any> {
        // Route this variant to an agent
        infix fun then(agent: Agent<T, OUT>)

        // Route this variant to a pipeline
        infix fun then(pipeline: Pipeline<T, OUT>)
    }
}

The flow is:

  1. Call on<Variant>() to specify which sealed subtype to match
  2. Use then to wire it to a handler agent or pipeline
  3. Repeat for each variant

All handlers must produce the same OUT type, which becomes the Branch's output type.

Basic Example

A shape classifier routes to different handlers based on the shape variant:

import agents_engine.core.*
import agents_engine.composition.branch.branch

sealed interface Shape
data class Circle(val radius: Double) : Shape
data class Rectangle(val w: Double, val h: Double) : Shape
data class Triangle(val base: Double, val height: Double) : Shape

val classify = agent<String, Shape>("classify") {
    skills { skill<String, Shape>("classify") { implementedBy { input ->
        when {
            input.startsWith("c") -> Circle(input.length.toDouble())
            input.startsWith("r") -> Rectangle(2.0, 3.0)
            else                  -> Triangle(4.0, 5.0)
        }
    } } }
}

val branch = classify.branch {
    on<Circle>()    then agent<Circle, String>("c")    { skills { skill<Circle, String>("c")    { implementedBy { "circle r=${it.radius}" } } } }
    on<Rectangle>() then agent<Rectangle, String>("r") { skills { skill<Rectangle, String>("r") { implementedBy { "rect ${it.w}x${it.h}" } } } }
    on<Triangle>()  then agent<Triangle, String>("t")  { skills { skill<Triangle, String>("t")  { implementedBy { "tri b=${it.base}" } } } }
}

branch("circle")   // "circle r=6.0"
branch("rect")     // "rect 2.0x3.0"
branch("triangle") // "tri b=4.0"

Pipeline on a Variant

A handler does not have to be a single agent. Use a Pipeline to process a variant through multiple stages:

val area    = agent<Circle, Double>("area")  {
    skills { skill<Circle, Double>("area") { implementedBy { Math.PI * it.radius * it.radius } } }
}
val rounded = agent<Double, String>("round") {
    skills { skill<Double, String>("round") { implementedBy { "%.2f".format(Locale.US, it) } } }
}

val branch = classify.branch {
    on<Circle>()    then (area then rounded)  // pipeline for this variant
    on<Rectangle>() then agent<Rectangle, String>("r") { skills { skill<Rectangle, String>("r") { implementedBy { "rect" } } } }
    on<Triangle>()  then agent<Triangle, String>("t")  { skills { skill<Triangle, String>("t")  { implementedBy { "tri" } } } }
}

branch("circle")  // "113.10" (area of circle with radius 6.0)

Composing Branches in Pipelines

A Branch is a first-class composition primitive. Connect it to other stages with then:

Branch after a pipeline:

val prepare = agent<Int, String>("prepare") {
    skills { skill<Int, String>("prepare") { implementedBy { if (it > 0) "circle" else "rect" } } }
}

val branch = classify.branch {
    on<Circle>()    then agent<Circle, Int>("c")    { skills { skill<Circle, Int>("c")    { implementedBy { it.radius.toInt() } } } }
    on<Rectangle>() then agent<Rectangle, Int>("r") { skills { skill<Rectangle, Int>("r") { implementedBy { (it.w * it.h).toInt() } } } }
    on<Triangle>()  then agent<Triangle, Int>("t")  { skills { skill<Triangle, Int>("t")  { implementedBy { (it.base * it.height / 2).toInt() } } } }
}

val pipeline = prepare then branch
pipeline(1)   // "circle" -> Circle(6.0) -> 6
pipeline(-1)  // "rect" -> Rectangle(2.0, 3.0) -> 6

Agent after a branch:

val branch = classify.branch {
    on<Circle>()    then agent<Circle, Double>("c")    { skills { skill<Circle, Double>("c")    { implementedBy { it.radius } } } }
    on<Rectangle>() then agent<Rectangle, Double>("r") { skills { skill<Rectangle, Double>("r") { implementedBy { it.w * it.h } } } }
    on<Triangle>()  then agent<Triangle, Double>("t")  { skills { skill<Triangle, Double>("t")  { implementedBy { it.base * it.height / 2 } } } }
}

val wrap = agent<Double, String>("wrap") {
    skills { skill<Double, String>("wrap") { implementedBy { "area=%.1f".format(Locale.US, it) } } }
}

val pipeline = branch then wrap
pipeline("circle")   // "area=6.0"
pipeline("rect")     // "area=6.0"
pipeline("triangle") // "area=10.0"

Unhandled Variants

If the source agent returns a variant that has no matching on<>() clause, the branch throws an IllegalStateException at runtime:

val branch = classify.branch {
    on<Circle>()    then circleHandler
    on<Rectangle>() then rectHandler
    // Triangle is not handled
}

branch("triangle")
// throws IllegalStateException: "No branch defined for Triangle."

Exhaustiveness

Unlike Kotlin's when on sealed types, the branch {} DSL does not enforce exhaustiveness at compile time. The routes are built dynamically via a MutableMap<KClass<*>, ...>, so the compiler cannot verify that every sealed variant is covered.

To protect yourself:

  • Handle all variants explicitly
  • Write tests that exercise every variant
  • The runtime IllegalStateException will catch any gaps during testing
// Recommended: cover every variant
val branch = classify.branch {
    on<Circle>()    then circleHandler
    on<Rectangle>() then rectHandler
    on<Triangle>()  then triHandler   // do not forget this one
}

Agent Placement

Agents used as handlers inside a branch are tracked and cannot be reused elsewhere:

val circleHandler = agent<Circle, String>("c") { skills { skill<Circle, String>("c") { implementedBy { "circle" } } } }

classify.branch {
    on<Circle>() then circleHandler  // circleHandler is now placed
    // ...
}

// This throws IllegalArgumentException:
// circleHandler then anotherAgent

Next: Pipeline | Parallel | Loop | Forum | While Loops

Clone this wiki locally