Skip to content

Skill Selection and Routing

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Skill Selection and Routing

When an agent has multiple skills, something must decide which skill handles each input. Agents.KT provides three strategies, applied in a strict priority order.


The Problem

Consider an agent with three skills:

val support = agent<String, String>("support") {
    model { ollama("qwen2.5:7b") }

    skills {
        skill<String, String>("billing", "Handle billing questions") {
            // ...
        }
        skill<String, String>("technical", "Handle technical issues") {
            // ...
        }
        skill<String, String>("general", "Handle general inquiries") {
            // ...
        }
    }
}

When a user asks "Why was I charged twice?", the agent needs to route to billing. When they ask "My app crashes on startup", it should go to technical. The question is: who decides?


Strategy 1: Predicate

The most explicit strategy. You write a function that inspects the input and returns the skill name:

val support = agent<String, String>("support") {
    model { ollama("qwen2.5:7b") }

    skillSelection { input ->
        when {
            input.containsAny("bill", "charge", "invoice", "payment", "refund") -> "billing"
            input.containsAny("crash", "error", "bug", "install", "setup") -> "technical"
            else -> "general"
        }
    }

    skills {
        skill<String, String>("billing", "Handle billing questions") { /* ... */ }
        skill<String, String>("technical", "Handle technical issues") { /* ... */ }
        skill<String, String>("general", "Handle general inquiries") { /* ... */ }
    }
}

// Helper
fun String.containsAny(vararg words: String): Boolean =
    words.any { this.contains(it, ignoreCase = true) }

When to Use Predicates

  • The routing logic is simple and keyword-based.
  • You need deterministic, reproducible routing (important for testing).
  • You want zero LLM cost for routing.
  • The set of skills is stable and well-defined.

Characteristics

Property Value
LLM calls 0
Latency Microseconds
Deterministic Yes
Handles ambiguity No -- you must code every case

Strategy 2: LLM Routing

When the agent has a configured model and multiple candidate skills, the framework can use the LLM itself to choose the right skill. This happens automatically -- you do not need to declare anything.

val support = agent<String, String>("support") {
    model { ollama("qwen2.5:7b") }   // model is configured
    // No skillSelection {} block     // no predicate

    skills {
        // Multiple skills with descriptive names
        skill<String, String>("billing", "Handle billing and payment questions") { /* ... */ }
        skill<String, String>("technical", "Handle technical support and debugging") { /* ... */ }
        skill<String, String>("general", "Handle general inquiries and small talk") { /* ... */ }
    }
}

The framework calls selectSkillByLlm, which:

  1. Builds a prompt listing all candidate skill names and descriptions.
  2. Sends it to the configured model.
  3. Parses the response to extract the chosen skill name.

When to Use LLM Routing

  • The routing decision requires understanding natural language nuance.
  • The input space is too broad for keyword matching.
  • Skill descriptions are clear enough for the LLM to distinguish.

Characteristics

Property Value
LLM calls 1 (for routing) + N (for skill execution)
Latency Hundreds of milliseconds (one extra LLM call)
Deterministic No -- LLM may route differently for similar inputs
Handles ambiguity Yes -- understands intent, not just keywords

Improving LLM Routing

The quality of routing depends on skill descriptions. Be specific:

// Vague -- the LLM may confuse these
skill<String, String>("a", "Handle questions") { /* ... */ }
skill<String, String>("b", "Answer queries") { /* ... */ }

// Clear -- the LLM can distinguish easily
skill<String, String>("billing", "Handle questions about charges, invoices, refunds, and payment methods") { /* ... */ }
skill<String, String>("technical", "Debug crashes, installation failures, configuration errors, and API issues") { /* ... */ }

Strategy 3: First-Match

When there is no predicate and no model configured, the framework falls back to the simplest strategy: pick the first skill whose input type is compatible with the actual input.

val processor = agent<String, String>("processor") {
    // No model {} block
    // No skillSelection {} block

    skills {
        skill<String, String>("process", "Process the input") {
            implementedBy { input -> input.uppercase() }
        }
        skill<String, String>("fallback", "Fallback processing") {
            implementedBy { input -> "Fallback: $input" }
        }
    }
}

Here, process always wins because it is listed first and its type (String -> String) matches the input.

When to Use First-Match

  • The agent has only one skill (routing is trivial).
  • The agent is deterministic and does not need a model.
  • Skills are ordered by priority, and the first match is always correct.

Characteristics

