Skip to content

Agent and Type Contract

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Agent and the 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.


Agent<IN, OUT> -- The Core Abstraction

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 -- The KClass of OUT, captured at construction time via reified generics.
  • castOut -- A function that casts raw output to OUT. Generated automatically by the agent() 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::class

Why Types Matter

Most 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.


Agent Configuration DSL

The agent() builder opens a receiver of type Agent<IN, OUT>, giving you access to these DSL functions:

prompt(text: String)

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.")
    // ...
}

model { }

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") }
}

budget { }

Sets the turn limit for the agentic loop. Throws BudgetExceededException if exceeded.

budget {
    maxTurns = 10
}

skills { }

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() }
    }
}

tools { }

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"
    }
}

memory(bank: MemoryBank)

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)
    // ...
}

skillSelection { }

Provides a predicate function for deterministic skill routing. Takes priority over LLM-based routing.

skillSelection { input ->
    if (input.startsWith("translate:")) "translate" else "summarize"
}

onToolUse { }

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")
}

onKnowledgeUsed { }

Listener called when a knowledge tool is invoked by the LLM.

onKnowledgeUsed { name, content ->
    logger.info("Knowledge '$name' loaded (${content.length} chars)")
}

onSkillChosen { }

Listener called when skill resolution completes.

onSkillChosen { name ->
    metrics.increment("skill.$name.chosen")
}

Validation at Construction

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 as a Callable

Agent<IN, OUT> implements operator fun invoke:

operator fun invoke(input: IN): OUT

This 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.


Code Examples

Pure Kotlin Agent

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)

Agentic LLM Agent

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")

Agent with Multiple Skills and Routing

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") }
}

Agent in a Pipeline

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"))

Clone this wiki locally