-
Notifications
You must be signed in to change notification settings - Fork 0
Generable and Guide
LLMs return strings. Your agent pipeline expects typed Kotlin objects. Guided generation bridges the gap -- annotations on your data classes tell the framework exactly what shape to ask the LLM for, and a lenient deserializer gets you there even when the LLM's formatting is imperfect.
Consider an agent that reviews code. You want it to return a structured result:
data class ReviewResult(
val approved: Boolean,
val issues: List<String>,
)Without guided generation, you would:
- Write a prose prompt describing the expected JSON shape.
- Hope the LLM formats its response correctly.
- Parse the response with a strict JSON parser.
- Handle the inevitable failure when the LLM wraps the JSON in markdown fences, adds trailing commas, or surrounds it with explanation text.
This is tedious, error-prone, and duplicates information that already lives in your Kotlin types. Agents.KT eliminates all four steps.
The generation system uses three annotations, all defined in agents_engine.generation.Annotations.kt:
Marks a data class or sealed interface as an LLM generation target. The framework uses this annotation at runtime to generate JSON Schema, prompt fragments, and a lenient deserializer.
@Generable("Result of code review")
data class ReviewResult(
val approved: Boolean,
val issues: List<String>,
)The optional description parameter provides context to the LLM about what this type represents.
Per-field or per-variant guidance for the LLM. On a constructor parameter, it tells the LLM what to put in that field -- its range, format, or constraints. On a sealed subclass, it tells the LLM when to choose that variant.
@Generable("Result of code review")
data class ReviewResult(
@Guide("True if code passes all checks") val approved: Boolean,
@Guide("List of issues found, empty if approved") val issues: List<String>,
)Overrides the auto-generated toLlmDescription() for a class. When present, the provided text is returned verbatim -- no auto-generation happens.
@Generable
@LlmDescription("Custom hand-written description -- ignores all auto-generation")
data class ManuallyDescribed(val x: Int)Use this when the auto-generated description does not fit your use case.
Once a class is annotated with @Generable, five runtime functions become available via extension functions on KClass<*>:
| Function | Returns | Purpose |
|---|---|---|
toLlmDescription() |
Markdown string | Human-readable description with field names, types, and @Guide text |
jsonSchema() |
JSON Schema string | Machine-readable constraint encoding for grammar decoding |
promptFragment() |
Natural-language string | Instruction telling the LLM exactly how to format its output |
fromLlmOutput(json) |
T? |
Lenient deserializer that handles messy LLM output |
PartiallyGenerated<T> |
Accumulator | Streaming-friendly field-by-field construction |
All five are defined in agents_engine.generation.GenerableSupport.kt and agents_engine.generation.PartiallyGenerated.kt.
Walk through a concrete example. Given this annotated data class:
@Generable("Distance measurement between two points")
data class Measurement(
@Guide("Value in meters") val distance: Double,
@Guide("Measurement label") val label: String,
)Generates a markdown description:
## Measurement
Distance measurement between two points
- **distance** (Double): Value in meters
- **label** (String): Measurement label
Fields without @Guide still appear, but without the description suffix. If @LlmDescription is present on the class, the entire output is replaced with its text.
Generates a JSON Schema string:
{
"type": "object",
"properties": {
"distance": {"type": "number", "description": "Value in meters"},
"label": {"type": "string", "description": "Measurement label"}
},
"required": ["distance", "label"]
}Type mappings:
| Kotlin Type | JSON Schema Type |
|---|---|
String |
"string" |
Int, Long
|
"integer" |
Double, Float
|
"number" |
Boolean |
"boolean" |
List<T> |
"array" with "items"
|
Nested @Generable
|
Inlined "object"
|
Nullable fields are omitted from the "required" array. @Guide descriptions are included as "description" values.
Generates a natural-language instruction:
Respond with a JSON object matching this structure:
{
"distance": <Double: Value in meters>,
"label": <String: Measurement label>
}
This fragment is injected into the skill's system prompt when the skill's output type is @Generable.
Parses the LLM's response into a Measurement instance:
val result: Measurement? = Measurement::class.fromLlmOutput(
"""{"distance": 42.5, "label": "room width"}"""
)
// result = Measurement(distance=42.5, label="room width")There is also an inline reified variant for cleaner call sites:
val result: Measurement? = fromLlmOutput("""{"distance": 42.5, "label": "room width"}""")Both return null on unrecoverable input.
LLMs rarely produce perfect JSON. fromLlmOutput delegates to LenientJsonParser, which handles the most common formatting issues:
Markdown fences:
val json = "```json\n{\"distance\": 0.7, \"label\": \"hall\"}\n```"
Measurement::class.fromLlmOutput(json) // worksTrailing commas:
Measurement::class.fromLlmOutput("""{"distance": 0.5, "label": "hall",}""") // worksExtra text surrounding JSON:
val input = """Here is the result: {"distance": 1.0, "label": "test"} Hope that helps!"""
Measurement::class.fromLlmOutput(input) // works -- extracts the JSON blockEscape sequences:
The parser correctly handles \", \\, \n, \r, \t, and \/ inside string values.
Type coercion:
Integer values are coerced to Double when the target field is Double. This handles the common case where an LLM returns 1 instead of 1.0.
Nested @Generable objects:
@Generable
data class NestedResult(
@Guide("The inner score object") val inner: ScoreResult,
val label: String,
)
val json = """{"inner": {"score": 0.8, "verdict": "pass"}, "label": "test"}"""
NestedResult::class.fromLlmOutput(json)
// NestedResult(inner=ScoreResult(score=0.8, verdict="pass"), label="test")When the input contains no JSON at all, fromLlmOutput returns null:
Measurement::class.fromLlmOutput("This is not JSON at all") // nullWhen fields arrive incrementally -- for example, as an LLM streams tokens -- PartiallyGenerated<T> acts as an immutable accumulator.
val partial = PartiallyGenerated.empty<ReviewResult>()
// or equivalently:
val partial = partiallyGenerated<ReviewResult>()Each withField returns a new instance. The original is unchanged:
val empty = partiallyGenerated<ReviewResult>()
val withApproved = empty.withField("approved", true)
val withBoth = withApproved.withField("issues", listOf("minor typo"))
empty.arrivedFieldNames // emptySet
withApproved.arrivedFieldNames // {"approved"}
withBoth.arrivedFieldNames // {"approved", "issues"}partial["approved"] // Any? -- the value, or null if not arrived
partial.has("approved") // Boolean -- true even if value is null
partial.arrivedFieldNames // Set<String>toComplete() attempts to construct a full T from the arrived fields. Returns null if required fields are missing:
val partial = partiallyGenerated<ReviewResult>()
.withField("approved", true)
.withField("issues", listOf("minor typo"))
val result: ReviewResult? = partial.toComplete()
// ReviewResult(approved=true, issues=["minor typo"])val incomplete = partiallyGenerated<ReviewResult>()
.withField("approved", true)
// "issues" is required but missing
incomplete.toComplete() // nullNote: Full typed property access (partial.approved) requires KSP codegen (planned Phase 2). Use get("fieldName") as Type for now.
When a skill's input or output type is annotated with @Generable, the skill's toLlmDescription() automatically embeds the type shape:
@Generable("Raw text document submitted for processing")
data class Document(@Guide("The full text content") val text: String)
@Generable("A concise single-sentence summary")
data class Summary(@Guide("The summary sentence") val sentence: String)
val s = skill<Document, Summary>("summarize", "Summarizes text to one sentence") {
knowledge("style", "Voice and tone guidelines") { "Active voice only" }
implementedBy { Summary(it.text) }
}
println(s.toLlmDescription())Output:
## Skill: summarize
**Input:** Document -- Raw text document submitted for processing
- text (String): The full text content
**Output:** Summary -- A concise single-sentence summary
- sentence (String): The summary sentence
Summarizes text to one sentence
**Knowledge:**
- style -- Voice and tone guidelines
The @Generable description and all @Guide annotations are woven into the skill's description automatically. No manual duplication required.
Agents.KT supports two tiers for ensuring the LLM produces valid structured output:
| Constrained | Guided | |
|---|---|---|
| Mechanism | Ollama grammar decoding | Prompt injection + lenient parse |
| Schema source | jsonSchema() |
promptFragment() |
| Guarantees | Output is valid JSON matching the schema | Output is best-effort; parse may return null
|
| Model support | Ollama models with grammar support | Any model (Ollama, OpenAI, etc.) |
| Fallback | Not needed -- output is always valid |
fromLlmOutput handles fences, commas, extra text |
| Use when | You need hard guarantees | You need broad model compatibility |
With the constrained tier, the jsonSchema() output is passed to Ollama's grammar decoding feature, which constrains the model's token generation to only produce valid JSON matching the schema. The LLM physically cannot produce malformed output.
With the guided tier, the promptFragment() is injected into the system prompt, and fromLlmOutput() handles the inevitable formatting variations. This works with any model but offers softer guarantees.
Both tiers use the same annotations. You choose the tier at the model configuration level, not at the type level.
-
Sealed Types and Branching -- Use
@Generablewith sealed interfaces for multi-shape outputs that drive branching logic. - Agent Memory -- Persistent state across agent invocations.
- Model & Tool Calling -- Connect agents to LLMs and define tools.
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