codeWithYoha logo
Code with Yoha
HomeArticlesAboutContact
Kotlin

Build Smart Agents in Kotlin: A Deep Dive into Embabel Framework

CodeWithYoha
CodeWithYoha
16 min read
Build Smart Agents in Kotlin: A Deep Dive into Embabel Framework

Introduction

The landscape of software development is rapidly evolving with the advent of Large Language Models (LLMs). While LLMs offer unprecedented capabilities in understanding and generating human language, integrating them effectively into applications to create truly intelligent, self-sufficient systems remains a significant challenge. This is where the concept of autonomous agents comes into play.

Autonomous agents are software entities designed to perceive their environment, deliberate on actions, and execute those actions to achieve specific goals, often interacting with LLMs as their 'brain'. Building such agents from scratch involves complex tasks like prompt engineering, context management, tool integration, and orchestrating multi-step reasoning.

This is precisely the problem the Embabel framework aims to solve for Kotlin developers. Embabel provides a high-level, idiomatic Kotlin API to abstract away the complexities of LLM interactions, allowing you to focus on the agent's logic and behavior. In this comprehensive guide, we'll explore how to leverage Embabel to create powerful, intelligent autonomous agents in Kotlin, covering everything from basic setup to advanced design patterns and real-world applications.

Prerequisites

To follow along with this guide, you'll need:

  • Kotlin Development Environment: IntelliJ IDEA is recommended, with Kotlin plugin installed.
  • JDK 17 or higher: Embabel leverages modern JVM features.
  • Gradle or Maven: For dependency management.
  • Basic understanding of Kotlin: Familiarity with coroutines, data classes, and extension functions will be beneficial.
  • LLM API Key: Access to an LLM provider like OpenAI, Azure OpenAI, or others. For this guide, we'll primarily use OpenAI examples, but Embabel supports various providers.

1. What are Autonomous Agents and Why Kotlin?

Defining Autonomous Agents

An autonomous agent is a system that operates independently, taking action based on its perceptions and internal goals. Key characteristics include:

  • Perception: The ability to gather information from its environment (e.g., user input, external data, sensor readings).
  • Deliberation: The ability to process perceived information, reason, plan, and decide on the next course of action, often powered by an LLM.
  • Action: The ability to execute operations in its environment (e.g., respond to a user, call an external API, modify data).
  • Goal-oriented: Designed to achieve specific objectives or tasks.

In the context of LLMs, an agent typically uses the LLM to perform complex reasoning, understand natural language, and generate human-like responses or commands.

Why Kotlin for Agent Development?

Kotlin offers several compelling advantages for building autonomous agents:

  • Conciseness and Readability: Kotlin's expressive syntax reduces boilerplate code, making agent logic easier to write and understand.
  • Type Safety and Null Safety: Reduces common runtime errors, leading to more robust agents.
  • Coroutines for Asynchronous Operations: Agent interactions with LLMs and external tools are inherently asynchronous. Kotlin coroutines provide a powerful and elegant way to handle these non-blocking operations, simplifying complex agent workflows.
  • JVM Ecosystem: Access to a vast array of existing Java libraries and tools.
  • Developer Experience: Modern language features and excellent tooling support. Embabel itself is written in Kotlin, providing a seamless experience.

2. Introducing the Embabel Framework

Embabel is a Kotlin-native framework designed to streamline the development of LLM-powered applications and autonomous agents. It abstracts away the low-level details of interacting with LLMs, focusing instead on the higher-level concepts needed for agentic behavior.

Core concepts in Embabel include:

  • Embabel Instance: The entry point to the framework, configured with an LLM provider.
  • Agent: Represents an intelligent entity capable of performing tasks, reasoning, and interacting with its environment.
  • Chat: Manages conversational history, allowing agents to maintain context over multiple turns.
  • Tool: External functions or services that an agent can invoke to perform specific actions (e.g., search the web, query a database, perform calculations).
  • KnowledgeContext: Provides specific, factual information to the LLM, reducing hallucinations and grounding responses.
  • EmbabelMode: Defines the operational mode of the agent (e.g., LLM_ONLY, TOOLS_ONLY, LLM_TOOLS for hybrid approaches).

