Skip to content

Sealed Types and Branching

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Sealed Types and Branching

Agents often need to produce one of several possible outputs -- approve or reject, classify into categories, choose a strategy. Kotlin sealed interfaces are the natural fit, and Agents.KT gives them first-class support: from JSON Schema generation through lenient deserialization to compile-time-safe branching.


Sealed Types in Agents.KT

A sealed interface annotated with @Generable models multi-shape agent outputs. Each subclass is a variant the LLM can choose:

@Generable("Decision on whether code is ready to ship")
sealed interface Decision {
    @Guide("Code is ready to ship")
    data class Approved(
        @Guide("Confidence score 0.0 to 1.0") val confidence: Double,
    ) : Decision

    @Guide("Code needs changes")
    data class Rejected(
        @Guide("Reason for rejection") val reason: String,
    ) : Decision
}

The annotations serve dual duty:

  • @Guide on a subclass tells the LLM when to choose that variant ("Code is ready to ship").
  • @Guide on a parameter tells the LLM what to put in that field ("Confidence score 0.0 to 1.0").

All five runtime artifacts from @Generable work with sealed interfaces.

toLlmDescription()

## Decision

Decision on whether code is ready to ship

Choose one of the following variants:

### Approved: Code is ready to ship
- **confidence** (Double): Confidence score 0.0 to 1.0

### Rejected: Code needs changes
- **reason** (String): Reason for rejection

promptFragment()

Respond with a JSON object for one of the following variants.
Set "type" to the variant name.

Approved: Code is ready to ship
Respond with a JSON object matching this structure:
{
  "type": <String>,
  "confidence": <Double: Confidence score 0.0 to 1.0>
}

Rejected: Code needs changes
Respond with a JSON object matching this structure:
{
  "type": <String>,
  "reason": <String: Reason for rejection>
}

The key instruction is Set "type" to the variant name. This tells the LLM to include a discriminator field.


JSON Schema for Sealed Types

Sealed interfaces generate a oneOf schema with a "type" discriminator on each variant:

Decision::class.jsonSchema()

Produces:

{
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "type": {"type": "string", "const": "Approved"},
        "confidence": {"type": "number", "description": "Confidence score 0.0 to 1.0"}
      },
      "required": ["type", "confidence"],
      "description": "Code is ready to ship"
    },
    {
      "type": "object",
      "properties": {
        "type": {"type": "string", "const": "Rejected"},
        "reason": {"type": "string", "description": "Reason for rejection"}
      },
      "required": ["type", "reason"],
      "description": "Code needs changes"
    }
  ]
}

Key details:

  • Each variant is a separate object in the oneOf array.
  • The "type" property uses "const" to pin the discriminator value to the variant's simple class name ("Approved", "Rejected").
  • "type" is always in the "required" array.
  • Variant-level @Guide descriptions appear as the object's "description".
  • Field-level @Guide descriptions appear as each property's "description".

This schema is used by the constrained enforcement tier (Ollama grammar decoding) to guarantee that the LLM produces valid output for exactly one variant. See Two Enforcement Tiers for details.


Lenient Deserialization

fromLlmOutput routes sealed types to the correct subclass via the "type" discriminator field:

val approved = Decision::class.fromLlmOutput(
    """{"type": "Approved", "confidence": 0.95}"""
)
// approved is Decision.Approved(confidence=0.95)

val rejected = Decision::class.fromLlmOutput(
    """{"type": "Rejected", "reason": "Too complex"}"""
)
// rejected is Decision.Rejected(reason="Too complex")

The deserialization logic:

  1. Parse the JSON leniently (handles fences, trailing commas, surrounding text).
  2. Extract the "type" field from the parsed map.
  3. Find the sealed subclass whose simpleName matches the type value.
  4. Construct that subclass from the remaining fields.

Edge cases handled gracefully:

// Unknown variant -- returns null
Decision::class.fromLlmOutput("""{"type": "Unknown", "foo": "bar"}""")  // null

// Missing discriminator -- returns null
Decision::class.fromLlmOutput("""{"confidence": 0.9}""")  // null

// Markdown fences -- works
Decision::class.fromLlmOutput("""```json
{"type": "Approved", "confidence": 0.85}
```""")  // Decision.Approved(confidence=0.85)

Combining with .branch{}

Sealed types and the branch operator are designed to work together. The agent produces a sealed type; the branch routes each variant to a different handler.

