Status: Draft. Owner: kskobeltsyn. Date drafted: 2026-05-15. Target release: 0.4.6.
This is a premortem release note, not a postmortem: written before any code changes for v0.4.6 ship, naming what we're trying to accomplish, what could fail, and the success criteria we'll measure against. The actual RELEASE_NOTES.md will be written when the work lands — and we'll compare to this document for honesty.
Make kotlin-reflect actually optional at runtime, for real this time.
v0.4.4 over-promised "reflect-free runtime"; v0.4.5 walked it back honestly. v0.4.6 finishes the job by wrapping every remaining kotlin.reflect.full.* callsite so a consumer without kotlin-reflect on their runtime classpath either gets the KSP-generated fast path or graceful degradation, and proves it with a separate Gradle subproject whose classpath excludes kotlin-reflect.
If we cannot demonstrate the proof, we don't claim the win.
In:
- Wrap every direct
findAnnotation<>,primaryConstructor,sealedSubclasses,callBy,hasAnnotation,memberProperties,allSuperclassescall insrc/main/. - Move
kotlin-reflectfromimplementationback tocompileOnlyinagents-kt'sbuild.gradle.kts. - Add
agents-kt-no-reflect-test/Gradle subproject — a minimal consumer-shaped app whose dependency graph DOES NOT containkotlin-reflect. Subproject builds + runs a small smoke test exercising the framework's main surface (agent construction, agentic loop with a stubModelClient, typed-tool dispatch). Build is part of./gradlew check. - Update
CHANGELOG.mdandRELEASE_NOTES.mdhonestly with what works and what degrades.
Out:
- Generating skill auto-descriptions via KSP (would be cleaner than wrapping, but it's another full pass; deferred).
- KSP-side processing of MCP-discovered types (impossible by definition — MCP types arrive at runtime).
- Switching to bytecode-level annotation reading (gross, brittle, not worth it for one annotation).
Audited 2026-05-15. The non-wrapped sites still using kotlin.reflect.full.*:
| File:line | Function | What it does | Fix path |
|---|---|---|---|
Skill.kt:207-222 |
KClass.generableDescription() |
Per-skill auto-description, walks findAnnotation<Generable>, primaryConstructor.parameters, per-param findAnnotation<Guide>. Called by Skill.toLlmDescription. |
Wrap in ReflectionFallback.withReflection; returns "" if reflection unavailable. Skill description is degraded but agent still works. |
Skill.kt:152-153 |
Skill.toLlmDescription itself |
Calls inType.generableDescription() and outType.generableDescription(). |
Inherits the wrap via #207. No direct change needed. |
ToolDef.kt:170 |
typed-tool builder | require(argsClass.findAnnotation<Generable>() != null). Hard-fails at agent build if args class isn't @Generable. |
Use new helper hasGenerableAnnotation() that checks the KSP-generated cache first (true reflect-free for KSP-applied consumers), then wraps findAnnotation for the reflection fallback. The check returns false cleanly when reflect-unavailable AND no generated companion exists — and we then degrade the require() to a warning at build time (rare edge case; most consumers apply KSP). |
McpServer.kt:230 |
runtime MCP type detection | inType.findAnnotation<Generable>() != null to pick the ExposedSkill branch. |
Use the same hasGenerableAnnotation() helper. |
GenerableSupport.kt:367/391 |
toLlmInput branch |
Checks cls.findAnnotation<Generable>() != null to decide JSON serialization vs toString(). Runs per turn in the agentic loop. |
Same helper. Degrades to toString() when both KSP-absent and reflect-absent — same fallback we already have for non-@Generable types. |
BranchBuilder.kt:75 |
branch exhaustiveness check | sourceOutType.sealedSubclasses to verify every variant has a case. Fires at agent build for sealed-typed branches. |
Wrap. When reflect-unavailable, skip the exhaustiveness check; rely on compile-time when-exhaustiveness from the consumer's side. (Branch users almost always also have a typed when block — the framework's check is belt-and-braces.) |
GenerableSupport's other reflection sites (lines 139, 160, 166, 169, 272, 295, 310, 403-415, 491, 514, 542) are already inside reflection-fallback function bodies wrapped by ReflectionFallback.withReflection at the entry points (KClass.jsonSchema, toLlmDescription, constructFromMap, fromLlmOutput). No new wraps needed there.
-
The smoke-test subproject doesn't actually exclude
kotlin-reflect. Gradle resolution is sneaky;kotlin-reflectmight land transitively viakotlin-test,kotlinx-coroutines, or evenkotlin-stdlib-jdk8in older variants. Mitigation: assert at smoke-test runtime viaClass.forName("kotlin.reflect.full.KClasses")— if it resolves, the test fails with "kotlin-reflect leaked into the smoke-test classpath." This is the load-bearing assertion of the whole release. -
A wrap is forgotten. Easy to miss one — KSP didn't touch every site, and I'm doing the audit by grep. Mitigation: the smoke-test runs the same scenarios our integration tests cover (agent construction, typed-tool dispatch, branch, MCP exposure, agentic loop). If any of those hit an unwrapped path, the smoke test crashes with
LinkageError. Better than a consumer finding it in production. -
A wrapped fallback returns a value that's wrong-but-doesn't-crash. Worse than a crash because it silently produces bad behavior. Example:
hasGenerableAnnotation()returns false for a class that IS@Generablebut the consumer didn't apply KSP —toLlmInputfalls back totoString(), model sees a JVMObject@deadbeefinstead of structured JSON. Mitigation: the smoke-test consumer DOES apply KSP, so all@Generabletypes are detected. The "reflect-absent + KSP-absent" combo is documented as "consumer must apply at least one" — explicit error rather than silent corruption.
-
CHANGELOG / RELEASE_NOTES drift back into over-promising. Same trap as v0.4.4. Mitigation: finishing the release means first the smoke-test passes, then I write the release notes from facts. The premortem (this doc) lists the success criteria; if I can't tick all boxes, the release ships as a partial (and the notes say so).
-
A degraded behavior hits an existing test. Some unit test exercises a path that now degrades. Mitigation: the regular test suite still runs with
kotlin-reflecton the test classpath (viatestImplementation). Only the new smoke-test runs without. The reflection paths still work for everyone who has the dep. -
The
require()in ToolDef.kt downgrade from hard-fail to silent-skip changes consumer behavior. Old: typo'd Args class without@Generableblew up loudly. New (without KSP and reflect): silent skip, model sees Map<String,Any?> generic schema. Mitigation: keeprequire()strict when EITHER KSP cache OR reflection can answer; only degrade if both unavailable. Most consumers have at least one.
-
Verification-metadata churn. Adding a new subproject pulls dependencies (kotlinx-test maybe?). Mitigation: regenerate after the subproject is set up; commit.
-
CI takes longer because the new subproject adds compile time. Minor.
For v0.4.6 to ship with the "kotlin-reflect optional" claim, ALL of these must be true:
- Published POM does NOT contain
kotlin-reflect. Same check as v0.4.4 attempted;grep -c kotlin-reflect ~/.m2/repository/.../agents-kt-0.4.6.pom == 0. -
agents-kt-no-reflect-testsubproject builds AND its smoke test passes. The test:- Constructs an agent with one
@Generabledata class (@Generable data class Foo(val x: String, val y: Int)). - Calls the agent via a stub
ModelClient. - Verifies typed-tool dispatch works through the KSP-generated
constructFromMap. - Asserts
kotlin.reflect.full.KClassesis NOT loadable viaClass.forName— confirms reflection lib isn't on the classpath. - Failure of the assertion fails CI.
- Constructs an agent with one
- Main unit suite stays green (kotlin-reflect on the classpath; existing behavior preserved).
- At least one negative-path test: an agent with an
@Generableclass WITHOUT KSP applied, withkotlin-reflecton the classpath — verifies reflection fallback still works (that's our migration story for consumers who haven't adopted KSP). - CHANGELOG and RELEASE_NOTES name specific files / line numbers / contracts. No "reflect-free runtime" without proof; the smoke test IS the proof.
- Performance benchmarking (the JAR-size + cold-start wins are documented in #1701-#1704 release notes; v0.4.6 doesn't change those).
- v0.5.0 or a major bump — staying in 0.4.x; this is a contract-strengthening patch.
- The deferred CI workflow update from v0.4.5 (stashed locally; needs a workflow-scoped token; not part of v0.4.6 mainline work).
When the actual RELEASE_NOTES.md for 0.4.6 is written, every "kotlin-reflect optional" claim must point at one of:
- The smoke-test subproject's name (concrete proof).
- A specific wrapped callsite (concrete change).
- A specific success-criterion box from this document (concrete acceptance gate).
No floating claims. If the smoke test failed and we're shipping anyway, the release notes must say which assertion is currently red and why.
Written before the work; will be compared against the actual outcome before the release ships.