Embabel's strength lies in its ability to enable agents to:

  • Manage Context: Automatically inject relevant information into LLM prompts.
  • Utilize Tools: Intelligently decide when and how to call external functions.
  • Maintain Conversation State: Remember previous interactions in a chat.
  • Handle Complex Prompts: Simplify prompt engineering through structured APIs.

3. Setting Up Your Embabel Project

Let's start by setting up a basic Kotlin project with Embabel dependencies. We'll use Gradle for this example.

Create a new Kotlin project (e.g., gradle init --type kotlin-application) and modify your build.gradle.kts.

// build.gradle.kts
plugins {
    kotlin("jvm") version "1.9.23"
    application
}

group = "com.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    // Embabel core dependencies
    implementation("com.danielwellman.embabel:embabel-core:0.7.0")
    implementation("com.danielwellman.embabel:embabel-support-openai:0.7.0") // For OpenAI LLM

    // Logging (optional but recommended)
    implementation("org.slf4j:slf4j-simple:2.0.7")

    // Kotlin Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")

    testImplementation(kotlin("test"))
}

kotlin {
    jvmToolchain(17)
}

application {
    mainClass.set("com.example.AppKt") // Replace with your main class
}

Replace 0.7.0 with the latest Embabel version if different. After syncing your Gradle project, you're ready to start coding.

To configure Embabel with your OpenAI API key, you can set it as an environment variable or pass it directly. The recommended way is via environment variables:

export OPENAI_API_KEY="YOUR_OPENAI_API_KEY"

4. Your First Embabel Agent: A Simple Greeter

Let's create a minimal agent that simply responds to a greeting. This demonstrates the core Embabel instance and a basic prompt.

// src/main/kotlin/com/example/App.kt
package com.example

import com.danielwellman.embabel.Embabel
import com.danielwellman.embabel.llm.openai.OpenAILLMProvider
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    // 1. Initialize Embabel with an LLM provider
    // OpenAILLMProvider automatically picks up OPENAI_API_KEY from environment variables
    val embabel = Embabel.builder()
        .llm(OpenAILLMProvider())
        .build()

    println("Embabel Greeter Agent is ready!")

    try {
        // 2. Create a simple agent
        val greeterAgent = embabel.agent("Greeter Agent")

        // 3. Make the agent say something
        val response = greeterAgent.say("Hello there! Introduce yourself as a friendly AI assistant.")

        println("Agent says: ${response.output}")

    } catch (e: Exception) {
        System.err.println("An error occurred: ${e.message}")
        e.printStackTrace()
    } finally {
        // 4. Close Embabel resources (important for graceful shutdown)
        embabel.close()
    }
}

When you run this, the agent will send the prompt to OpenAI and print its generated introduction. This simple example showcases the ease of getting an LLM response through Embabel.

5. Context Management and Knowledge Bases

One of the most critical aspects of building effective agents is managing context. LLMs have limited token windows, and providing relevant, up-to-date information is key to preventing hallucinations and ensuring accurate responses. Embabel's KnowledgeContext allows you to inject specific information.

Let's create an agent that can answer questions about a specific document.

package com.example

import com.danielwellman.embabel.Embabel
import com.danielwellman.embabel.llm.openai.OpenAILLMProvider
import com.danielwellman.embabel.knowledge.file.FileKnowledgeSource
import com.danielwellman.embabel.knowledge.file.loadKnowledge
import kotlinx.coroutines.runBlocking
import java.io.File

fun main() = runBlocking {
    // Create a dummy text file for our knowledge base
    val knowledgeFile = File("company_policy.txt").apply {
        writeText(
            """
            # Company Policy on Remote Work

            1. Employees are eligible for remote work after 6 months of employment.
            2. Remote work requests must be approved by a direct manager.
            3. All remote employees must maintain a secure internet connection.
            4. Office visits are required once a month for team meetings.
            """.trimIndent()
        )
    }

    val embabel = Embabel.builder()
        .llm(OpenAILLMProvider())
        .build()

    try {
        // 1. Create a KnowledgeContext from our file
        val companyPolicyKnowledge = embabel.loadKnowledge("CompanyPolicy", FileKnowledgeSource(knowledgeFile))

        // 2. Create an agent with the knowledge context
        val policyAgent = embabel.agent("Company Policy Bot") {
            knowledgeContext(companyPolicyKnowledge)
        }

        println("Company Policy Agent is ready. Ask me about remote work policies!")

        // 3. Ask a question related to the knowledge base
        val question1 = "Am I eligible for remote work immediately after joining?"
        val response1 = policyAgent.say(question1)
        println("Q: $question1\nA: ${response1.output}")

        val question2 = "How often do I need to visit the office if I work remotely?"
        val response2 = policyAgent.say(question2)
        println("Q: $question2\nA: ${response2.output}")

        val question3 = "What is the capital of France?" // Irrelevant question
        val response3 = policyAgent.say(question3)
        println("Q: $question3\nA: ${response3.output}") // LLM might state it doesn't know about this topic

    } catch (e: Exception) {
        System.err.println("An error occurred: ${e.message}")
        e.printStackTrace()
    } finally {
        embabel.close()
        knowledgeFile.delete() // Clean up the dummy file
    }
}

