-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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?
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) }- 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.
| Property | Value |
|---|---|
| LLM calls | 0 |
| Latency | Microseconds |
| Deterministic | Yes |
| Handles ambiguity | No -- you must code every case |
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:
- Builds a prompt listing all candidate skill names and descriptions.
- Sends it to the configured model.
- Parses the response to extract the chosen skill name.
- 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.
| 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 |
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") { /* ... */ }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.
- 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.
| Property | Value |
|---|---|
| LLM calls | 0 |
| Latency | Microseconds |
| Deterministic | Yes |
| Handles ambiguity | No -- always picks the first compatible skill |
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.
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 |
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).
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") { /* ... */ }
}
}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"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}")- Model & Tool Calling -- configure the model used for LLM routing
- Budget Controls -- limit the agentic loop after routing
-
Observability Hooks -- monitor routing decisions with
onSkillChosen - Tool Error Recovery -- handle errors after the skill is selected
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