|
| 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. |
0 commit comments