Embabel automatically embeds the content of company_policy.txt into the LLM's prompt when the agent responds, ensuring the LLM uses the provided information. This is crucial for building agents that are factual and domain-specific.

6. Empowering Agents with Tools

Autonomous agents gain significant power when they can interact with the outside world beyond just generating text. This is achieved through tools. A tool is essentially a function that an agent can invoke. Embabel allows you to define Kotlin functions as tools, and the LLM will intelligently decide when and how to call them based on the user's prompt.

Let's create an agent that can perform calculations using a simple calculator tool.

package com.example

import com.danielwellman.embabel.Embabel
import com.danielwellman.embabel.llm.openai.OpenAILLMProvider
import com.danielwellman.embabel.tools.AIAssisted
import com.danielwellman.embabel.tools.Tool
import kotlinx.coroutines.runBlocking
import kotlin.math.roundToInt

// Define a simple calculator tool
class CalculatorTool {

    @AIAssisted("Performs addition of two numbers")
    fun add(a: Int, b: Int): Int {
        println("Executing add($a, $b)")
        return a + b
    }

    @AIAssisted("Performs subtraction of two numbers")
    fun subtract(a: Int, b: Int): Int {
        println("Executing subtract($a, $b)")
        return a - b
    }

    @AIAssisted("Performs multiplication of two numbers")
    fun multiply(a: Int, b: Int): Int {
        println("Executing multiply($a, $b)")
        return a * b
    }

    @AIAssisted("Performs division of two numbers, returns an Int")
    fun divide(a: Int, b: Int): Int {
        if (b == 0) throw IllegalArgumentException("Cannot divide by zero")
        println("Executing divide($a, $b)")
        return (a.toDouble() / b).roundToInt()
    }
}

fun main() = runBlocking {
    val embabel = Embabel.builder()
        .llm(OpenAILLMProvider())
        .build()

    try {
        // 1. Create an instance of our tool
        val calculator = CalculatorTool()

        // 2. Register the tool with the agent
        val mathAgent = embabel.agent("Math Expert") {
            tools(Tool(calculator))
        }

        println("Math Expert Agent is ready. Ask me to do some calculations!")

        val question1 = "What is 15 plus 7?"
        val response1 = mathAgent.say(question1)
        println("Q: $question1\nA: ${response1.output}")

        val question2 = "Multiply 12 by 5."
        val response2 = mathAgent.say(question2)
        println("Q: $question2\nA: ${response2.output}")

        val question3 = "Can you tell me the result of 100 divided by 4?"
        val response3 = mathAgent.say(question3)
        println("Q: $question3\nA: ${response3.output}")

        val question4 = "What's the weather like today?" // Question not covered by tools
        val response4 = mathAgent.say(question4)
        println("Q: $question4\nA: ${response4.output}")

    } catch (e: Exception) {
        System.err.println("An error occurred: ${e.message}")
        e.printStackTrace()
    } finally {
        embabel.close()
    }
}

Notice the @AIAssisted annotation. This is how Embabel discovers which functions in your class are meant to be tools and generates the necessary descriptions for the LLM to understand their purpose and arguments. When you ask a calculation question, the LLM will first decide which tool to call, what arguments to pass, and then Embabel executes that Kotlin function. The result is then fed back to the LLM to formulate a natural language response.

7. Building Conversational Agents with Chat Sessions

Most real-world agents need to maintain state across multiple turns of a conversation. Embabel's Chat object facilitates this by automatically managing the conversational history. This ensures the LLM remembers previous statements and can build upon them.

