Skip to content
koaks-aiPublic

About

Agentic AI framework for Kotlin Multiplatform.

Topics

Resources

Stars

13 stars

Watchers

0 watching

Forks

Repository files navigation

Koaks

🌐   English | 中文

The name "Koaks" is homophonic with "coax".

icon

🧩 Connect your tools, compose your logic, rule your agents.


✨ Highlights

  • One declarative DSL: agent { } assembles an immutable, reusable agent.
  • First-class tools: define a tool inline with a typed input; its JSON Schema is derived from your @Serializable class. Class-based tools, JVM @Tool annotations, and lazy MCP discovery are all supported.
  • Composable Skills: load reusable instructions, read-only resources, and tools from SKILL.md directories or custom sources.
  • Pluggable memory: sliding-window, summarizing, or vector memory. Each completed message is appended as it happens; cancel keeps already-written content.
  • Live run messaging: RunHandle.send() injects input before the next model request; tools { childAgents() } adds child collaboration tools. Step hooks edit memory, stop or pause execution. See messaging and hooks.
  • Structured output: agent.run<T>() returns a typed, decoded result.
  • Resilient by design: model fallbacks, retry/substitute error policies, step & token budgets, typed hooks, guardrails, and human approval.
  • Kotlin Multiplatform: JVM, JS, and macOS (Apple Silicon) from a single codebase.

🚀 Quick Start

1. Prerequisites

  • Kotlin 2.x / JDK 21 or higher
  • Gradle or Maven
  • An LLM endpoint + API key (any OpenAI-compatible provider, e.g. Qwen / DeepSeek, or a local Ollama)

Warning: The project is in a rapid iteration phase — the API may change at any time. JVM artifacts are compiled for Java 21; Java 17 runtimes are no longer supported.

2. Add Dependencies

The current published group is org.koaks. Pick the koaks-core runtime plus the provider module(s) you need.

For Node.js 20+ and Electron main/utility processes, the ESM-only @koaks/node facade can be built as a local tarball with ./gradlew :interop:node:npmPack. Its API, lifecycle guidance, and Electron examples are documented in interop/node/npm/README.md.

For snapshot versions, add the Central snapshot repository:

repositories {
    mavenCentral()
    maven("https://central.sonatype.com/repository/maven-snapshots/")
}

Gradle (Kotlin DSL)

// For Gradle projects — JVM or Kotlin Multiplatform — just add the artifact below.
// Gradle resolves the right platform variant automatically.
implementation("org.koaks:koaks-core:0.0.1-beta5")
implementation("org.koaks:model-openai:0.0.1-beta5")

// Optional add-ons:
// implementation("org.koaks:model-ollama:0.0.1-beta5")
// implementation("org.koaks:memory-summarizing:0.0.1-beta5")
// implementation("org.koaks:memory-vector:0.0.1-beta5")

Maven

<!-- For Maven you must pick the platform variant yourself.
     If you're unsure what that means, the JVM variant below is the one you want. -->
<dependency>
  <groupId>org.koaks</groupId>
  <artifactId>koaks-core-jvm</artifactId>
  <version>0.0.1-beta5</version>
</dependency>
<dependency>
  <groupId>org.koaks</groupId>
  <artifactId>koaks-model-openai-jvm</artifactId>
  <version>0.0.1-beta5</version>
</dependency>

3. Your First Agent

import kotlinx.coroutines.runBlocking
import org.koaks.framework.loop.agent
import org.koaks.framework.loop.use
import org.koaks.provider.qwen.qwen

fun main() = runBlocking {
    val agent = agent {
        id = "assistant"
        name = "assistant"
        instructions = "You are a concise, helpful assistant."
        model {
            qwen(
                baseUrl = "base-url",
                apiKey = "api-key",
                modelName = "qwen3-235b-a22b-instruct-2507",
            )
        }
    }

    agent.use {
        val result = it.run("What's the meaning of life?")
        println(result.text)
    }
}

run drives the agent to a terminal state and returns an AgentResult (.text, .usage, .isSuccess). agent.use { } closes the transport the agent owns when you're done.

Pure Java 21. The JVM artifacts also expose a facade that uses regular builders, CompletableFuture, JDK Flow.Publisher, virtual-thread tool callbacks, and Jackson for tool inputs and structured output:

import org.koaks.framework.loop.AgentResult;
import org.koaks.framework.annotation.Param;
import org.koaks.framework.annotation.Tool;
import org.koaks.java.Agent;
import org.koaks.java.qwen.Qwen;

record WeatherInput(String city) {}
record Weather(String city, int tempC) {}

final class WeatherTools {
    @Tool("Get weather for a city")
    public String getWeather(WeatherInput input) {
        return input.city() + ": cloudy";
    }
}

