-
Notifications
You must be signed in to change notification settings - Fork 0
Best Practices
Guidelines for building maintainable, testable, and performant Agents.KT systems.
Each agent should have a single, well-defined responsibility. The type contract Agent<IN, OUT> enforces this structurally -- one input type, one output type. If an agent needs to do two unrelated things, split it into two agents and compose them with then.
// Good: each agent has a clear job
val parse = agent<RawText, Spec>("parse") { /* ... */ }
val generate = agent<Spec, Code>("generate") { /* ... */ }
val review = agent<Code, ReviewResult>("review") { /* ... */ }
val pipeline = parse then generate then review// Bad: god-agent doing everything
val doEverything = agent<RawText, ReviewResult>("do-everything") {
// 500 lines of skills, tools, knowledge...
}Skill descriptions are used by the LLM for skill selection and for generating readable documentation. Knowledge descriptions are used by the LLM to decide whether to fetch the content. Invest in clear, specific descriptions.
// Good: the LLM knows exactly what this skill does and when to use it
skill<String, String>("translate-to-french", "Translate the given English text to French. Preserves formatting and tone.") {
tools()
}
// Good: the LLM knows what this knowledge contains before loading it
knowledge("style-guide", "Preferred coding style -- immutability, naming, formatting conventions for Kotlin") {
loadFile("style.md")
}// Bad: vague descriptions that don't help the LLM route or decide
skill<String, String>("do-stuff", "Handles text") { tools() }
knowledge("data", "Some data") { loadFile("data.txt") }Always set maxTurns on agentic agents. An LLM can loop indefinitely calling tools without converging on an answer. A budget is your safety net.
agent<String, String>("researcher") {
model { ollama("qwen2.5:7b") }
budget { maxTurns = 10 } // fail fast rather than spin forever
// ...
}See Budget Controls for details.
If you can determine the correct skill from the input without calling the LLM, use skillSelection { }. It is deterministic, free, and fast.
skillSelection { input ->
when {
input.startsWith("TRANSLATE:") -> "translate"
input.startsWith("SUMMARIZE:") -> "summarize"
else -> "general"
}
}Fall back to LLM routing only when the decision genuinely requires understanding the input's meaning.
For unit tests, inject a mock ModelClient instead of hitting a real Ollama server. This makes tests fast, deterministic, and CI-friendly.
@Test
fun `calculator agent returns correct result`() {
val toolCalls = mutableListOf<String>()
val mockClient = ModelClient { messages ->
// Simulate the LLM calling tools and returning a result
val lastMsg = messages.last().content
if ("tool" !in messages.last().role) {
LlmResponse.ToolCalls(listOf(
ToolCall("add", mapOf("a" to 2.0, "b" to 3.0))
))
} else {
LlmResponse.Text("5")
}
}
val calc = agent<String, String>("calc") {
model { ollama("any"); client = mockClient }
budget { maxTurns = 5 }
tools {
tool("add", "Add a + b") { args ->
val r = (args["a"] as Number).toDouble() + (args["b"] as Number).toDouble()
toolCalls.add("add")
r
}
}
skills { skill<String, String>("solve", "Solve math") { tools("add") } }
}
val result = calc("2 + 3")
assertEquals("5", result)
assertTrue(toolCalls.contains("add"))
}When your agent's OUT type is not String, provide a transformOutput block so the framework can parse the LLM's text response.
skill<String, Int>("solve", "Evaluate expressions") {
tools("add", "multiply")
transformOutput { text ->
text.trim().toIntOrNull()
?: Regex("-?\\d+").find(text)?.value?.toInt()
?: error("No integer in: $text")
}
}For complex types, use @Generable annotations and let the framework's lenient parser handle it automatically. Use transformOutput when you need custom parsing logic.
The single-placement rule means each agent instance can be used once. When you need the same logic in multiple places, wrap it in a factory function:
fun createValidator(): Agent<Code, ValidationResult> =
agent<Code, ValidationResult>("validator") {
skills {
skill<Code, ValidationResult>("validate", "Validates code quality") {
implementedBy { code -> validate(code) }
}
}
}
// Use in multiple pipelines
val pipeline1 = parser then coder then createValidator()
val pipeline2 = importer then createValidator() then deployerWhen agents exchange complex data types through LLM boundaries, annotate them. The framework auto-generates prompt fragments, JSON schemas, and lenient deserializers.
@Generable("Code review assessment")
data class ReviewResult(
@Guide("Quality score from 0.0 to 1.0") val score: Double,
@Guide("One of: approved, needs-revision, rejected") val verdict: String,
@Guide("Specific issues found, or empty list") val issues: List<String>,
)Wire up onToolUse, onKnowledgeUsed, and onSkillChosen for logging, debugging, and test assertions.
agent<String, String>("agent") {
onSkillChosen { name -> logger.info("Skill: $name") }
onToolUse { name, args, result -> logger.info("Tool: $name($args) = $result") }
onKnowledgeUsed { name, content -> logger.info("Knowledge loaded: $name (${content.length} chars)") }
}An agent with 10 skills, 20 tools, and 15 knowledge entries is too complex. The LLM will struggle to select the right skill and use the right tools. Break it into focused agents and compose them.
Do not try to work around the placement rule by mutating agent internals. It exists to prevent shared mutable state bugs that are nearly impossible to debug. Use factory functions instead.
// DON'T: reuse instances
val shared = agent<String, String>("shared") { /* ... */ }
shared then a // placed
shared then b // CRASH -- and this is the correct behavior
// DO: use factory functions
fun makeAgent() = agent<String, String>("shared") { /* ... */ }
makeAgent() then a
makeAgent() then bKnowledge content can be logged via onKnowledgeUsed, sent to the LLM, and potentially exposed in error messages. Do not put API keys, passwords, or tokens in knowledge entries.
// DON'T
knowledge("credentials", "API keys") { "sk-abc123..." }
// DO: use environment variables or a secrets manager
knowledge("api-config", "API endpoint configuration") {
"endpoint: ${System.getenv("API_ENDPOINT")}"
}A loop without a termination condition or budget will run forever.
// DON'T: no termination condition
val infinite = refiner.loop { result -> result.draft } // always continues
// DO: have a clear exit condition
val bounded = refiner.loop { result ->
if (result.score >= 90 || result.iteration > 5) null else result.draft
}Also set budget { maxTurns = N } on agents inside loops as a safety net.
Agent<Any, Any> throws away all type safety. The compiler cannot check pipeline boundaries, and runtime errors become inevitable.
// DON'T
val unsafe = agent<Any, Any>("unsafe") { /* ... */ }
// DO: use specific types
val safe = agent<UserRequest, UserResponse>("handler") { /* ... */ }Agents should communicate through their typed inputs and outputs, not through shared mutable variables. If you need shared state, use MemoryBank.
// DON'T: shared mutable state
var sharedResult = ""
val a = agent<String, String>("a") {
skills { skill<String, String>("s", "s") {
implementedBy { sharedResult = it; it } // side effect
}}
}
val b = agent<String, String>("b") {
skills { skill<String, String>("s", "s") {
implementedBy { sharedResult } // reads from side effect
}}
}
// DO: use the pipeline's data flow
val pipeline = a then b // b receives a's output directlyIf you find yourself writing complex routing logic, multiple when branches, or long tool lists inside a single agent, it is a sign to decompose.
// DON'T: one agent routing everything
val monolith = agent<Request, Response>("monolith") {
skillSelection { input ->
when (input.type) {
"create" -> "create"
"read" -> "read"
"update" -> "update"
"delete" -> "delete"
"notify" -> "notify"
"report" -> "report"
// ... 20 more
}
}
// 20 skills...
}
// DO: compose focused agents
val router = agent<Request, TypedRequest>("router") { /* ... */ }
val handler = router.branch {
on<CreateRequest>() then createAgent
on<ReadRequest>() then readAgent
on<UpdateRequest>() then updateAgent
on<DeleteRequest>() then deleteAgent
}Live LLM tests are slow, non-deterministic, and require infrastructure. Use them sparingly for integration testing. Use mock ModelClient for unit tests.
// Unit tests: fast, deterministic
val mock = ModelClient { messages -> LlmResponse.Text("expected output") }
// Integration tests: tagged separately, run less frequently
@Tag("live-llm")
@Test
fun `integration test with real Ollama`() { /* ... */ }| Practice | Category |
|---|---|
| One agent, one job | DO |
| Meaningful descriptions | DO |
| Budget limits on agentic agents | DO |
| Predicate routing when possible | DO |
| Mock ModelClient for tests | DO |
| transformOutput for non-String outputs | DO |
| Factory functions for agent reuse | DO |
| @Generable/@Guide on shared types | DO |
| Observability hooks | DO |
| God-agents | DON'T |
| Ignoring single-placement rule | DON'T |
| Secrets in knowledge | DON'T |
| Unbounded loops | DON'T |
| Any as type parameter | DON'T |
| Shared mutable state | Anti-pattern |
| Overly complex single agents | Anti-pattern |
| Testing only with live LLMs | Anti-pattern |
See also: API Quick Reference | Cookbook | Troubleshooting | Architecture Overview
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