Skip to content

Commit 79dda05

Browse files
Skobeltsynclaude
andcommitted
docs: ksp-design.md — initiative roadmap, runtime-checks inventory, phasing
Foundational design note for the KSP / compile-time-validation initiative spanning issues #1015–#1023. Inventories all 72 runtime require/check/error sites across the codebase, classifies them into four buckets (DSL validations / freeze guards / @generable reflection / inherently runtime), and lays out a three-phase plan: typed tool refs first (Phase 1, no KSP needed), :agents-kt-ksp module second (Phase 2), kotlin-reflect drop third (Phase 3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5ead93b commit 79dda05

1 file changed

Lines changed: 148 additions & 0 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.

0 commit comments

Comments
 (0)