try (Agent agent = Agent.builder()
        .id("weather-agent")
        .instructions("Answer concisely and call tools when useful.")
        .model(Qwen.builder()
                .apiKey(System.getenv("QWEN_API_KEY"))
                .modelName("qwen-plus")
                .build())
        .tool(new WeatherTools())
        .maxSteps(20)
        .build()) {
    AgentResult result = agent.run("What is the weather in Shanghai?");
    Weather typed = agent.runStructured("Return Shanghai weather as JSON", Weather.class);
    System.out.println(result.getText() + " / " + typed);
}

A direct Java scalar parameter is wrapped into the object schema expected by model tool calling. It must declare a stable JSON name and description with @Param; parameter-name inference is intentionally not used:

@Tool("Get weather for a city")
public String getWeather(
        @Param(name = "city", description = "City name, for example Shanghai") String city
) {
    return city + ": cloudy";
}

Every public @Tool method on the supplied object is registered. Methods may take no arguments or one record/POJO argument, and may return String or CompletionStage<String>. Tools.sync/Tools.async remain available for explicit lambda-based registration. Use runAsync for a cancellable CompletableFuture, stream for a single-subscriber JDK publisher, and AgentRuntime.builder() when explicit concurrency and quota control is needed. A complete annotated example is available in JavaQuickStart.java.

Multi-segment & dynamic instructions. The instructions = "..." shorthand is fine for a fixed prompt. When you need several pieces — or parts that depend on run-time context — use the instructions { } block instead. Each dynamic { } segment is a suspend provider resolved once per run (returning null/blank omits it); all non-blank segments are joined with a blank line into the single system prompt.

agent {
    id = "dynamic-assistant"
    instructions {
        +"You are a concise, helpful assistant."     // static
        text("Always answer in English.")            // static (explicit form)
        dynamic { "Today is ${LocalDate.now()}." }    // resolved per run
        dynamic { lookupUserProfile(userId)?.let { "User prefs: $it" } }  // null → skipped
    }
    model { qwen(baseUrl = "...", apiKey = "...", modelName = "qwen3-235b-a22b-instruct-2507") }
}

Both forms coexist; if you set instructions = "..." and an instructions { } block on the same agent, the block wins. See DynamicInstructions.kt.

KV-cache tip: keep the resolved instructions stable across the turns of a conversation. Changing them mid-conversation invalidates the provider's prompt cache, since the system prompt sits at the front of every request.

4. Streaming Events

stream emits the loop's events as they happen — assistant text, the model's reasoning trace, tool calls, and lifecycle markers.

import org.koaks.framework.loop.AgentEvent

agent.use {
    it.stream("Explain Kotlin coroutines in two sentences.").collect { event ->
        when (event) {
            is AgentEvent.ReasoningDelta    -> print(event.text)          // model thinking
            is AgentEvent.TextDelta         -> print(event.text)          // final answer
            is AgentEvent.ToolCallRequested -> println("\n[tool] ${event.call.name}")
            is AgentEvent.ToolResult        -> println("[result] ${event.output}")
            is AgentEvent.Completed         -> println("\n[done]")
            is AgentEvent.Terminated        -> println("\n[terminated] ${event.reason}")
            is AgentEvent.Failed            -> println("\n[error] ${event.error.message}")
            is AgentEvent.StepCompleted     -> Unit
            is AgentEvent.Model             -> Unit // emitted only when LOSSLESS is requested
            else                            -> Unit
        }
    }
}

EventDetail.SEMANTIC is the default and preserves the compact event sequence above. Use EventDetail.LOSSLESS when a UI, recorder, or debugger needs the complete logical provider stream:

import org.koaks.framework.model.EventDetail

agent.stream("Explain the result", eventDetail = EventDetail.LOSSLESS).collect { event ->
    when (event) {
        is AgentEvent.TextDelta -> renderText(event.text, event.itemRef)
        is AgentEvent.ReasoningDelta -> renderReasoning(event.text, event.kind, event.itemRef)
        is AgentEvent.Model -> recordModelEvent(event.event, event.step, event.phase)
        else -> Unit
    }
}

In lossless mode, Responses, Chat Completions, and Anthropic emit one ModelEvent.ProviderEvent for every input WireFrame, before any derived semantic events. The provider event identifies the wire protocol and source and retains the logical payload unchanged. This guarantee starts after HTTP framing: it does not retain raw HTTP bytes, headers, SSE comments, or connection metadata. Text, reasoning, and completed tool calls continue to use their existing AgentEvent projections and are not duplicated inside AgentEvent.Model. ReasoningDelta.kind distinguishes SUMMARY from RAW, while itemRef associates streamed deltas with their final item.