Let's enhance our greeter agent to be conversational.

package com.example

import com.danielwellman.embabel.Embabel
import com.danielwellman.embabel.llm.openai.OpenAILLMProvider
import kotlinx.coroutines.runBlocking
import java.util.Scanner

fun main() = runBlocking {
    val embabel = Embabel.builder()
        .llm(OpenAILLMProvider())
        .build()

    try {
        // 1. Create a chat session
        val chat = embabel.chat("Friendly Chat Bot") {
            // Optional: Provide a system message to set the agent's persona
            systemChat(systemMessage = "You are a friendly and helpful AI assistant. Keep your responses concise.")
        }

        println("Chat Bot is ready. Type 'exit' to quit.")
        val scanner = Scanner(System.`in`)

        while (true) {
            print("You: ")
            val userInput = scanner.nextLine()

            if (userInput.lowercase() == "exit") {
                break
            }

            // 2. Send user input to the chat session
            val response = chat.say(userInput)
            println("Bot: ${response.output}")
        }

    } catch (e: Exception) {
        System.err.println("An error occurred: ${e.message}")
        e.printStackTrace()
    } finally {
        embabel.close()
    }
}

In this example, each chat.say(userInput) call sends the current user input along with the entire history of the conversation to the LLM. This allows the bot to remember previous turns, answer follow-up questions, and maintain a coherent dialogue.

8. Advanced Agent Design: Multi-step Reasoning

Autonomous agents often need to perform complex tasks that require multiple steps of reasoning, possibly involving several tool calls or sequential LLM prompts. While Embabel's core agent.say() and chat.say() methods handle a single turn, designing agents for multi-step reasoning often involves orchestrating these calls within your Kotlin code.

Consider an agent that needs to:

  1. Understand a user's request (LLM).
  2. Search for relevant information (Tool).
  3. Summarize the findings (LLM).
  4. Optionally, perform a calculation based on findings (Tool).
  5. Present a final answer (LLM).

Embabel helps by simplifying each individual LLM interaction and tool invocation, allowing you to focus on the orchestration logic. You would typically use a loop or a sequence of calls, passing the output of one step as input or context for the next.

package com.example

import com.danielwellman.embabel.Embabel
import com.danielwellman.embabel.llm.openai.OpenAILLMProvider
import com.danielwellman.embabel.tools.AIAssisted
import com.danielwellman.embabel.tools.Tool
import kotlinx.coroutines.runBlocking

// A mock web search tool
class WebSearchTool {
    @AIAssisted("Searches the web for a given query and returns a summary of results.")
    fun search(query: String): String {
        println("Executing web search for: \"$query\"")
        // In a real scenario, this would call a search API (e.g., Google, Bing)
        return when (query.lowercase()) {
            "latest news on ai" -> "Recent news highlights advancements in generative AI, ethical concerns, and increased investment in AI startups."
            "kotlin coroutines benefits" -> "Kotlin coroutines offer lightweight threads, structured concurrency, and simplified asynchronous programming, making code more readable and maintainable."
            else -> "No specific results found for \"$query\"."
        }
    }
}

fun main() = runBlocking {
    val embabel = Embabel.builder()
        .llm(OpenAILLMProvider())
        .build()

    try {
        val searchTool = WebSearchTool()
        val assistantAgent = embabel.agent("Research Assistant") {
            tools(Tool(searchTool))
            systemChat(systemMessage = "You are a helpful research assistant. If asked a question that requires external knowledge, use the web search tool. Then, summarize your findings.")
        }

        println("Research Assistant is ready. Ask me anything!")

        val question = "Tell me about the latest news regarding AI."
        println("You: $question")
        val response = assistantAgent.say(question)
        println("Assistant: ${response.output}")

        val followUp = "What are some benefits of Kotlin coroutines?"
        println("You: $followUp")
        val followUpResponse = assistantAgent.say(followUp)
        println("Assistant: ${followUpResponse.output}")

    } catch (e: Exception) {
        System.err.println("An error occurred: ${e.message}")
        e.printStackTrace()
    } finally {
        embabel.close()
    }
}

In this example, the systemChat message guides the LLM to use the search tool when appropriate. The agent then processes the search results and formulates a response. For more complex, multi-turn reasoning, you might chain chat.say() calls or use a dedicated state machine in your Kotlin code, where each state involves an Embabel interaction.

