Skip to content

Commit 93022b7

Browse files
authored
Merge pull request #41 from Deep-CodeAI/feat/1015-typed-tool-handle
Feat/1015 typed tool handle
2 parents 506aa8b + 79dda05 commit 93022b7

3 files changed

Lines changed: 267 additions & 6 deletions

File tree

docs/ksp-design.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# KSP — Design Note: Lifting Runtime Checks to Compile Time
2+
3+
**Status:** Draft. **Owner:** kskobeltsyn. **Date:** 2026-05-03. **Target release:** 0.3.0 (additive).
4+
5+
## Problem
6+
7+
Agents.KT performs **72 runtime `require` / `check` / `error(...)` invocations** across `src/main/kotlin/agents_engine/` (counted 2026-05-03). Roughly half are *construction-time DSL validations* — they fire when the user calls `agent { ... }`, *before any LLM round-trip*. They protect against typos, name collisions, missing skills, and structural mistakes. They are correct, but they are paid:
8+
9+
1. **Every JVM start** of every consumer of the library.
10+
2. **At first invocation**, not at the consumer's compile time. A typo in `tools("writeFle")` bombs in CI test runs, not in the IDE.
11+
3. **Through `kotlin-reflect`** in many cases (`findAnnotation<Generable>()`, `KClass.isSubclassOf`), pulling a 3 MB dependency into the published artifact.
12+
13+
KSP is the natural lift. This note inventories what's actually liftable, what isn't, and proposes a phased path.
14+
15+
## Inventory
16+
17+
The 72 sites cluster into four buckets. The per-site references below use `file:line`.
18+
19+
### Bucket A — Construction-time DSL validations (KSP-liftable)
20+
21+
These run when the agent / skill / tools block is built. Their inputs are static at the consumer's compile time — the user wrote literal strings, literal classes, literal counts. KSP can see all of it.
22+
23+
| Check | Site(s) | Liftable? |
24+
|---|---|---|
25+
| Tool-name uniqueness within `tools { }` | `model/ToolDef.kt:69, 83, 94, 106, 132` | **Yes** — names are `String` literals at the call site; KSP can fold across a builder block |
26+
| Tool-name not in `RESERVED_MEMORY_TOOL_NAMES` | `core/Agent.kt:59`, `model/ToolDef.kt:51` | **Yes** — set is constant |
27+
| Skill-name uniqueness within agent | `core/Agent.kt:330` | **Yes** if skills are declared as properties; partial if skills are inline `skill<...>(name, ...)` with literal strings |
28+
| Skill→tool reference resolution (`tools("writeFile")`) | `core/Agent.kt:404` | **Yes** if KSP indexes `tool(...)` definitions and `tools(...)` references in the same module — the highest-leverage win |
29+
| `+autoTool("name")` reference resolution | `core/Agent.kt:410` | **Yes** — same mechanism |
30+
| Skill produces agent's OUT type | `core/Agent.kt:397` | **Yes** — KSP can read OUT-type parameter and skill `outType` declarations |
31+
| `@Generable` annotation present on typed-tool `Args` | `model/ToolDef.kt:137` | **Yes** — annotation presence is a KSP staple |
32+
| `Args` is not sealed | `model/ToolDef.kt:141` | **Yes** — KSP `KSClassDeclaration.classKind` |
33+
| Threshold in `0.0..1.0` (`onBudgetThreshold`) | `core/Agent.kt:177, 193` | **Yes**, when literal — `agent.onBudgetThreshold(0.8)` is liftable; an arg from a config `Double` is not |
34+
| `Loop.maxIterations > 0` | `composition/loop/Loop.kt:21` | **Yes**, when literal |
35+
| Forum: captain not in participants | `composition/forum/Forum.kt:147, 156, 170` | **Yes** if agent declarations are property-level |
36+
| Forum: `forumReturnAllowed` ⊆ all agents | `composition/forum/Forum.kt:184` | **Yes** — same |
37+
| Forum: no `forum_return` collision in toolMap | `composition/forum/Forum.kt:118` | **Yes** — same |
38+
| MCP DSL: duplicate server name | `mcp/AgentMcpDsl.kt:52` | **Yes** — names are literals |
39+
| MCP DSL: exactly one transport selected | `mcp/AgentMcpDsl.kt:78, 82, 87` | **Yes** — chosen by which method was called |
40+
| `Branch`: cases cover sealed hierarchy | `composition/branch/BranchBuilder.kt:77` | **Yes***and we already do compile-time exhaustiveness for `when` in Kotlin*. KSP can match that for our builder. |
41+
| Pipeline: duplicate stage names | `model/AgenticLoop.kt:59` | **Yes** |
42+
| `slash(name)` non-blank | `runtime/LiveShow.kt:439` | **Yes** when literal |
43+
44+
**Total in Bucket A:** ~30 of 72.
45+
46+
### Bucket B — Freeze-after-construction guards
47+
48+
These are post-construction mutator guards: `Agent.checkFrozen` (at `core/Agent.kt:133`) and `Skill.checkFrozen` (at `core/Skill.kt:40`) — fire if anyone tries to add a tool, skill, or knowledge entry *after* the agent finished building.
49+
50+
KSP doesn't help here directly — the right fix is a **type split**: `AgentBuilder` (mutators) and `Agent` (no mutators), where `agent { }` returns the latter. We've effectively done this via the `frozen` boolean and `@PublishedApi internal var`, but the type-level version is cleaner. Out of KSP scope; tracked separately.
51+
52+
**Total in Bucket B:** 2.
53+
54+
### Bucket C — `@Generable` reflection paths (already on roadmap)
55+
56+
Runtime walks via `kotlin-reflect`:
57+
58+
- `argsClass.constructFromMap(rawArgs)``model/ToolDef.kt:147`, `mcp/McpServer.kt:235`
59+
- Typed-output parsing — `model/AgenticLoop.kt:171, 202`
60+
- Forum return-value parsing — `composition/forum/Forum.kt:88, 94, 97`
61+
- `GenerableSupport` — schema gen, lenient parser, `toLlmDescription()`, `PartiallyGenerated<T>`
62+
63+
These are the **payloads** the existing roadmap entry (Phase 2 KSP processor) targets. They are larger than the validation lift but already scoped — see `docs/roadmap.md:39`, `README.md:67, 142`.
64+
65+
**Total in Bucket C:** ~25 reflection call sites + the entire `GenerableSupport` codepath.
66+
67+
### Bucket D — Inherently runtime
68+
69+
Cannot be lifted; the inputs only exist at runtime.
70+
71+
- LLM response parsing — `model/AgenticLoop.kt:171, 202, 259` (model-produced JSON / tool-calls)
72+
- MCP wire-format parsing — `mcp/McpClient.kt:46, 48, 53, 87, 89, 92, 94, 104, 123`, `mcp/HttpMcpTransport.kt:45, 49, 56`, `mcp/LineDelimitedMcpTransport.kt:49`
73+
- MCP server skill dispatch (`isAgentic`, deserialize from JSON) — `mcp/McpServer.kt:174, 182, 222, 235`
74+
- Agent placement in composition (already-placed guard) — `core/Agent.kt:234`
75+
- Skill-selection dispatch (selected name not in candidates) — `core/Agent.kt:288, 292, 300`
76+
- Branch dispatch with no `onElse``composition/branch/Branch.kt:37, 50`
77+
- Swarm `absorb` invariants (sibling shape) — `runtime/Swarm.kt:67, 70, 82`*liftable in principle if we knew sibling identities at compile time, but the whole point of Swarm is ServiceLoader discovery at runtime, so no.*
78+
79+
**Total in Bucket D:** ~15.
80+
81+
## What KSP buys us, by bucket
82+
83+
| Bucket | Sites | KSP impact | Effort |
84+
|---|---|---|---|
85+
| A — DSL validation | ~30 | **Move to compile-time errors** with red squiggles in IDE | High value, medium effort — needs DSL annotations + index |
86+
| B — Freeze guards | 2 | Zero (type-split is the fix) | Out of scope for KSP |
87+
| C — `@Generable` reflection | ~25 + GenerableSupport | **Eliminate `kotlin-reflect` for happy path**; typed `PartiallyGenerated<T>` | High value, high effort — *already roadmapped* |
88+
| D — Runtime parsing | ~15 | Zero (inputs are runtime) | N/A |
89+
90+
**Total liftable: ~55 of 72 (76%).** Of those, ~30 are pure DSL validation that doesn't need any deserialisation work — quick wins.
91+
92+
## DSL changes needed for Bucket A
93+
94+
The biggest unlock is **typed cross-references**. Right now:
95+
96+
```kotlin
97+
val writeFile = tool("write_file", "...") { ... } // returns ToolDef? No — it goes into a list
98+
skill<...> { tools("write_file") } // string lookup, validated at agent build()
99+
```
100+
101+
For KSP to resolve `"write_file"``tool("write_file", ...)`, both have to be in the same compilation unit and indexed by KSP. That works today, but it's awkward — KSP would need to:
102+
103+
1. Walk every call to `agents_engine.model.tool(...)` (or `tools.tool(...)`) and collect literal name strings.
104+
2. Walk every call to `Skill.tools(...)` and collect literal name strings.
105+
3. Cross-reference, fail compilation on unknowns.
106+
107+
The *cleaner* option is to switch to typed refs — already on the roadmap as `tools(writeFile, compile)` (see `README.md:69`). With typed refs, KSP isn't even strictly required for cross-reference checking — Kotlin's type system handles it. KSP is then narrower: just enforce `@Generable` shape, name-uniqueness within a block, threshold ranges.
108+
109+
Recommendation: **typed refs first, KSP-validation second**. KSP becomes the catcher for things the type system can't see (literal-only validations: name format, range bounds, sealed-coverage).
110+
111+
## Phasing
112+
113+
### Phase 0 — Inventory & feasibility (this note)
114+
- ✅ Catalog all 72 check sites
115+
- ✅ Bucket them
116+
- Decision point: typed-refs-first or KSP-first?
117+
118+
### Phase 1 — Typed tool/skill references (no KSP)
119+
- Refactor `tools("name")``tools(toolRef1, toolRef2)`
120+
- Make `tool(...)` return a typed handle (`Tool<Args, Result>`)
121+
- Skill `tools(...)` accepts handles, not strings
122+
- Compile-time error on typos; eliminates `core/Agent.kt:404` and `:410`
123+
- *No KSP module yet.* This is a DSL evolution.
124+
125+
### Phase 2 — KSP module: `:agents-kt-ksp`
126+
- New Gradle module — `org.jetbrains.kotlin.ksp` + `SymbolProcessorProvider`
127+
- Validate `@Generable` shape at compile time (annotation present, not sealed, primary constructor exists, all params primitive/`@Generable`/`List<>`)
128+
- Validate `agent { }` block static invariants (Bucket A residue: thresholds, loop counts, slash-name format)
129+
- Generate `*GeneratedSchema.kt` companion files — JSON Schema + `toLlmDescription()` + lenient parser, all stamped at compile time
130+
- Runtime path keeps reflection as fallback when KSP isn't applied → consumers without KSP still work
131+
132+
### Phase 3 — Drop `kotlin-reflect` from runtime classpath
133+
- Once KSP-generated schema is the default and reflection is the fallback, mark reflection path as opt-in
134+
- Move `kotlin-reflect` to `compileOnly` in the published POM
135+
- Document in `docs/generation.md`
136+
137+
## Open questions
138+
139+
1. **Library-level API:** how does the Gradle plugin tell KSP which classes to process? `@Generable` is the obvious entry; `agent { }` is harder because it's a builder block, not a declaration. Options: (a) generate per-`@Generable`-class schema + leave block validation to a separate KSP visitor, (b) annotate the agent function (`@Agent fun coder() = agent { ... }`) — probably (a) first, (b) later.
140+
2. **Multi-module:** if a consumer declares `@Generable Foo` in module A and uses it in module B's `agent { }`, KSP only sees within-module. We'd need to **publish** the generated schema next to the class, or fall back to reflection across module boundaries.
141+
3. **Incremental KSP:** must work with Gradle build cache and incremental compilation — design generated file naming + symbol IDs accordingly.
142+
4. **Native CLI / GraalVM:** roadmap also lists a GraalVM native binary. KSP-generated schemas help here a lot (no reflection metadata to register). Worth designing in lockstep.
143+
144+
## Recommendation
145+
146+
Start with **Phase 1 (typed refs)** before opening the KSP module. It eliminates the highest-frequency runtime failure mode (`tools("typo")`) without introducing a new build-tool dependency, and it simplifies what the eventual KSP processor has to do. KSP can then focus narrowly on `@Generable` schema gen — which is the actually-hard part — instead of being forced to also do cross-reference indexing across the whole agent block.
147+
148+
If Phase 1 lands cleanly, Phase 2 (the `:agents-kt-ksp` module) becomes a 0.3.0 release. Phase 3 (drop `kotlin-reflect`) is 0.4.0.

src/main/kotlin/agents_engine/model/ToolDef.kt

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ class ToolDef(
2828
internal set
2929
}
3030

31+
/**
32+
* Typed handle returned by every `tool(...)` builder overload. Wraps a
33+
* [ToolDef] with phantom type parameters that let `Skill.tools(...)` and
34+
* `+autoTool(...)` accept compile-time-checked references instead of
35+
* stringly-typed lookups (#1015 — KSP P1.1).
36+
*
37+
* `Args` is the deserialized input type for typed tools (the `@Generable`
38+
* data class), `Map<String, Any?>` for untyped tools. `Result` is the lambda's
39+
* return type. Both type parameters are erased at runtime — the [def]
40+
* underneath is the canonical runtime representation.
41+
*/
42+
@JvmInline
43+
value class Tool<Args, Result> @PublishedApi internal constructor(
44+
@PublishedApi internal val def: ToolDef,
45+
) {
46+
val name: String get() = def.name
47+
val description: String get() = def.description
48+
49+
override fun toString(): String = "Tool<${def.name}>"
50+
}
51+
3152
class ToolDefaultsBuilder {
3253
internal var errorHandler: ToolErrorHandler? = null
3354

@@ -64,21 +85,27 @@ class ToolsBuilder {
6485
defaultErrorHandler = builder.errorHandler
6586
}
6687

67-
fun tool(name: String, description: String, executor: (Map<String, Any?>) -> Any?) {
88+
fun tool(
89+
name: String,
90+
description: String,
91+
executor: (Map<String, Any?>) -> Any?,
92+
): Tool<Map<String, Any?>, Any?> {
6893
requireUserNotReservedToolName(name)
6994
require(defs.none { it.name == name }) {
7095
"Tool \"$name\" is already defined in this tools block. " +
7196
"Tool names must be unique."
7297
}
73-
defs.add(ToolDef(name = name, description = description, executor = executor))
98+
val def = ToolDef(name = name, description = description, executor = executor)
99+
defs.add(def)
100+
return Tool(def)
74101
}
75102

76103
fun tool(
77104
name: String,
78105
description: String,
79106
onError: OnErrorBuilder.() -> Unit,
80107
executor: (Map<String, Any?>) -> Any?,
81-
) {
108+
): Tool<Map<String, Any?>, Any?> {
82109
requireUserNotReservedToolName(name)
83110
require(defs.none { it.name == name }) {
84111
"Tool \"$name\" is already defined in this tools block. " +
@@ -87,9 +114,10 @@ class ToolsBuilder {
87114
val def = ToolDef(name = name, description = description, executor = executor)
88115
def.errorHandler = OnErrorBuilder().apply(onError).build()
89116
defs.add(def)
117+
return Tool(def)
90118
}
91119

92-
fun tool(name: String, block: ToolDefBuilder.() -> Unit) {
120+
fun tool(name: String, block: ToolDefBuilder.() -> Unit): Tool<Map<String, Any?>, Any?> {
93121
requireUserNotReservedToolName(name)
94122
require(defs.none { it.name == name }) {
95123
"Tool \"$name\" is already defined in this tools block. " +
@@ -99,6 +127,7 @@ class ToolsBuilder {
99127
builder.block()
100128
val def = builder.build()
101129
defs.add(def)
130+
return Tool(def)
102131
}
103132

104133
operator fun ToolDef.unaryPlus() {
@@ -127,7 +156,7 @@ class ToolsBuilder {
127156
name: String,
128157
description: String,
129158
crossinline executor: (Args) -> Result,
130-
) {
159+
): Tool<Args, Result> {
131160
requireUserNotReservedToolName(name)
132161
require(defs.none { it.name == name }) {
133162
"Tool \"$name\" is already defined in this tools block. " +
@@ -150,7 +179,9 @@ class ToolsBuilder {
150179
)
151180
executor(typed)
152181
}
153-
defs.add(ToolDef(name = name, description = description, executor = wrapped, argsType = argsClass))
182+
val def = ToolDef(name = name, description = description, executor = wrapped, argsType = argsClass)
183+
defs.add(def)
184+
return Tool(def)
154185
}
155186
}
156187

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package agents_engine.model
2+
3+
import agents_engine.core.agent
4+
import agents_engine.generation.Generable
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertSame
8+
9+
/**
10+
* #1015 — `tool(...)` builders return typed `Tool<Args, Result>` handles.
11+
*
12+
* The handle is the basis for typed `Skill.tools(...)` / `+autoTool(...)` overloads
13+
* landing in #1016, but it must already work as a standalone return value in 0.2.x:
14+
* existing call sites that ignore the return value continue to compile, and new
15+
* call sites that bind the handle to a `val` see the type parameters propagate.
16+
*/
17+
class ToolHandleTest {
18+
19+
@Generable("a typed tool's args")
20+
data class FetchArgs(val url: String, val timeoutMs: Int = 5_000)
21+
22+
@Test
23+
fun `untyped tool builder returns Tool with Map Args`() {
24+
var captured: Tool<Map<String, Any?>, Any?>? = null
25+
agent<String, String>("untyped-tool-handle") {
26+
tools {
27+
captured = tool("fetch", "Fetch a URL") { args -> args["url"]?.toString() ?: "missing" }
28+
}
29+
skills { skill<String, String>("s") { implementedBy { it } } }
30+
}
31+
val handle = checkNotNull(captured)
32+
assertEquals("fetch", handle.name)
33+
assertEquals("Fetch a URL", handle.description)
34+
assertEquals("Tool<fetch>", handle.toString())
35+
}
36+
37+
@Test
38+
fun `typed tool builder returns Tool with reified Args`() {
39+
var captured: Tool<FetchArgs, String>? = null
40+
agent<String, String>("typed-tool-handle") {
41+
tools {
42+
captured = tool<FetchArgs, String>("fetch_typed", "Fetch typed") { args ->
43+
"GET ${args.url} (${args.timeoutMs}ms)"
44+
}
45+
}
46+
skills { skill<String, String>("s") { implementedBy { it } } }
47+
}
48+
val handle = checkNotNull(captured)
49+
assertEquals("fetch_typed", handle.name)
50+
assertEquals("Tool<fetch_typed>", handle.toString())
51+
}
52+
53+
@Test
54+
fun `block-builder tool returns Tool handle`() {
55+
var captured: Tool<Map<String, Any?>, Any?>? = null
56+
agent<String, String>("block-tool-handle") {
57+
tools {
58+
captured = tool("audit") {
59+
description("Audit log writer")
60+
executor { _ -> "logged" }
61+
}
62+
}
63+
skills { skill<String, String>("s") { implementedBy { it } } }
64+
}
65+
val handle = checkNotNull(captured)
66+
assertEquals("audit", handle.name)
67+
assertEquals("Audit log writer", handle.description)
68+
}
69+
70+
@Test
71+
fun `Tool def points at the same ToolDef registered with the agent`() {
72+
var captured: Tool<Map<String, Any?>, Any?>? = null
73+
val a = agent<String, String>("ref-identity") {
74+
tools {
75+
captured = tool("ping", "ping") { _ -> "pong" }
76+
}
77+
skills { skill<String, String>("s") { implementedBy { it } } }
78+
}
79+
val handle = checkNotNull(captured)
80+
assertSame(a.toolMap["ping"], handle.def)
81+
}
82+
}

0 commit comments

Comments
 (0)