Sensitive data and journal capacity: lossless payloads can contain model text, tool arguments, raw reasoning, signatures, and encrypted provider content. They are not redacted. Detailed events are recorded in the runtime's bounded per-run journal; the default capacity is 1024 events, so lossless runs can reach history_gap sooner.

Migration note: AgentEvent.Model is a new sealed subtype. Existing exhaustive when expressions over AgentEvent must add this branch (or an else branch).

Java callers select the same behavior through RunOptions.builder().eventDetail(EventDetail.LOSSLESS).build(). Node callers use { eventDetail: "lossless" }.

5. Tools

Define a tool inline with a typed input — its JSON Schema is generated from the @Serializable class, so the model knows exactly what arguments to send.

import kotlinx.serialization.Serializable
import org.koaks.framework.annotation.Param
import org.koaks.framework.loop.agent
import org.koaks.framework.loop.tool
import org.koaks.framework.loop.use
import org.koaks.provider.qwen.qwen

@Serializable
data object NoInput

@Serializable
data class WeatherInput(
    @Param(name = "city", description = "City name, for example Shanghai")
    val city: String,
)

fun main() = kotlinx.coroutines.runBlocking {
    val agent = agent {
        id = "weather-agent"
        name = "weather-agent"
        instructions = "Answer the user's questions, using tools when needed."
        model {
            qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") {
                params { parallelToolCalls = true }   // provider-level default
            }
        }
        params { temperature = 0.3 }                  // agent-level params override provider defaults

        tools {
            tool<NoInput>(
                name = "get_city",
                description = "Get the city where the user is located",
            ) { "Shanghai" }

            tool<WeatherInput>(
                name = "get_weather",
                description = "Get the weather for a specific city",
            ) { input -> "${input.city}: cloudy, with a high-wind warning." }
        }

        terminateAfter(maxSteps = 20)
    }

    agent.use {
        println(it.run("What's the weather where I am?").text)
    }
}

You can also register class-based tools (Tool<In>), JVM @Tool annotated functions, or connect an MCP server whose tools are discovered lazily:

tools {
    tool(MyClassBasedTool())   // implements Tool<In>
    mcp(myMcpGateway)          // tools discovered on first run via tools/list
}

6. Skills

Skills add reusable instructions, resources, and tools to an Agent. The built-in Markdown loader expects one direct child directory per Skill:

.agents/skills/
└── code-review/
    ├── SKILL.md
    └── references/conventions.md

Each SKILL.md starts with YAML front matter; its Markdown body becomes the Skill instructions:

---
name: code-review
description: Review Kotlin code using project conventions
---
Check correctness, concurrency, error handling, and public API compatibility.

Configure sources and optional selections when building the Agent:

val agent = agent {
    id = "reviewer"
    model { qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") }
    skills {
        source(".agents/skills")
        source(customLoader)       // implements SkillLoader

        use("code-review")       // any use(...) switches to a global allow-list
        use("project-conventions")
    }
}

agent.prepare()                   // optional: validate eagerly at service startup

source(path) uses the built-in MarkdownDirectorySkillLoader; source(loader) accepts a custom SkillLoader backed by memory, a database, a remote service, or another format. The default directory filesystem is available on JVM, Native, and Node.js. Browser JS applications should use source(customLoader) with HTTP, IndexedDB, bundled resources, or another application-owned backend. Core paths are literal and relative paths use the process working directory; the Koaks CLI additionally expands ~/ to the user's home. Without use(), every discovered Skill is enabled. Once any use() is present, only the selected Skills are loaded, in use() order. Discovery reads metadata first and full definitions only for enabled Skills. The first run or stream automatically prepares the Agent if prepare() was not called explicitly.

Skill resources remain lazy and read-only. Koaks exposes them to the model through a bounded, paginated resource tool that only accepts relative paths inside enabled Skills. Pages use a 1-based line/column cursor, so even a single very long line can be continued without losing content. Scripts in a Skill package are resources only and are never executed automatically.

7. Memory (Multi-Turn Conversations)

Attach memory to the agent, then pass the same thread to each run. ThreadId is owned by the Runtime, so different Agents can join the same conversation. Each completed message is written to Memory as it happens; a cancellation keeps the user turn and any partial assistant text already streamed.

Custom memory implements load(query) and append(item). Repeated item refs update the existing message, preserving its position. Structured output retains both intermediate assistant messages and the final JSON. none() retains messages only during the current run.

Pass temporary background directly as context = listOf(ModelItem.system("Background")) to Runtime calls or child spawning. The Runtime snapshots the list when spawning and adds it to each model request for that run, without writing it to Memory. Messages carry the same context list directly. Vector memory and shared context handles have been removed.

val agent = agent {
    id = "memory-assistant"
    model { qwen(baseUrl = "base-url", apiKey = "api-key", modelName = "qwen3-235b-a22b-instruct-2507") }
    memory {
        window(40)   // sliding-window; or none() / custom("provider-id", provider)
    }
}

agent.use {
    println(it.run("My name is Ada.", thread = "user-1001").text)
    println(it.run("What's my name?", thread = "user-1001").text)   // remembers across turns
}

All execution goes through an AgentRuntime. The convenient Agent.run, stream, and spawn methods share a lazily-created process-wide default Runtime. Create an explicit Runtime when you need a bounded lifecycle, scheduling, quotas, cancellation, or global observation:

AgentRuntime { maxConcurrency = 8 }.use { runtime ->
    val result = runtime.run(agent, "Continue", thread = "user-1001")
    val events = runtime.stream(agent, "Explain that", thread = "user-1001")
    val handle = runtime.spawn(agent, "Background task", thread = "user-1001")
}

AgentId identifies the immutable Agent definition, ThreadId identifies a long-lived conversation, and RunId identifies one execution instance. Top-level runs on the same Thread run FIFO; different Threads remain concurrent.

Tools and agent coroutines can create structured child runs with spawnChild. Failure propagates by default; supervised work can capture its own result, and ephemeral children run concurrently without creating persistent Thread bindings:

val result = spawnChild(
    worker,
    input = "Inspect the parser",
    failurePolicy = ChildFailurePolicy.CAPTURE,
    conversation = ChildConversation.Ephemeral,
).await()

The runtime API is organized around RunHandle, RunSnapshot, RunState, and AgentContext. Tools obtain the latter with currentAgentContext() and call ctx.send/receive/request/reply/publish/subscribe, ctx.spawn, or ctx.withResource. Application-level messaging is available through runtime.coordination; conversation inspection uses runtime.conversationSnapshot(ThreadId(...)). See the runtime migration guide for package and Node API changes.

8. Structured Output

Ask for a typed result and Koaks constrains the final step to valid JSON (native JSON mode when the model supports it, otherwise a schema-in-prompt fallback) and decodes it.

import org.koaks.framework.loop.run

@Serializable
data class CityWeather(val city: String, val tempC: Int)

agent.use {
    val w: CityWeather = it.run<CityWeather>("What's the weather in Shanghai right now?")
    println("${w.city}: ${w.tempC}°C")
}

9. Model Fallback & Resilience

agent {
    id = "resilient-assistant"
    model {
        // try Qwen first; fall back to Ollama only if the primary fails before any output
        qwen(baseUrl = "...", apiKey = "...", modelName = "qwen3-235b-a22b-instruct-2507")
            .fallback(ollama(baseUrl = "http://localhost:11434", modelName = "llama3.1"))
    }
    onError(org.koaks.framework.policy.ErrorPolicy.retryRetriable(maxRetries = 2))
    runBudget(maxTotalSteps = 30, maxTotalTokens = 100_000)   // whole-run global guard
}

Typed hooks can transform model requests/streams and tool calls/results. Push-style listeners (Tracing) remain observe-only:

agent {
    id = "guarded-assistant"
    hook {
        onModelCall {
            before { ctx -> ctx.request }
        }
        onToolCall {
            before { ctx -> if (ctx.call.name == "danger") Deny("blocked") else Proceed }
        }
    }
    install(org.koaks.framework.middleware.Tracing)
    // install(Guardrail(...)); install(HumanApproval(...))
}

🧱 Modules

Module Artifact Purpose
core koaks-core The agent runtime: DSL, loop, tools, Skills, memory, hooks/listeners, transport
qwen koaks-model-qwen Qwen / OpenAI-compatible provider
ollama koaks-model-ollama Local Ollama provider (NDJSON)
memory: summarizing koaks-memory-summarizing Summarizing long-conversation memory
memory: vector koaks-memory-vector Vector-store-backed memory
graph koaks-graph Graph orchestration (in progress)

🤝 Contributing Guide

Thank you for your interest in contributing! Code, documentation improvements, and issues are all welcome.

  1. Fork the repository
  2. Create a new branch (git checkout -b feature-xxx)
  3. Commit your changes (git commit -m 'Add new feature')
  4. Push your branch (git push origin feature-xxx)
  5. Open a Pull Request

Building from source requires JDK 21. Quick check: ./gradlew :tests:jvmTest.

💖 Acknowledgements

This project makes use of, but is not limited to, the following open-source projects:

Project Description
Kotlin The Kotlin Programming Language.
kotlin-logging Lightweight multiplatform logging framework for Kotlin. A convenient and performant logging facade.

About

Agentic AI framework for Kotlin Multiplatform.

Topics

Resources

Stars

13 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages