-
Notifications
You must be signed in to change notification settings - Fork 0
Model and Tool Calling
Connect your agents to LLMs, define tools they can call, and understand the agentic loop that drives autonomous reasoning.
Every agent that needs LLM reasoning configures it through the model {} DSL block. The block produces a ModelConfig and, under the hood, a ModelClient that speaks HTTP to the LLM provider.
val agent = agent<String, String>("researcher") {
model {
ollama("qwen2.5:7b") // model name on the Ollama server
host = "localhost" // default
port = 11434 // default
temperature = 0.7 // default; lower = more deterministic
}
// ...
}ModelConfig is a plain data class -- no magic:
data class ModelConfig(
val name: String,
val provider: ModelProvider,
val temperature: Double = 0.7,
val host: String = "localhost",
val port: Int = 11434,
val client: ModelClient? = null // override for testing
)The ModelBuilder DSL maps directly to these fields:
| DSL Function / Property | ModelConfig Field | Purpose |
|---|---|---|
ollama("name") |
name, provider
|
Selects the Ollama provider and model |
host = "..." |
host |
Ollama server hostname |
port = 1234 |
port |
Ollama server port |
temperature = 0.3 |
temperature |
Sampling temperature |
client = myClient |
client |
Inject a custom or mock ModelClient
|
ModelClient is a fun interface -- a single abstract method:
fun interface ModelClient {
fun chat(messages: List<LlmMessage>): LlmResponse
}The framework ships OllamaClient, which implements ModelClient by POSTing to Ollama's /api/chat endpoint. You never need to instantiate it manually; the model {} block handles it. But you can replace it entirely for testing (see the last section).
Messages flowing in and out of the LLM use two simple types:
data class LlmMessage(
val role: String, // "system", "user", "assistant", "tool"
val content: String,
val toolCalls: List<ToolCall>? = null
)LlmResponse is a sealed type with two variants:
sealed interface LlmResponse {
data class Text(val content: String) : LlmResponse
data class ToolCalls(val calls: List<ToolCall>) : LlmResponse
}When the LLM wants to call a tool, it returns ToolCalls. When it has a final answer, it returns Text. The agentic loop reacts accordingly.
Tools are functions the LLM can invoke during its reasoning loop. You define them inside a skill's tools {} block.
tool(...) is declared inside the agent's tools { } block and returns a Tool<Args, Result> typed handle. Capture the handle with lateinit var and pass it positionally to skill { tools(handle1, handle2) } — this is the canonical, compile-time-checked form. The legacy string overload tools("name", ...) still works but is @Deprecated(level = WARNING). See Typed tool refs.
agent<String, String>("calculator") {
lateinit var add: Tool<Map<String, Any?>, Any?>
lateinit var multiply: Tool<Map<String, Any?>, Any?>
tools {
add = tool("add", "Add two numbers") { args ->
val a = args["a"] as Double
val b = args["b"] as Double
a + b
}
multiply = tool("multiply", "Multiply two numbers") { args ->
val a = args["a"] as Double
val b = args["b"] as Double
a * b
}
}
skills {
skill<String, String>("calculator", "Performs arithmetic") {
tools(add, multiply) // marks this skill as agentic
}
}
}Each tool() call creates a ToolDef:
class ToolDef(
val name: String,
val description: String,
val executor: (Map<String, Any?>) -> Any?
)The name and description are sent to the LLM as part of the system prompt so it knows which tools exist and what they do. The executor lambda runs on the JVM when the LLM calls the tool.
Arguments arrive as Map<String, Any?> parsed from the LLM's JSON output. Common patterns:
tool("search", "Search the web") { args ->
val query = args["query"] as String
val maxResults = (args["max_results"] as? Number)?.toInt() ?: 10
searchService.search(query, maxResults)
}Because LLMs can produce unexpected types (a number as a string, a missing key), defensive casting is good practice. For structured error handling, see Tool Error Recovery.
For tools whose arguments deserve a real type, the typed builder takes an @Generable Args class. The returned handle's type narrows to Tool<WriteFileArgs, WriteFileResult>, so the schema travels with the reference:
@Generable("Write a file to disk")
data class WriteFileArgs(
@Guide("Absolute path to write to") val path: String,
@Guide("UTF-8 file contents") val content: String,
)
@Generable data class WriteFileResult(val bytesWritten: Long)
agent<String, String>("writer") {
lateinit var writeFile: Tool<WriteFileArgs, WriteFileResult>
tools {
writeFile = tool<WriteFileArgs, WriteFileResult>("write_file", "Writes content to a file") { args ->
File(args.path).writeText(args.content)
WriteFileResult(args.content.length.toLong())
}
}
skills {
skill<String, String>("save", "Save a file") { tools(writeFile) }
}
}What you get:
-
Real provider schemas. The Ollama envelope's
parametersblock becomes the actual JSON Schema generated fromArgs::class.jsonSchema()— properproperties,required,@Guidestrings as field descriptions. The model can no longer guess; it sees the real shape. -
Typed executor body. Your lambda receives a constructed
Argsinstance, notMap<String, Any?>. No defensive casts. -
Correct routing of bad args. When the model emits arguments that don't deserialize (missing required field, wrong type), the framework routes through
onError { invalidArgs { raw, error -> ... } }— same path used for JSON-parse failures. The executor never runs with bad input. -
Defaults respected.
Argsfields with default values are NOT marked asrequiredin the schema; the model can omit them.
Issues that landed this: #634 (typed builder), #635 (provider schema generation), #636 (validation routing).
A skill becomes agentic -- meaning the LLM drives it -- the moment you call tools(handle, ...) inside the skill definition:
skill<String, String>("analyze", "Analyze data using tools") {
tools(fetchData, summarize) // <-- this makes it agentic
}Without tools(...), a skill is deterministic: it runs the implementedBy lambda directly, no LLM involved. With tools(...), the skill enters the agentic loop described below.
The agentic loop is the engine behind every LLM-driven skill. Here is what executeAgentic does step by step:
Step 1 ─ Build System Prompt
Concatenate: agent prompt + skill description + tool list (names & descriptions)
Step 2 ─ Add User Input
Append the user's input as a "user" message
Step 3 ─ Call LLM chat()
Send the full message list to the ModelClient
Step 4 ─ Handle Response
If LlmResponse.Text ──→ parse the content, return the result. Done.
If LlmResponse.ToolCalls ──→ go to Step 5
Step 5 ─ Execute Tool Calls
For each ToolCall in the response:
- Find the matching ToolDef by name
- Run its executor with the arguments
- Append the result as a "tool" message
Step 6 ─ Loop
Go back to Step 3 with the updated message list
Step 7 ─ Budget Check
Before each iteration, check maxTurns.
If exceeded, throw BudgetExceededException.
Each iteration of steps 3-6 is one "turn." The Budget Controls page explains how to cap these.
The LLM produces tool calls as structured objects:
data class ToolCall(
val name: String,
val arguments: Map<String, Any?>
)The framework matches name against your registered ToolDef instances and passes arguments to the executor.
The skill { tools(...) } declaration IS authorization, enforced at execution. Every agentic invocation builds a per-skill allowlist; the runtime refuses to execute any tool not in that allowlist. The system prompt's "Available tools" listing is descriptive — what the LLM is told it can call — but it is not the security boundary.
For a given agentic invocation, the allowlist is:
skill.toolNames (what the skill explicitly listed)
∪ agent.autoToolNames (auto-injected agent capabilities)
∪ memory_read / memory_write / memory_search (when memory { } is configured)
∪ skill.knowledge() entries (lazy knowledge providers, exposed as tools)
Anything outside that set is rejected with:
IllegalStateException: Tool 'X' is not allowed for skill 'Y'. Allowed: [a, b, c]
The error names the offending skill and lists only the allowed tool names — it does NOT leak the wider agent.toolMap (no information disclosure to the model or to logs).
- Hallucinated tool calls. A model trained on a different framework might emit a tool name that exists on the agent but wasn't listed by the current skill — refused.
- Cross-skill access. Two skills on the same agent with disjoint tool sets cannot reach each other's tools, even though both are registered globally on the agent.
- Jailbreak / prompt injection. The runtime enforcement is independent of what the prompt says, so prompt-injection content asking the model to "use the writeFile tool" cannot bypass the boundary if the current skill didn't list it.
-
Declare dangerous tools per-skill, not just per-agent. If
shell,writeFile,deploy, or any side-effecting / privileged tool is registered globally on the agent (tools { tool(...) }), every skill that lists it gets access. Prefer registering at the narrowest scope: only on the skill that needs it. -
tools(...)is typo-safe. Agent construction fails fast (IllegalArgumentException) if atools(...)argument doesn't match a registered tool — silent typos are not possible (issue #631). - Don't rely on the system prompt as a fence. The "Available tools" section the framework adds is for the LLM's benefit, not security. The runtime is the security.
This model was established in #630 (P0 fix); the validation in #631 ensures tools(...) references can't silently disappear; the budget-default change in #632 caps runaway loops at 8 turns by default.
In agentic skills, knowledge entries are exposed to the LLM as callable tools. The LLM decides when to fetch them -- it does not receive all knowledge upfront.
val agent = agent<String, String>("support-bot") {
model { ollama("qwen2.5:7b") }
lateinit var lookupFaq: Tool<Map<String, Any?>, Any?>
tools {
lookupFaq = tool("lookup_faq", "Search the FAQ database") { args ->
val query = args["query"] as String
faqDatabase.search(query)
}
}
skills {
skill<String, String>("answer", "Answer support questions") {
tools(lookupFaq)
knowledge("faq", "Frequently asked questions") {
loadText("/data/faq.txt")
}
knowledge("policies", "Company policies") {
loadText("/data/policies.txt")
}
}
}
}When the LLM calls a knowledge tool, the onKnowledgeUsed hook fires. See Observability Hooks for details.
A complete, runnable agent with arithmetic tools:
val calculator = agent<String, String>("calculator") {
prompt = "You are a calculator. Use the provided tools to compute the answer."
model {
ollama("qwen2.5:7b")
temperature = 0.1 // low temperature for deterministic math
}
budget { maxTurns = 5 }
lateinit var add: Tool<Map<String, Any?>, Any?>
lateinit var subtract: Tool<Map<String, Any?>, Any?>
lateinit var multiply: Tool<Map<String, Any?>, Any?>
lateinit var divide: Tool<Map<String, Any?>, Any?>
tools {
add = tool("add", "Add two numbers: a + b") { args ->
val a = (args["a"] as Number).toDouble()
val b = (args["b"] as Number).toDouble()
a + b
}
subtract = tool("subtract", "Subtract two numbers: a - b") { args ->
val a = (args["a"] as Number).toDouble()
val b = (args["b"] as Number).toDouble()
a - b
}
multiply = tool("multiply", "Multiply two numbers: a * b") { args ->
val a = (args["a"] as Number).toDouble()
val b = (args["b"] as Number).toDouble()
a * b
}
divide = tool("divide", "Divide two numbers: a / b") { args ->
val a = (args["a"] as Number).toDouble()
val b = (args["b"] as Number).toDouble()
require(b != 0.0) { "Division by zero" }
a / b
}
}
skills {
skill<String, String>("compute", "Perform calculations") {
tools(add, subtract, multiply, divide)
}
}
onToolUse { name, args, result ->
println("Tool: $name($args) = $result")
}
}
// Usage
val answer = calculator("What is (3 + 5) * 2?")
// Tool: add({a=3, b=5}) = 8.0
// Tool: multiply({a=8.0, b=2}) = 16.0
// answer: "The result of (3 + 5) * 2 is 16."Notice the flow: the LLM decides to call add first, gets 8.0, then calls multiply with 8.0 and 2, gets 16.0, and finally returns a text answer. Two turns, two tool calls, one final answer.
ModelClient is a fun interface, so you can replace it with a lambda. This means you can test agents without a running Ollama server.
val mockClient = ModelClient { messages ->
// Always return a text response
LlmResponse.Text("42")
}
val agent = agent<String, String>("test-agent") {
model {
ollama("unused") // model name is irrelevant for mocks
client = mockClient // injected via the DSL
}
lateinit var lookup: Tool<Map<String, Any?>, Any?>
tools {
lookup = tool("lookup", "Look something up") { args -> "data" }
}
skills {
skill<String, String>("answer", "Give an answer") {
tools(lookup)
}
}
}
val result = agent("question")
assertEquals("42", result)To test the full agentic loop, return ToolCalls on the first call and Text on the second:
var callCount = 0
val mockClient = ModelClient { messages ->
callCount++
when (callCount) {
1 -> LlmResponse.ToolCalls(
listOf(ToolCall("lookup", mapOf("query" to "test")))
)
else -> LlmResponse.Text("Final answer based on lookup")
}
}Capture the messages the agent sends to the LLM:
val capturedMessages = mutableListOf<List<LlmMessage>>()
val mockClient = ModelClient { messages ->
capturedMessages.add(messages.toList())
LlmResponse.Text("done")
}
agent("input")
// Verify system prompt was constructed correctly
val systemMsg = capturedMessages[0].first { it.role == "system" }
assertTrue(systemMsg.content.contains("lookup"))
// Verify user input was passed
val userMsg = capturedMessages[0].first { it.role == "user" }
assertEquals("input", userMsg.content)This pattern lets you write fast, deterministic unit tests for agent behavior without any LLM infrastructure. Combine with Observability Hooks for even richer test assertions.
- Tool Error Recovery -- handle malformed tool calls gracefully
- Skill Selection & Routing -- choose between multiple skills
- Budget Controls -- prevent runaway loops
- Observability Hooks -- monitor tool calls, knowledge use, and skill selection
Getting Started
Core Concepts
Composition Operators
LLM Integration
- Model & Tool Calling
- MCP Integration
- Agent Deployment Modes
- Swarm
- Tool Error Recovery
- Skill Selection & Routing
- Budget Controls
- Observability Hooks
Guided Generation
Agent Memory
Reference
- API Quick Reference
- Type Algebra Cheat Sheet
- Glossary
- Best Practices
- Cookbook & Recipes
- Troubleshooting & FAQ
- Roadmap
Contributing