Basic pattern

// Step 1: Define the sealed output
sealed interface Shape
data class Circle(val radius: Double) : Shape
data class Rectangle(val w: Double, val h: Double) : Shape

// Step 2: Build an agent that produces the sealed type
val classify = agent<String, Shape>("classify") {
    skills {
        skill<String, Shape>("classify") {
            implementedBy { input ->
                if (input.startsWith("c")) Circle(input.length.toDouble())
                else Rectangle(2.0, 3.0)
            }
        }
    }
}

// Step 3: Branch on the variants
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}" } } }
    }
}

branch("circle")  // "circle r=6.0"
branch("rect")    // "rect 2.0x3.0"

The on<T>() then handler syntax is type-safe. The handler agent must accept T as input. The compiler enforces this -- you cannot accidentally route a Circle to an agent expecting Rectangle.

Branch handlers can be pipelines

The then in a branch clause accepts either an Agent or a Pipeline:

val area    = agent<Circle, Double>("area")  { /* ... */ }
val rounded = agent<Double, String>("round") { /* ... */ }

val branch = classify.branch {
    on<Circle>()    then (area then rounded)    // pipeline as handler
    on<Rectangle>() then rectHandler             // agent as handler
}

Unhandled variants throw at invocation

If the agent produces a variant that has no branch defined, you get a clear error:

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

branch("rect")  // throws: "No branch defined for Rectangle."

This is a runtime error, not a compile-time one. Define all variants to be safe.


Pattern: Review Decision Pipeline

Here is a complete pattern that combines LLM-driven classification with branching. A reviewer agent examines code and produces a sealed Decision. The branch routes approved code to a deployer and rejected code to a reporter.

@Generable("Review decision")
sealed interface ReviewDecision {
    @Guide("Code passes all checks")
    data class Passed(
        @Guide("Confidence 0.0 to 1.0") val confidence: Double,
    ) : ReviewDecision

    @Guide("Code has issues that must be fixed")
    data class Failed(
        @Guide("Description of the problem") val reason: String,
    ) : ReviewDecision
}

// Reviewer agent -- LLM-driven, produces ReviewDecision
val reviewer = agent<String, ReviewDecision>("reviewer") {
    prompt("Review the code. If it passes, respond with Passed and your confidence. If it fails, respond with Failed and the reason.")
    model { ollama("qwen2.5:7b"); temperature = 0.2 }
    skills {
        skill<String, ReviewDecision>("review", "Review code quality") {
            tools()
            transformOutput { raw -> fromLlmOutput<ReviewDecision>(raw) ?: ReviewDecision.Failed(reason = "Could not parse review") }
        }
    }
}

// Handler agents
val deployer = agent<ReviewDecision.Passed, String>("deployer") {
    skills {
        skill<ReviewDecision.Passed, String>("deploy") {
            implementedBy { "Deployed with confidence ${it.confidence}" }
        }
    }
}

val reporter = agent<ReviewDecision.Failed, String>("reporter") {
    skills {
        skill<ReviewDecision.Failed, String>("report") {
            implementedBy { "Filed issue: ${it.reason}" }
        }
    }
}

// Wire it together
val pipeline = reviewer.branch {
    on<ReviewDecision.Passed>() then deployer
    on<ReviewDecision.Failed>() then reporter
}

// Use it
val result = pipeline("fun add(a: Int, b: Int) = a + b")
// Either "Deployed with confidence 0.95" or "Filed issue: ..."

Composing branches into larger pipelines

Branches are composable. You can place them inside pipelines:

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

val pipeline = preparer then classify.branch {
    on<Circle>()    then circleHandler
    on<Rectangle>() then rectHandler
    on<Triangle>()  then triangleHandler
}

pipeline(1)   // preparer -> "circle" -> classify -> Circle -> circleHandler
pipeline(-1)  // preparer -> "rect"   -> classify -> Rectangle -> rectHandler

Or place agents after a branch:

val branch = classify.branch {
    on<Circle>()    then areaFromCircle
    on<Rectangle>() then areaFromRect
}

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

val pipeline = branch then wrap
pipeline("circle")  // "area=113.1"

The type algebra works: if all branch handlers produce Double, the branch itself is Branch<String, Double>, and it chains naturally with an Agent<Double, String>.


Next Steps

Clone this wiki locally