Property Value
LLM calls 0
Latency Microseconds
Deterministic Yes
Handles ambiguity No -- always picks the first compatible skill

Priority Order

The three strategies form a strict priority chain:

1. Predicate     ─── if skillSelection {} is defined, use it
       |
       v (not defined)
2. LLM Routing   ─── if model is configured AND multiple candidates exist, use LLM
       |
       v (no model or single candidate)
3. First-Match   ─── pick the first type-compatible skill

This means:

  • A predicate always wins, even if a model is configured.
  • LLM routing only kicks in when there is no predicate and the agent has a model and there are multiple type-compatible skills.
  • First-match is the last resort.

Decision Table

skillSelection {} defined? model {} configured? Number of candidates Strategy Used
Yes Any Any Predicate
No Yes 2+ LLM Routing
No Yes 1 First-Match
No No Any First-Match

Observability

The onSkillChosen hook fires after skill selection, regardless of which strategy was used:

val support = agent<String, String>("support") {
    model { ollama("qwen2.5:7b") }

    skillSelection { input ->
        if (input.contains("bill", ignoreCase = true)) "billing" else "general"
    }

    skills {
        skill<String, String>("billing", "Billing questions") { /* ... */ }
        skill<String, String>("general", "General questions") { /* ... */ }
    }

    onSkillChosen { name ->
        println("Selected skill: $name")
        metrics.counter("skill.selected.$name").increment()
    }
}

This hook is useful for:

  • Logging which skill was chosen and why.
  • Collecting metrics on skill usage distribution.
  • Debugging routing issues in development.
  • Writing test assertions (see Observability Hooks).

Code Examples

Example 1: Predicate with Enum Mapping

enum class Department { SALES, ENGINEERING, HR, UNKNOWN }

fun classifyDepartment(input: String): Department {
    val lower = input.lowercase()
    return when {
        lower.containsAny("price", "demo", "contract", "discount") -> Department.SALES
        lower.containsAny("bug", "feature", "deploy", "api") -> Department.ENGINEERING
        lower.containsAny("vacation", "salary", "benefits", "onboarding") -> Department.HR
        else -> Department.UNKNOWN
    }
}

val router = agent<String, String>("company-router") {
    model { ollama("qwen2.5:7b") }

    skillSelection { input ->
        when (classifyDepartment(input)) {
            Department.SALES -> "sales"
            Department.ENGINEERING -> "engineering"
            Department.HR -> "hr"
            Department.UNKNOWN -> "general"
        }
    }

    skills {
        skill<String, String>("sales", "Handle sales inquiries") { /* ... */ }
        skill<String, String>("engineering", "Handle engineering requests") { /* ... */ }
        skill<String, String>("hr", "Handle HR questions") { /* ... */ }
        skill<String, String>("general", "Handle everything else") { /* ... */ }
    }
}

Example 2: LLM Routing with Detailed Descriptions

val codeAssistant = agent<String, String>("code-assistant") {
    model { ollama("qwen2.5:14b") }
    budget { maxTurns = 10 }

    // No skillSelection block -- LLM routing kicks in automatically

    skills {
        skill<String, String>("write-code", "Write new code from a description or specification") {
            tools("create_file", "read_file")
            // ... tool definitions
        }
        skill<String, String>("review-code", "Review existing code for bugs, style issues, and improvements") {
            tools("read_file", "annotate")
            // ... tool definitions
        }
        skill<String, String>("refactor", "Refactor existing code: rename, extract, restructure") {
            tools("read_file", "write_file")
            // ... tool definitions
        }
        skill<String, String>("explain", "Explain what a piece of code does in plain language") {
            tools("read_file")
            // ... tool definitions
        }
    }

    onSkillChosen { name ->
        println("LLM chose skill: $name")
    }
}

// The LLM reads the input and picks the best skill:
codeAssistant("Can you explain what the flatMap function does in this file?")
// Output: "LLM chose skill: explain"

Example 3: First-Match for Single-Skill Agents

val formatter = agent<String, String>("json-formatter") {
    // No model needed -- pure Kotlin skill
    skills {
        skill<String, String>("format", "Format JSON with indentation") {
            implementedBy { input ->
                val parsed = JsonParser.parse(input)
                JsonWriter.prettyPrint(parsed)
            }
        }
    }
}

// First-match selects "format" -- the only skill
formatter("{\"a\":1,\"b\":2}")

Next Steps

Clone this wiki locally