Skip to content

Best Practices

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Best Practices and Anti-Patterns

Guidelines for building maintainable, testable, and performant Agents.KT systems.


DO: One Agent, One Job

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

DO: Write Meaningful Descriptions

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

DO: Set Budget Limits

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.


DO: Use Predicate Routing When Possible

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.


DO: Test with Mock ModelClient

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

DO: Use transformOutput for Non-String Outputs

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.


DO: Use Factory Functions for Agent Reuse

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 deployer

DO: Annotate Types with @Generable and @Guide

When 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>,
)

DO: Use Observability Hooks

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

DON'T: Build God-Agents

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.


DON'T: Ignore the Single-Placement Rule

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 b

DON'T: Put Secrets in Knowledge

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

DON'T: Create Unbounded Loops

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.


DON'T: Use Any as a Type Parameter

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

Anti-Pattern: Shared Mutable State Between Agents

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 directly

Anti-Pattern: Overly Complex Single Agents

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

Anti-Pattern: Testing Only with Live LLMs

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`() { /* ... */ }

Summary Table

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

Clone this wiki locally