Skip to content

Model and Tool Calling

skobeltsyn edited this page May 4, 2026 · 4 revisions

Model Configuration and Tool Calling

Connect your agents to LLMs, define tools they can call, and understand the agentic loop that drives autonomous reasoning.


The model {} Block

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

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

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

LlmMessage and LlmResponse

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.


Defining Tools

Tools are functions the LLM can invoke during its reasoning loop. You define them inside a skill's tools {} block.

The tool() DSL

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.

Tool Arguments

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.

Typed Tools — tool<Args, Result>(...)

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 parameters block becomes the actual JSON Schema generated from Args::class.jsonSchema() — proper properties, required, @Guide strings as field descriptions. The model can no longer guess; it sees the real shape.
  • Typed executor body. Your lambda receives a constructed Args instance, not Map<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. Args fields with default values are NOT marked as required in the schema; the model can omit them.

Issues that landed this: #634 (typed builder), #635 (provider schema generation), #636 (validation routing).


Agentic Skills

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

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.

ToolCall

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.


Tool Authorization Model

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.

Allowlist composition

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

What this protects against

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

Practical guidance

  • 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 a tools(...) 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.

Reference

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.


Knowledge as Lazy Tools

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.


The Calculator Example

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.


Testing with Mock ModelClient

ModelClient is a fun interface, so you can replace it with a lambda. This means you can test agents without a running Ollama server.

Basic Mock

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)

Simulating Tool Calls

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

Asserting on Messages

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.


Next Steps

Clone this wiki locally