-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture Overview
Agents.KT is a Kotlin-native framework for building typed, composable AI agents. Every agent is a function (IN) -> OUT backed by one or more skills, and agents compose into pipelines, parallel groups, forums, loops, and branches using infix operators. The framework enforces type safety at construction time and prevents shared mutable state through the Single-Placement Rule.
+-----------+
input--->| Agent |---output--->
| <IN, OUT> |
+-----+-----+
|
resolveSkill(input)
|
+-------------+-------------+
| |
Pure Kotlin Skill Agentic Skill
(implementedBy{}) (tools())
| |
lambda(input) +---------+---------+
| | |
return OUT Knowledge tools Action tools
| |
lazy providers ToolDef executors
| |
+----+---------+----+
| |
v v
+-----+---------+-----+
| ModelClient |
| (OllamaClient) |
+----------+-----------+
|
LLM API
|
LlmResponse.Text
LlmResponse.ToolCalls
|
parseOutput / loop
|
return OUT
Composition operators wrap agents:
a then b Pipeline<A, C> (sequential)
a / b Parallel<A, B> (concurrent)
a * b Forum<A, C> (deliberation)
a.loop { ... } Loop<A, B> (feedback)
a.branch { on<X>()... } Branch<A, OUT> (type routing)
The framework is organized into four packages:
The foundation. Contains the three core abstractions that every agent program uses:
-
Agent<IN, OUT>-- The typed, callable agent. Holds skills, prompt, model config, tools, memory, and event listeners. See Agent and the Type Contract. -
Skill<IN, OUT>-- A named, described capability. Can be pure Kotlin or LLM-driven. See Skills and Knowledge. -
MemoryBank-- Shared or isolated key-value memory with auto-generated tools (memory_read,memory_write,memory_search).
Operators that compose agents into larger structures. Each sub-package provides one composition primitive:
| Sub-package | Operator | Structure | Description |
|---|---|---|---|
pipeline/ |
a then b |
Pipeline<A, C> |
Sequential: output of a feeds into b
|
parallel/ |
a / b |
Parallel<A, B> |
Concurrent: both receive same input, returns List<B>
|
forum/ |
a * b |
Forum<A, C> |
Multi-agent deliberation group |
loop/ |
a.loop { } |
Loop<A, B> |
Feedback loop: runs until next returns null
|
branch/ |
a.branch { } |
Branch<A, OUT> |
Type-based routing via sealed interface variants |
All operators enforce the Single-Placement Rule by calling markPlaced() on each agent instance.
The LLM integration layer:
-
ModelClient-- Functional interface:fun chat(messages: List<LlmMessage>): LlmResponse. Swap in any backend. -
ModelConfig/ModelBuilder-- DSL for model configuration (name, provider, temperature, host, port, or a customModelClient). -
OllamaClient-- Built-in Ollama HTTP client implementingModelClient. -
AgenticLoop-- TheexecuteAgentic()function: builds system prompt, calls LLM, dispatches tool calls, loops until a text response. -
ToolDef/ToolsBuilder-- Tool definition (name, description, executor lambda) and DSL builder. -
BudgetConfig-- Turn-limit guard; throwsBudgetExceededExceptionwhen exceeded.
Structured output support for generating typed Kotlin objects from LLM text:
-
@Generable-- Marks data classes and sealed interfaces as LLM generation targets. -
@Guide-- Per-field or per-variant guidance for the LLM. -
@LlmDescription-- Manual override for auto-generated prompt fragments. -
GenerableSupport-- Runtime generation of JSON Schema, prompt fragments, and lenient deserialization viaKClass.fromLlmOutput(). -
LenientJsonParser-- Handles markdown fences, trailing commas, and surrounding explanation text in LLM output. -
PartiallyGenerated-- Support for incremental/streaming generation.
The central type. Agent<IN, OUT> is a typed function that receives IN and produces OUT. It holds a map of skills, and resolving which skill to invoke is the first step of every call. Agents are constructed with the agent() builder and configured via a Kotlin DSL. See Agent and the Type Contract for the full API.
val summarizer = agent<String, String>("summarizer") {
prompt("You are a concise summarizer.")
model { ollama("llama3"); temperature = 0.3 }
skills {
skill<String, String>("summarize", "Summarize text") {
tools()
}
}
}
val result: String = summarizer("Long article text...")A named capability with a description, typed input/output, and either a pure Kotlin implementation or an LLM-driven one. Skills carry knowledge entries and can transform LLM output into typed objects. See Skills and Knowledge.
Composition primitives. Each is itself callable with operator fun invoke, so compositions nest:
val pipeline = (agentA then agentB) // Pipeline<A, C>
val fan = (worker1 / worker2 / worker3) // Parallel<A, B>
val refined = editor.loop { if (it.score > 8) null else it.draft }
val routed = classifier.branch {
on<Positive>() then celebrator
on<Negative>() then consoler
}
// Nest them:
val system = pipeline then fan then aggregatorA single-method interface. The framework ships OllamaClient, but you can pass any lambda:
val mock = ModelClient { messages -> LlmResponse.Text("mocked response") }A tool the LLM can call. Name, description, and executor lambda:
tools {
tool("read_file", "Read a file from disk") { args ->
File(args["path"].toString()).readText()
}
}When you call agent(input), the following happens:
The agent resolves which skill handles this input via a three-tier strategy:
-
Predicate routing -- If
skillSelection { }is set, it runs first and returns the skill name directly. - LLM routing -- If multiple type-compatible skills exist and a model is configured, the LLM picks the best skill based on descriptions.
- First-match fallback -- If no model is configured, the first type-compatible skill wins.
// Predicate routing
skillSelection { input ->
if (input.startsWith("UP:")) "upper" else "lower"
}The skillChosenListener receives the chosen skill name, if registered.
Non-agentic (pure Kotlin): The framework calls the implementedBy { } lambda directly with the input. No LLM involved.
Agentic (LLM-driven): The framework enters the agentic loop:
-
Build system prompt -- Combines the agent's
prompt, the skill's description (or full context if no knowledge tools), and a listing of all available tools. - Send user message -- The serialized input.
-
Call LLM -- Via the configured
ModelClient. -
Handle response:
-
LlmResponse.Text-- Parse the text intoOUT(usingtransformOutput,@Generabledeserialization, or identity forString). Return. -
LlmResponse.ToolCalls-- Execute each tool call, append results as tool messages, and loop back to step 3.
-
-
Budget guard -- If turns exceed
budgetConfig.maxTurns, throwBudgetExceededException.
The agent's castOut function ensures the result is typed as OUT.
Every agent, skill, pipeline, and composition operator carries generic types. The compiler rejects a then b if a's output type does not match b's input type. The validate() function enforces at construction that at least one skill produces the agent's OUT type.
Each Agent instance can participate in exactly one composition. This prevents shared mutable state across structures. If you need the same logic in two places, create two instances. See The Single-Placement Rule.
Agents do not execute arbitrary code. All behavior flows through skills. This makes agents inspectable -- you can list an agent's skills, their descriptions, and their knowledge entries without executing anything.
Knowledge entries are lambdas, not strings. They are evaluated only when the LLM requests them via tool calls, or when toLlmContext() is called for non-agentic eager loading. This keeps system prompts lean and lets knowledge be dynamic.
LLM output is unpredictable. The LenientJsonParser and fromLlmOutput() handle markdown fences, trailing commas, extra text around JSON, and missing fields. Combined with @Generable annotations and @Guide field-level hints, this gives structured output without fragile parsing.
Pipelines, loops, branches, and parallels are all callable -- they implement operator fun invoke. This means a Pipeline<A, B> can be used anywhere an (A) -> B is expected: inside another pipeline, as the body of a loop, as a branch handler. Compositions nest arbitrarily.
val inner = agentA then agentB // Pipeline<A, C>
val looped = inner.loop { feedback(it) } // Loop<A, C>
val outer = prepare then looped then formatGetting 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