9. Real-world Use Cases and Best Practices

Autonomous agents built with Embabel can power a wide range of applications:

Real-world Use Cases

  • Intelligent Customer Support: Agents that can answer FAQs, troubleshoot common issues, and escalate complex queries to human agents, potentially integrating with CRM systems via tools.
  • Personalized Content Generation: Agents that generate blog posts, marketing copy, or code snippets based on user inputs and specific knowledge bases.
  • Data Analysis and Reporting: Agents that can query databases, summarize data, and even generate simple reports, using tools to interact with SQL or data visualization libraries.
  • Educational Tutors: Agents that provide explanations, answer student questions, and guide learning paths based on course materials.
  • Smart Home Automation: Agents that understand natural language commands and use tools to control smart devices.

Best Practices

  • Clear and Concise Prompts: Design your systemChat messages and direct prompts to be as clear and unambiguous as possible. Guide the LLM on its role, constraints, and expected output format.
  • Modular Tools: Break down complex functionalities into small, single-purpose tools. This makes it easier for the LLM to select the correct tool and for you to maintain them.
  • Robust Error Handling: Implement error handling for LLM API calls and tool invocations. Agents should gracefully handle failures, inform the user, or attempt recovery.
  • Context Relevance: Be judicious about what information you inject into the KnowledgeContext. Too much irrelevant data can confuse the LLM or exceed token limits.
  • Iterative Testing: Agent behavior can be non-deterministic. Test your agents extensively with various inputs and scenarios. Refine prompts and tool descriptions based on observed behavior.
  • Security and Privacy: Never hardcode API keys. Use environment variables or secure configuration management. Be mindful of sensitive data being passed to LLMs and ensure compliance with data privacy regulations.
  • Performance Monitoring: Monitor LLM response times and token usage. Optimize prompts and tool calls to reduce latency and cost.

10. Common Pitfalls and Troubleshooting

Developing with LLMs and autonomous agents comes with its own set of challenges:

  • Hallucinations: LLMs can generate factually incorrect information. Grounding agents with KnowledgeContext and providing reliable tools is crucial.
  • Tool Invocation Failures: The LLM might misunderstand when to call a tool, or pass incorrect arguments. Refine tool descriptions (@AIAssisted annotation) and provide clear examples in system messages.
  • Context Overflow: Exceeding the LLM's token limit. Regularly review your prompts, chat history length, and knowledge base size. Consider summarization techniques for long conversations or documents.
  • API Rate Limits: Frequent calls to LLMs can hit rate limits. Implement retry mechanisms with exponential backoff.
  • Non-deterministic Behavior: LLM responses can vary even with the same prompt. Design agents to be resilient to variations. Adjust temperature settings if your LLM provider allows for more consistent (lower temperature) or creative (higher temperature) responses.
  • Debugging Agent Behavior: It can be challenging to understand why an LLM made a particular decision. Embabel's logging (when configured with SLF4J) can show the actual prompts sent to the LLM, which is invaluable for debugging.

To troubleshoot, always check:

  1. Embabel Logs: See what prompts are being sent to the LLM and the raw responses.
  2. LLM Provider Dashboard: Check for API errors or usage limits.
  3. Prompt Clarity: Is the instruction unambiguous? Is the agent's role clearly defined?
  4. Tool Descriptions: Are the tool names and descriptions accurate and easy for the LLM to understand?

Conclusion

The Embabel framework in Kotlin offers a powerful and elegant solution for building sophisticated autonomous agents. By abstracting away the complexities of LLM integration, context management, and tool orchestration, Embabel empowers developers to focus on the unique logic and behavior of their intelligent applications.

From simple conversational bots to complex multi-step reasoning agents that interact with external systems, Embabel provides the foundational components to bring your AI ideas to life. As the field of generative AI continues to advance, frameworks like Embabel will be indispensable for creating robust, scalable, and truly intelligent software agents.

Now that you have a solid understanding of Embabel's core features and best practices, it's time to experiment, build, and innovate. The future of autonomous agents is here, and with Kotlin and Embabel, you're well-equipped to be a part of it.

CodewithYoha

Written by

CodewithYoha

Full-Stack Software Engineer with 5+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.

Related Articles