-
Notifications
You must be signed in to change notification settings - Fork 0
Agent and Type Contract
The Agent<IN, OUT> class is the core abstraction in Agents.KT. It is a typed, callable function that receives IN and produces OUT, backed by one or more skills. This page covers its construction, configuration DSL, validation rules, and usage patterns.
An agent is constructed with three values:
class Agent<IN, OUT>(
val name: String,
val outType: KClass<*>,
private val castOut: (Any?) -> OUT,
)-
name-- Human-readable identifier, used in error messages and composition tracking. -
outType-- TheKClassofOUT, captured at construction time viareifiedgenerics. -
castOut-- A function that casts raw output toOUT. Generated automatically by theagent()builder.
You never call the constructor directly. Use the top-level builder:
inline fun <IN, reified OUT : Any> agent(
name: String,
block: Agent<IN, OUT>.() -> Unit,
): Agent<IN, OUT>The reified keyword on OUT lets the framework capture OUT::class at construction time. This is what enables runtime type checking -- the agent knows its output type as a KClass, not just as a generic parameter erased by the JVM.
val myAgent = agent<String, Summary>("summarizer") {
// configure here
}
// myAgent.outType == Summary::classMost agent frameworks treat agents as String -> String or Any -> Any. This creates several problems:
The "god agent" anti-pattern. Without types, there is no contract. An agent that claims to "answer questions" might return JSON, prose, a list, or an error string. Callers must parse and hope.
Composition breaks silently. If agent A returns a summary string and agent B expects a structured review object, a pipeline A then B compiles and runs -- then fails at runtime with an incomprehensible LLM error or a malformed parse.
No validation at construction. You cannot verify that an agent's skills produce the right output type until you actually call it.
Agents.KT solves this with generics:
// This compiles -- types align
val pipeline = agent<String, Summary>("a") { ... } then
agent<Summary, Report>("b") { ... }
// This does NOT compile -- Summary != Int
val broken = agent<String, Summary>("a") { ... } then
agent<Int, Report>("b") { ... }At construction, validate() ensures at least one skill produces the agent's OUT type. At composition, the Kotlin compiler ensures types chain correctly.
The agent() builder opens a receiver of type Agent<IN, OUT>, giving you access to these DSL functions:
Sets the system-level prompt. Prepended to the LLM's system message in agentic skills.
agent<String, String>("writer") {
prompt("You are a technical writer. Be concise and precise.")
// ...
}Configures the LLM backend. Required for agentic skills and LLM-based skill routing.
model {
ollama("llama3") // model name + provider shortcut
temperature = 0.3
host = "localhost"
port = 11434
}You can also inject a custom ModelClient directly:
model {
ollama("llama3")
client = ModelClient { messages -> LlmResponse.Text("mocked") }
}Sets the turn limit for the agentic loop. Throws BudgetExceededException if exceeded.
budget {
maxTurns = 10
}Registers the agent's skills. This is where all behavior is defined. See Skills and Knowledge for the full API.
skills {
skill<String, String>("uppercase", "Convert to uppercase") {
implementedBy { it.uppercase() }
}
}Registers agent-level tools available to all agentic skills. Tools are functions the LLM can call.
tools {
tool("read_file", "Read file contents") { args ->
File(args["path"].toString()).readText()
}
tool("write_file", "Write content to file") { args ->
File(args["path"].toString()).writeText(args["content"].toString())
"ok"
}
}Attaches a MemoryBank and auto-registers memory_read, memory_write, and memory_search tools.
val sharedMemory = MemoryBank(maxLines = 100)
agent<String, String>("note-taker") {
memory(sharedMemory)
// ...
}Provides a predicate function for deterministic skill routing. Takes priority over LLM-based routing.
skillSelection { input ->
if (input.startsWith("translate:")) "translate" else "summarize"
}Listener called after each tool invocation. Receives the tool name, arguments, and result.
onToolUse { name, args, result ->
logger.info("Tool $name called with $args -> $result")
}Listener called when a knowledge tool is invoked by the LLM.
onKnowledgeUsed { name, content ->
logger.info("Knowledge '$name' loaded (${content.length} chars)")
}Listener called when skill resolution completes.
onSkillChosen { name ->
metrics.increment("skill.$name.chosen")
}The agent() builder calls validate() after your configuration block runs:
fun validate() {
if (skills.isNotEmpty()) {
require(skills.values.any { it.outType == outType }) {
"Agent \"$name\" has no skill producing ${outType.simpleName}. " +
"At least one skill must return the agent's OUT type."
}
}
}This catches a common mistake: defining skills that do not actually produce the agent's declared output type.
data class CodeBundle(val code: String)
// This throws IllegalArgumentException at construction time:
agent<String, CodeBundle>("bad") {
skills {
skill<String, String>("spell-check") {
implementedBy { it }
}
// No skill produces CodeBundle!
}
}
// Error: Agent "bad" has no skill producing CodeBundle.
// At least one skill must return the agent's OUT type.Note: an agent with no skills at all passes validation. It will fail at invocation with an IllegalStateException instead.
Agent<IN, OUT> implements operator fun invoke:
operator fun invoke(input: IN): OUTThis means agents are callable like functions:
val upper = agent<String, String>("upper") {
skills {
skill<String, String>("upper", "Uppercase") {
implementedBy { it.uppercase() }
}
}
}
val result: String = upper("hello") // "HELLO"Because agents, pipelines, loops, branches, and parallels all implement invoke, they are all interchangeable as functions. A Pipeline<A, C> is just as callable as an Agent<A, C>. This is what makes fractal composition work -- see Architecture Overview.
No LLM, no tools. The skill lambda runs directly.
data class Input(val text: String)
data class Output(val words: List<String>, val count: Int)
val tokenizer = agent<Input, Output>("tokenizer") {
skills {
skill<Input, Output>("tokenize", "Split text into words") {
implementedBy { input ->
val words = input.text.split("\\s+".toRegex())
Output(words, words.size)
}
}
}
}
val result = tokenizer(Input("hello world"))
// Output(words=["hello", "world"], count=2)The skill delegates to an LLM that can call tools.
val coder = agent<String, String>("coder") {
prompt("You are a Kotlin developer. Write clean, idiomatic code.")
model { ollama("llama3"); temperature = 0.2 }
budget { maxTurns = 15 }
tools {
tool("read_file", "Read file from disk") { args ->
File(args["path"].toString()).readText()
}
tool("write_file", "Write file to disk") { args ->
File(args["path"].toString()).writeText(args["content"].toString())
"Written successfully"
}
}
skills {
skill<String, String>("implement", "Implement a feature") {
tools("read_file", "write_file")
knowledge("style-guide", "Kotlin coding conventions") {
File("docs/style-guide.md").readText()
}
}
}
}
val result = coder("Add a data class for user profiles with name, email, and age")val textProcessor = agent<String, String>("text-processor") {
prompt("You process text according to the user's request.")
model { ollama("llama3"); temperature = 0.0 }
skills {
skill<String, String>("summarize", "Summarize text into a brief overview") {
tools()
}
skill<String, String>("translate", "Translate text to another language") {
tools()
}
skill<String, String>("format", "Reformat text (e.g., to markdown, bullet points)") {
tools()
}
}
// Option A: let the LLM route (omit skillSelection)
// Option B: deterministic routing
skillSelection { input ->
when {
input.contains("summarize", ignoreCase = true) -> "summarize"
input.contains("translate", ignoreCase = true) -> "translate"
else -> "format"
}
}
onSkillChosen { name -> println("Routed to skill: $name") }
}Agents compose naturally because they are typed functions:
data class RawText(val content: String)
data class Keywords(val words: List<String>)
data class Report(val summary: String)
val extractor = agent<RawText, Keywords>("extractor") {
skills {
skill<RawText, Keywords>("extract", "Extract keywords") {
implementedBy { input ->
Keywords(input.content.split(" ").filter { it.length > 4 })
}
}
}
}
val reporter = agent<Keywords, Report>("reporter") {
skills {
skill<Keywords, Report>("report", "Generate report from keywords") {
implementedBy { input ->
Report("Found ${input.words.size} keywords: ${input.words.joinToString()}")
}
}
}
}
val pipeline = extractor then reporter
val report: Report = pipeline(RawText("The quick brown fox jumps over the lazy dog"))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