-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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:
-
@Guideon a subclass tells the LLM when to choose that variant ("Code is ready to ship"). -
@Guideon 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.
## 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
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.
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
objectin theoneOfarray. - 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
@Guidedescriptions appear as the object's"description". - Field-level
@Guidedescriptions 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.
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:
- Parse the JSON leniently (handles fences, trailing commas, surrounding text).
- Extract the
"type"field from the parsed map. - Find the sealed subclass whose
simpleNamematches the type value. - 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)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.
// 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.
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
}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.
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: ..."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 -> rectHandlerOr 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>.
- @Generable & @Guide -- Full reference on annotations and runtime artifacts.
-
Composition: Pipeline -- Chaining agents with
then. - Agent Memory -- Persistent state across invocations.
Getting Started
Core Concepts
Composition Operators
LLM Integration
- Model & Tool Calling
- Tool Error Recovery
- Skill Selection & Routing
- Budget Controls
- Observability Hooks
Guided Generation
Agent Memory
Reference