Building a Tool-Calling Calculator Agent with Koog
Open on GitHub Download .ipynb
In this mini-tutorial we’ll build a calculator agent powered by Koog tool-calling. You’ll learn how to: - Design small, pure tools for arithmetic - Orchestrate parallel tool calls with Koog’s multiple-call strategy - Add lightweight event logging for transparency - Run with OpenAI (and optionally Ollama)
We’ll keep the API tidy and idiomatic Kotlin, returning predictable results and handling edge cases (like division by zero) gracefully.
Setup
We assume you’re in a Kotlin Notebook environment with Koog available. Provide an LLM executor
%useLatestDescriptors
%use koog
val OPENAI_API_KEY = System.getenv("OPENAI_API_KEY")
?: error("Please set the OPENAI_API_KEY environment variable")
val executor = simpleOpenAIExecutor(OPENAI_API_KEY)
Calculator Tools
Tools are small, pure functions with clear contracts.
We’ll use Double
for better precision and format outputs consistently.
import ai.koog.agents.core.tools.annotations.Tool
// Format helper: integers render cleanly, decimals keep reasonable precision.
private fun Double.pretty(): String =
if (abs(this % 1.0) < 1e-9) this.toLong().toString() else "%.10g".format(this)
@LLMDescription("Tools for basic calculator operations")
class CalculatorTools : ToolSet {
@Tool
@LLMDescription("Adds two numbers and returns the sum as text.")
fun plus(
@LLMDescription("First addend.") a: Double,
@LLMDescription("Second addend.") b: Double
): String = (a + b).pretty()
@Tool
@LLMDescription("Subtracts the second number from the first and returns the difference as text.")
fun minus(
@LLMDescription("Minuend.") a: Double,
@LLMDescription("Subtrahend.") b: Double
): String = (a - b).pretty()
@Tool
@LLMDescription("Multiplies two numbers and returns the product as text.")
fun multiply(
@LLMDescription("First factor.") a: Double,
@LLMDescription("Second factor.") b: Double
): String = (a * b).pretty()
@Tool
@LLMDescription("Divides the first number by the second and returns the quotient as text. Returns an error message on division by zero.")
fun divide(
@LLMDescription("Dividend.") a: Double,
@LLMDescription("Divisor (must not be zero).") b: Double
): String = if (abs(b) < 1e-12) {
"ERROR: Division by zero"
} else {
(a / b).pretty()
}
}
Tool Registry
Expose our tools (plus two built-ins for interaction/logging).
val toolRegistry = ToolRegistry {
tool(AskUser) // enables explicit user clarification when needed
tool(SayToUser) // allows the agent to present the final message to the user
tools(CalculatorTools())
}
Strategy: Multiple Tool Calls (with Optional Compression)
This strategy lets the LLM propose multiple tool calls at once (e.g., plus
, minus
, multiply
, divide
) and then sends the results back.
If the token usage grows too large, we compress the history of tool results before continuing.
import ai.koog.agents.core.environment.ReceivedToolResult
object CalculatorStrategy {
private const val MAX_TOKENS_THRESHOLD = 1000
val strategy = strategy<String, String>("test") {
val callLLM by nodeLLMRequestMultiple()
val executeTools by nodeExecuteMultipleTools(parallelTools = true)
val sendToolResults by nodeLLMSendMultipleToolResults()
val compressHistory by nodeLLMCompressHistory<List<ReceivedToolResult>>()
edge(nodeStart forwardTo callLLM)
// If the assistant produced a final answer, finish.
edge((callLLM forwardTo nodeFinish) transformed { it.first() } onAssistantMessage { true })
// Otherwise, run the tools LLM requested (possibly several in parallel).
edge((callLLM forwardTo executeTools) onMultipleToolCalls { true })
// If we’re getting large, compress past tool results before continuing.
edge(
(executeTools forwardTo compressHistory)
onCondition { llm.readSession { prompt.latestTokenUsage > MAX_TOKENS_THRESHOLD } }
)
edge(compressHistory forwardTo sendToolResults)
// Normal path: send tool results back to the LLM.
edge(
(executeTools forwardTo sendToolResults)
onCondition { llm.readSession { prompt.latestTokenUsage <= MAX_TOKENS_THRESHOLD } }
)
// LLM might request more tools after seeing results.
edge((sendToolResults forwardTo executeTools) onMultipleToolCalls { true })
// Or it can produce the final answer.
edge((sendToolResults forwardTo nodeFinish) transformed { it.first() } onAssistantMessage { true })
}
}
Agent Configuration
A minimal, tool-forward prompt works well. Keep temperature low for deterministic math.
val agentConfig = AIAgentConfig(
prompt = prompt("calculator") {
system("You are a calculator. Always use the provided tools for arithmetic.")
},
model = OpenAIModels.Chat.GPT4o,
maxAgentIterations = 50
)
import ai.koog.agents.features.eventHandler.feature.handleEvents
val agent = AIAgent(
promptExecutor = executor,
strategy = CalculatorStrategy.strategy,
agentConfig = agentConfig,
toolRegistry = toolRegistry
) {
handleEvents {
onToolCall { e ->
println("Tool called: ${e.tool.name}, args=${e.toolArgs}")
}
onAgentRunError { e ->
println("Agent error: ${e.throwable.message}")
}
onAgentFinished { e ->
println("Final result: ${e.result}")
}
}
}
Try It
The agent should decompose the expression into parallel tool calls and return a neatly formatted result.
import kotlinx.coroutines.runBlocking
runBlocking {
agent.run("(10 + 20) * (5 + 5) / (2 - 11)")
}
// Expected final value ≈ -33.333...
Tool called: plus, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=10.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=20.0})
Tool called: plus, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=5.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=5.0})
Tool called: minus, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.minus(kotlin.Double, kotlin.Double): kotlin.String=2.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.minus(kotlin.Double, kotlin.Double): kotlin.String=11.0})
Tool called: multiply, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.multiply(kotlin.Double, kotlin.Double): kotlin.String=30.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.multiply(kotlin.Double, kotlin.Double): kotlin.String=10.0})
Tool called: divide, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.divide(kotlin.Double, kotlin.Double): kotlin.String=1.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.divide(kotlin.Double, kotlin.Double): kotlin.String=-9.0})
Tool called: divide, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.divide(kotlin.Double, kotlin.Double): kotlin.String=300.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.divide(kotlin.Double, kotlin.Double): kotlin.String=-9.0})
Final result: The result of the expression \((10 + 20) * (5 + 5) / (2 - 11)\) is approximately \(-33.33\).
The result of the expression \((10 + 20) * (5 + 5) / (2 - 11)\) is approximately \(-33.33\).
Try Forcing Parallel Calls
Ask the model to call all needed tools at once. You should still see a correct plan and stable execution.
runBlocking {
agent.run("Use tools to calculate (10 + 20) * (5 + 5) / (2 - 11). Please call all the tools at once.")
}
Tool called: plus, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=10.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=20.0})
Tool called: plus, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=5.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.plus(kotlin.Double, kotlin.Double): kotlin.String=5.0})
Tool called: minus, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.minus(kotlin.Double, kotlin.Double): kotlin.String=2.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.minus(kotlin.Double, kotlin.Double): kotlin.String=11.0})
Tool called: multiply, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.multiply(kotlin.Double, kotlin.Double): kotlin.String=30.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.multiply(kotlin.Double, kotlin.Double): kotlin.String=10.0})
Tool called: divide, args=VarArgs(args={parameter #1 a of fun Line_4_jupyter.CalculatorTools.divide(kotlin.Double, kotlin.Double): kotlin.String=30.0, parameter #2 b of fun Line_4_jupyter.CalculatorTools.divide(kotlin.Double, kotlin.Double): kotlin.String=-9.0})
Final result: The result of \((10 + 20) * (5 + 5) / (2 - 11)\) is approximately \(-3.33\).
The result of \((10 + 20) * (5 + 5) / (2 - 11)\) is approximately \(-3.33\).
Running with Ollama
Swap the executor and model if you prefer local inference.
val ollamaExecutor: PromptExecutor = simpleOllamaAIExecutor()
val ollamaAgentConfig = AIAgentConfig(
prompt = prompt("calculator", LLMParams(temperature = 0.0)) {
system("You are a calculator. Always use the provided tools for arithmetic.")
},
model = OllamaModels.Meta.LLAMA_3_2,
maxAgentIterations = 50
)
val ollamaAgent = AIAgent(
promptExecutor = ollamaExecutor,
strategy = CalculatorStrategy.strategy,
agentConfig = ollamaAgentConfig,
toolRegistry = toolRegistry
)
runBlocking {
ollamaAgent.run("(10 + 20) * (5 + 5) / (2 - 11)")
}
Agent says: The result of the expression (10 + 20) * (5 + 5) / (2 - 11) is approximately -33.33.
If you have any more questions or need further assistance, feel free to ask!