Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

All notable changes to Agents.KT are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-1.0, minor bumps may add new public API; existing API surface is preserved.

## [Unreleased] — targeting 0.3.0

First leg of the **KSP / compile-time-validation initiative** described in `docs/ksp-design.md`. This release ships **typed tool refs** — Kotlin's type system catches `tools("typo")` mistakes that previously bombed at agent `validate()` (or in CI test runs). No KSP yet; that arrives in a later release.

### Binary compatibility

**Source-compatible** with 0.2.x — your code compiles unchanged (you'll see deprecation warnings on `tools("name")` calls, with a `ReplaceWith` hint to the typed form).

**NOT binary-compatible.** `tool(...)` builders changed return type `Unit → Tool<Args, Result>`. Consumers who upgrade the `agents-kt` jar without recompiling will hit `NoSuchMethodError` at first tool registration. Recompile against 0.3.0; no source changes required. If you depend on `agents-kt` from a published library, that library must also republish against 0.3.0. This is why the bump goes 0.2.x → 0.3.0 and not 0.2.x → 0.2.3.

### Added
- `Tool<Args, Result>` typed handle returned by every `tool(...)` builder overload. Phantom-typed wrapper around `ToolDef` whose type parameters propagate through the agent build (#1015).
- `Skill.tools(first: Tool<*, *>, vararg rest: Tool<*, *>)` — typed overload alongside the legacy stringly-typed form. Tool typos become red squiggles in IntelliJ instead of runtime errors at `validate()` (#1016).
- `Skill.tools()` — explicit no-argument overload that marks a skill agentic with no allowlisted tools (the model gets only memory + built-in tools). Disambiguates from the deprecated string-vararg form.
- `docs/ksp-design.md` — initiative roadmap, runtime-checks inventory (72 sites bucketed), three-phase plan.

### Changed
- README + `docs/model-and-tools.md` examples now show typed-ref form first; string form is documented only for built-in tools (`escalate`, `throwException`, `memory_*`).
- Internal test fixtures migrated to typed refs across 35+ files (#1017).

### Deprecated
- `Skill.tools(vararg names: String)` — soft-deprecated at warning level. Stays for built-in tools (`escalate`, `throwException`, `memory_*`) and runtime-discovered tool names (MCP); no removal planned pre-1.0.

## [0.2.3] — 2026-05-04

Hotfix patch — single bug.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ These APIs work in `main`, are unit-tested, and are exercised by integration tes
- **Skills with knowledge** — `skill { knowledge("key", "...") { } }`, lazy-loaded per call. See [docs/skills.md#shared-knowledge](docs/skills.md#shared-knowledge).
- **Agentic loop with tool calling** — multi-turn `chat ↔ tools` driven by the model. See [docs/model-and-tools.md](docs/model-and-tools.md).
- **Typed tools via `@Generable`** — `tool<Args, Result>(...)` with reflection-built JSON Schema; `additionalProperties: false`; sealed-discriminator validation (#658, #661, #699).
- **Typed tool refs in skill allowlists** — `tool(...)` returns a `Tool<Args, Result>` handle; `skill { tools(writeFile, compile) }` accepts handles, the IDE catches typos (#1015–#1017). The legacy `tools("name")` string form remains for built-in tools and runtime-discovered MCP names but produces a deprecation warning.
- **Per-skill tool authorization** — runtime allowlist; the prompt's "Available tools" listing is descriptive, the security boundary is the runtime check (#630). See [docs/model-and-tools.md#tool-authorization-model](docs/model-and-tools.md#tool-authorization-model).
- **Inline tool-call fallback** — auto-recovery when an Ollama model rejects native `tools` (e.g. `gemma3:4b`) — strips the field, injects inline JSON format prompt, retries (#702, #706). See [docs/model-and-tools.md#inline-tool-call-fallback-ollama-models-without-native-tool-support](docs/model-and-tools.md#inline-tool-call-fallback-ollama-models-without-native-tool-support).
- **Composition operators** — `then`, `/` (parallel), `*` and `forum { }` (multi-agent), `.loop {}`, `.branch {}` on sealed types. See [docs/composition.md](docs/composition.md).
Expand Down
30 changes: 19 additions & 11 deletions docs/model-and-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@ val calculator = agent<String, String>("calculator") {
prompt("You are a calculator. Use the provided tools to evaluate expressions step by step.")
model { ollama("gpt-oss:120b-cloud"); host = "localhost"; port = 11434; temperature = 0.0 }

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?>
lateinit var power: Tool<Map<String, Any?>, Any?>
tools {
tool("add", "Add two numbers. Args: a, b") { args -> num(args, "a") + num(args, "b") }
tool("subtract", "Subtract b from a. Args: a, b") { args -> num(args, "a") - num(args, "b") }
tool("multiply", "Multiply two numbers. Args: a, b") { args -> num(args, "a") * num(args, "b") }
tool("divide", "Divide a by b. Args: a, b") { args -> num(args, "a") / num(args, "b") }
tool("power", "Raise base to exponent. Args: base, exp") { args -> Math.pow(num(args, "base"), num(args, "exp")) }
add = tool("add", "Add two numbers. Args: a, b") { args -> num(args, "a") + num(args, "b") }
subtract = tool("subtract", "Subtract b from a. Args: a, b") { args -> num(args, "a") - num(args, "b") }
multiply = tool("multiply", "Multiply two numbers. Args: a, b") { args -> num(args, "a") * num(args, "b") }
divide = tool("divide", "Divide a by b. Args: a, b") { args -> num(args, "a") / num(args, "b") }
power = tool("power", "Raise base to exponent. Args: base, exp") { args -> Math.pow(num(args, "base"), num(args, "exp")) }
}

skills {
skill<String, String>("solve", "Evaluate arithmetic expressions using tools") {
tools("add", "subtract", "multiply", "divide", "power")
tools(add, subtract, multiply, divide, power)
}
}

Expand Down Expand Up @@ -100,8 +105,9 @@ A per-instance latch records the model's incapability, so subsequent `chat()` ca
val a = agent<String, String>("calc") {
// gemma3:4b doesn't support native tools — the fallback drives it via inline JSON
model { ollama("gemma3:4b"); host = "localhost"; port = 11434 }
tools { tool("evaluate", "Evaluate an arithmetic expression") { args -> eval(args["expression"]!!) } }
skills { skill<String, String>("calc", "Compute") { tools("evaluate") } }
lateinit var evaluate: Tool<Map<String, Any?>, Any?>
tools { evaluate = tool("evaluate", "Evaluate an arithmetic expression") { args -> eval(args["expression"]!!) } }
skills { skill<String, String>("calc", "Compute") { tools(evaluate) } }
}
a("Compute (2+3)*4") // works — agent invokes evaluate via inline tool call, returns "20"
```
Expand Down Expand Up @@ -194,12 +200,14 @@ assistant("Translate this to French: Hello world")
```kotlin
val compute = agent<String, Int>("calculator") {
model { ollama("gpt-oss:120b-cloud"); host = "localhost"; port = 11434; temperature = 0.0 }
lateinit var add: Tool<Map<String, Any?>, Any?>
lateinit var power: Tool<Map<String, Any?>, Any?>
tools {
tool("add", "Add two numbers. Args: a, b") { args -> num(args, "a") + num(args, "b") }
tool("power", "Raise base to exponent. Args: base, exp") { args -> Math.pow(num(args, "base"), num(args, "exp")) }
add = tool("add", "Add two numbers. Args: a, b") { args -> num(args, "a") + num(args, "b") }
power = tool("power", "Raise base to exponent. Args: base, exp") { args -> Math.pow(num(args, "base"), num(args, "exp")) }
}
skills { skill<String, Int>("solve", "Evaluate arithmetic expressions") {
tools("add", "power")
tools(add, power)
transformOutput { it.trim().toIntOrNull() ?: Regex("-?\\d+").find(it)?.value?.toInt() ?: error("No int in: $it") }
}}
}
Expand Down
25 changes: 24 additions & 1 deletion src/main/kotlin/agents_engine/core/Skill.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,30 @@ class Skill<IN, OUT>(
isAgentic = false
}

/** Marks this skill as LLM-driven; [names] are the tools the LLM may call. */
/**
* No-arg form — marks the skill agentic with no allowlisted tools (the LLM
* is restricted to memory and built-in tools only). Not deprecated; useful
* for skills that derive output via `transformOutput { }` or pure prompting.
*/
fun tools() {
checkNotFrozen()
isAgentic = true
toolNames = emptyList()
implementation = null
}

/**
* Marks this skill as LLM-driven; [names] are the tools the LLM may call.
*
* Soft-deprecated in favor of the typed `tools(first: Tool<*, *>, vararg rest)`
* overload (#1016) — the typed form catches typos at compile time. The string
* form remains for built-in tools (`escalate`, `throwException`, `memory_*`)
* that have no user-declared `Tool<*, *>` handle to capture.
*/
@Deprecated(
message = "Use the typed `tools(first, vararg rest)` overload that takes Tool<*, *> handles returned by `tool(...)` builders. The string form is kept for built-in tools (escalate, throwException, memory_*) and negative tests of validate().",
level = DeprecationLevel.WARNING,
)
fun tools(vararg names: String) {
checkNotFrozen()
isAgentic = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ class BranchAgenticIntegrationTest {
skills { skill<Positive, String>("pos", "Pos") { implementedBy { "ok" } } }
}
on<Negative>() then agent<Negative, String>("neg") {
lateinit var logIssue: agents_engine.model.Tool<Map<String, Any?>, Any?>
model { ollama("llama3"); client = handlerMock }
tools { tool("log_issue", "Log a support issue") { args -> toolCalls.add(args["msg"].toString()); "logged" } }
skills { skill<Negative, String>("neg", "Handle negative with logging") { tools("log_issue") } }
tools { logIssue = tool("log_issue", "Log a support issue") { args -> toolCalls.add(args["msg"].toString()); "logged" } }
skills { skill<Negative, String>("neg", "Handle negative with logging") { tools(logIssue) } }
}
on<Neutral>() then agent<Neutral, String>("neu") {
skills { skill<Neutral, String>("neu", "Neu") { implementedBy { "meh" } } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ class LoopAgenticIntegrationTest {
}

val worker = agent<String, Int>("worker") {
lateinit var increment: agents_engine.model.Tool<Map<String, Any?>, Any?>
model { ollama("llama3"); client = mock }
tools { tool("increment", "Increment a number") { args -> toolLog.add(args["n"].toString()); "ok" } }
tools { increment = tool("increment", "Increment a number") { args -> toolLog.add(args["n"].toString()); "ok" } }
skills { skill<String, Int>("work", "Do iterative work") {
tools("increment")
tools(increment)
transformOutput { it.trim().toInt() }
}}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ class ParallelExecutionTest {
val mockB = ModelClient { _ -> LlmResponse.Text("plain") }

val a = agent<String, String>("a") {
lateinit var reverse: agents_engine.model.Tool<Map<String, Any?>, Any?>
model { ollama("llama3"); client = mockA }
tools { tool("reverse", "") { args -> args["t"].toString().reversed() } }
skills { skill<String, String>("sa", "Reverse") { tools("reverse") } }
tools { reverse = tool("reverse", "") { args -> args["t"].toString().reversed() } }
skills { skill<String, String>("sa", "Reverse") { tools(reverse) } }
}
val b = agent<String, String>("b") {
model { ollama("llama3"); client = mockB }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,10 @@ class PipelineAgenticIntegrationTest {
val mock = ModelClient { _ -> responses.removeFirst() }

val reverser = agent<String, String>("reverser") {
lateinit var reverse: agents_engine.model.Tool<Map<String, Any?>, Any?>
model { ollama("llama3"); client = mock }
tools { tool("reverse", "Reverse a string") { args -> args["text"].toString().reversed() } }
skills { skill<String, String>("rev", "Reverse text via tool") { tools("reverse") } }
tools { reverse = tool("reverse", "Reverse a string") { args -> args["text"].toString().reversed() } }
skills { skill<String, String>("rev", "Reverse text via tool") { tools(reverse) } }
onToolUse { name, _, _ -> toolUses.add(name) }
}
val suffix = agent<String, String>("suffix") {
Expand Down
1 change: 1 addition & 0 deletions src/test/kotlin/agents_engine/core/AgentMemoryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class AgentMemoryTest {
}

@Test
@Suppress("DEPRECATION") // built-in memory_read — no user tool handle to capture
fun `user attempts to register a reserved memory tool name are rejected (#644)`() {
try {
agent<String, String>("a") {
Expand Down
10 changes: 6 additions & 4 deletions src/test/kotlin/agents_engine/core/ObservePipelineEventTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ class ObservePipelineEventTest {

val events = mutableListOf<PipelineEvent>()
val a = agent<String, String>("a") {
lateinit var echo: agents_engine.model.Tool<Map<String, Any?>, Any?>
model { ollama("llama3"); client = mock }
tools { tool("echo", "") { args -> args["text"].toString().uppercase() } }
skills { skill<String, String>("s", "s") { tools("echo") } }
tools { echo = tool("echo", "") { args -> args["text"].toString().uppercase() } }
skills { skill<String, String>("s", "s") { tools(echo) } }
}
a.observe { events += it }

Expand Down Expand Up @@ -159,9 +160,10 @@ class ObservePipelineEventTest {

val events = mutableListOf<PipelineEvent>()
val a = agent<String, String>("a") {
lateinit var noop: agents_engine.model.Tool<Map<String, Any?>, Any?>
model { ollama("llama3"); client = mock }
tools { tool("noop", "") { _ -> "ok" } }
skills { skill<String, String>("s", "s") { tools("noop") } }
tools { noop = tool("noop", "") { _ -> "ok" } }
skills { skill<String, String>("s", "s") { tools(noop) } }
}
a.observe { events += it }

Expand Down
2 changes: 2 additions & 0 deletions src/test/kotlin/agents_engine/core/SkillFreezeTest.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package agents_engine.core

import kotlin.test.Test
Expand Down
10 changes: 6 additions & 4 deletions src/test/kotlin/agents_engine/core/ToolMapEncapsulationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ class ToolMapEncapsulationTest {
@Test
fun `Agent toolMap is exposed as read-only Map (regression)`() {
val a = agent<String, String>("ok") {
tools { tool("foo", "x") { _ -> "f" } }
skills { skill<String, String>("s", "stub") { tools("foo") } }
lateinit var foo: agents_engine.model.Tool<Map<String, Any?>, Any?>
tools { foo = tool("foo", "x") { _ -> "f" } }
skills { skill<String, String>("s", "stub") { tools(foo) } }
}
// Read access still works
assertEquals("f", a.toolMap["foo"]!!.executor(emptyMap()))
Expand Down Expand Up @@ -55,11 +56,12 @@ class ToolMapEncapsulationTest {
fun `tools DSL rejects duplicate names (via registerTool guard)`() {
try {
agent<String, String>("a") {
lateinit var first: agents_engine.model.Tool<Map<String, Any?>, Any?>
tools {
tool("first", "x") { _ -> "ok" }
first = tool("first", "x") { _ -> "ok" }
tool("first", "x") { _ -> "dup" }
}
skills { skill<String, String>("s", "stub") { tools("first") } }
skills { skill<String, String>("s", "stub") { tools(first) } }
}
fail("expected duplicate rejection")
} catch (e: IllegalArgumentException) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package agents_engine.core

import kotlin.test.Test
Expand Down
1 change: 1 addition & 0 deletions src/test/kotlin/agents_engine/mcp/AgentFibonacciMcpTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class AgentFibonacciMcpTest {
budget { maxTurns = 5 }
skills {
skill<String, Int>("fib", "Generate next Fibonacci number via fib_next") {
@Suppress("DEPRECATION") // MCP tools discovered at runtime — names aren't compile-time refs
tools(*mcpTools.map { it.name }.toTypedArray())
transformOutput { it.trim().toIntOrNull() ?: Regex("\\d+").find(it)?.value?.toInt() ?: error("No int in: $it") }
}
Expand Down
1 change: 1 addition & 0 deletions src/test/kotlin/agents_engine/mcp/AgentMcpToolUseTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class AgentMcpToolUseTest {
budget { maxTurns = 5 }
skills {
skill<String, String>("answer", "Answer the user's question by calling a tool") {
@Suppress("DEPRECATION") // MCP tools discovered at runtime — names aren't compile-time refs
tools(*toolNames)
}
}
Expand Down
Loading
Loading