Skip to content

Commit d689b23

Browse files
authored
Merge pull request #20 from Deep-CodeAI/feat/937-typed-input-serialization
feat(#937): serialize typed @generable agent input as JSON
2 parents 0d55ed4 + 829ce00 commit d689b23

3 files changed

Lines changed: 261 additions & 3 deletions

File tree

src/main/kotlin/agents_engine/generation/GenerableSupport.kt

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,100 @@ private fun KType.promptTypeName(): String = when (val cls = classifier) {
220220
else -> "String"
221221
}
222222

223+
// ─── LLM Input Serialization (typed → wire format) ──────────────────────────
224+
225+
/**
226+
* Serializes an agent input value to the form the LLM should see in a user message.
227+
*
228+
* Symmetric with [fromLlmOutput]: that takes JSON the model produced and reconstructs
229+
* a typed instance; this takes a typed instance and emits the wire format the model
230+
* should see.
231+
*
232+
* Rules:
233+
* - `null` → `"null"`
234+
* - `String` → the string as-is (no JSON quoting; matches the current behavior of
235+
* passing a free-form question/instruction to a model).
236+
* - `Boolean` / `Number` → JSON literal.
237+
* - `List<*>` → JSON array, recursing on each element.
238+
* - `Map<*, *>` → JSON object, recursing on each value.
239+
* - `@Generable` data class → JSON object with each constructor param as a field.
240+
* Sealed-class variants get a `"type":"VariantName"` discriminator (matches
241+
* [fromLlmOutput]'s expected shape).
242+
* - Anything else → `value.toString()` (backward-compatible fallback for plain
243+
* classes that don't opt into `@Generable`).
244+
*
245+
* See #937.
246+
*/
247+
fun toLlmInput(value: Any?): String = when (value) {
248+
null -> "null"
249+
is String -> value
250+
is Boolean -> value.toString()
251+
is Number -> value.toString()
252+
is List<*> -> value.joinToString(",", "[", "]") { jsonSerialize(it) }
253+
is Map<*, *> -> value.entries.joinToString(",", "{", "}") { (k, v) ->
254+
"\"${k.toString().escapeJson()}\":${jsonSerialize(v)}"
255+
}
256+
else -> {
257+
val cls = value::class
258+
if (cls.findAnnotation<Generable>() != null) {
259+
generableToJson(value, cls)
260+
} else {
261+
value.toString()
262+
}
263+
}
264+
}
265+
266+
/**
267+
* Internal recursive serializer — used by collections and Generable field
268+
* walking. Differs from [toLlmInput] in that strings get JSON-quoted (since
269+
* they're nested inside a JSON value).
270+
*/
271+
private fun jsonSerialize(value: Any?): String = when (value) {
272+
null -> "null"
273+
is String -> "\"${value.escapeJson()}\""
274+
is Boolean -> value.toString()
275+
is Number -> value.toString()
276+
is List<*> -> value.joinToString(",", "[", "]") { jsonSerialize(it) }
277+
is Map<*, *> -> value.entries.joinToString(",", "{", "}") { (k, v) ->
278+
"\"${k.toString().escapeJson()}\":${jsonSerialize(v)}"
279+
}
280+
else -> {
281+
val cls = value::class
282+
if (cls.findAnnotation<Generable>() != null) {
283+
generableToJson(value, cls)
284+
} else {
285+
// Non-Generable, non-primitive nested value — render via toString
286+
// and JSON-quote it. Lossy but consistent with falling back to
287+
// toString at the top level.
288+
"\"${value.toString().escapeJson()}\""
289+
}
290+
}
291+
}
292+
293+
private fun generableToJson(value: Any, cls: KClass<*>): String {
294+
val ctor = cls.primaryConstructor
295+
?: return "\"${value.toString().escapeJson()}\""
296+
val isSealedVariant = cls.allSuperclasses.any { it.isSealed }
297+
return buildString {
298+
append("{")
299+
var first = true
300+
if (isSealedVariant) {
301+
append("\"type\":\"${cls.simpleName}\"")
302+
first = false
303+
}
304+
ctor.parameters.forEach { param ->
305+
val name = param.name ?: return@forEach
306+
val prop = cls.memberProperties.find { it.name == name } ?: return@forEach
307+
if (!first) append(",")
308+
first = false
309+
@Suppress("UNCHECKED_CAST")
310+
val fieldValue = (prop as kotlin.reflect.KProperty1<Any, *>).get(value)
311+
append("\"${name.escapeJson()}\":${jsonSerialize(fieldValue)}")
312+
}
313+
append("}")
314+
}
315+
}
316+
223317
// ─── Lenient Deserialization ──────────────────────────────────────────────────
224318

225319
/**

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import agents_engine.core.Skill
55
import agents_engine.core.SkillRoute
66
import agents_engine.generation.constructFromMap
77
import agents_engine.generation.fromLlmOutput
8+
import agents_engine.generation.toLlmInput
89
import java.util.concurrent.atomic.AtomicReference
910
import kotlin.reflect.KClass
1011
import kotlinx.coroutines.Dispatchers
@@ -95,8 +96,10 @@ suspend fun <IN> executeAgentic(
9596
}
9697
if (systemContent.isNotBlank()) messages.add(LlmMessage("system", systemContent))
9798

98-
// User: serialized input
99-
messages.add(LlmMessage("user", input.toString()))
99+
// User: serialized input. Typed @Generable inputs become JSON; primitives
100+
// and Strings render literally; non-Generable types fall back to toString.
101+
// See #937 / GenerableSupport.toLlmInput.
102+
messages.add(LlmMessage("user", toLlmInput(input)))
100103

101104
var turns = 0
102105
var toolCalls = 0
@@ -185,7 +188,7 @@ suspend fun <IN> selectSkillByLlm(
185188

186189
val messages = listOf(
187190
LlmMessage("system", systemPrompt),
188-
LlmMessage("user", input.toString()),
191+
LlmMessage("user", toLlmInput(input)), // #937 — typed Generable inputs as JSON
189192
)
190193

191194
val client = config.client ?: OllamaClient(config.host, config.port, config.name, config.temperature)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package agents_engine.generation
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNotNull
6+
import kotlin.test.assertTrue
7+
8+
// Tests for #937 — toLlmInput serializes typed agent input as JSON when the
9+
// type is @Generable; falls back to toString() for plain types; preserves
10+
// literal String passthrough so free-form prompts don't get JSON-quoted.
11+
12+
@Generable("a single field")
13+
data class InputSingle(val v: String)
14+
15+
@Generable("multiple primitive fields")
16+
data class InputMulti(val name: String, val count: Int, val active: Boolean)
17+
18+
@Generable("contains a list")
19+
data class InputWithList(val tags: List<String>)
20+
21+
@Generable("contains a nested generable")
22+
data class InputWithNested(val inner: InputSingle, val label: String)
23+
24+
@Generable("sealed root")
25+
sealed interface Decision937 {
26+
@Generable("approved")
27+
data class Approved(val confidence: Double) : Decision937
28+
29+
@Generable("rejected")
30+
data class Rejected(val reason: String) : Decision937
31+
}
32+
33+
class PlainNonGenerable(val v: String) {
34+
override fun toString(): String = "PLAIN-$v"
35+
}
36+
37+
class LlmInputSerializationTest {
38+
39+
@Test
40+
fun `null serializes as JSON null literal`() {
41+
assertEquals("null", toLlmInput(null))
42+
}
43+
44+
@Test
45+
fun `String passes through unchanged (no JSON quoting)`() {
46+
// Free-form prompts must not get wrapped in quotes — that would
47+
// change what the LLM sees from the user message.
48+
assertEquals("hello world", toLlmInput("hello world"))
49+
}
50+
51+
@Test
52+
fun `String with quotes and backslashes still passes through unchanged`() {
53+
// Top-level String is opaque; the agent passed it as-is, the LLM
54+
// sees it as-is.
55+
val s = "she said \"hi\" \\ then left"
56+
assertEquals(s, toLlmInput(s))
57+
}
58+
59+
@Test
60+
fun `primitive Number renders as JSON literal`() {
61+
assertEquals("42", toLlmInput(42))
62+
assertEquals("3.14", toLlmInput(3.14))
63+
assertEquals("9999999999", toLlmInput(9_999_999_999L))
64+
}
65+
66+
@Test
67+
fun `Boolean renders as JSON literal`() {
68+
assertEquals("true", toLlmInput(true))
69+
assertEquals("false", toLlmInput(false))
70+
}
71+
72+
@Test
73+
fun `Generable single-field data class serializes as JSON object`() {
74+
val out = toLlmInput(InputSingle("hello"))
75+
assertEquals("""{"v":"hello"}""", out)
76+
}
77+
78+
@Test
79+
fun `Generable multi-field data class serializes with each constructor param`() {
80+
val out = toLlmInput(InputMulti(name = "alice", count = 3, active = true))
81+
// Field order follows constructor order.
82+
assertEquals("""{"name":"alice","count":3,"active":true}""", out)
83+
}
84+
85+
@Test
86+
fun `String fields inside Generable are JSON-escaped`() {
87+
val out = toLlmInput(InputSingle("she said \"hi\" then \\left"))
88+
assertEquals("""{"v":"she said \"hi\" then \\left"}""", out)
89+
}
90+
91+
@Test
92+
fun `Generable with List field renders the list as JSON array`() {
93+
val out = toLlmInput(InputWithList(listOf("a", "b", "c")))
94+
assertEquals("""{"tags":["a","b","c"]}""", out)
95+
}
96+
97+
@Test
98+
fun `Generable with nested Generable field recurses`() {
99+
val out = toLlmInput(InputWithNested(inner = InputSingle("inside"), label = "outer"))
100+
assertEquals("""{"inner":{"v":"inside"},"label":"outer"}""", out)
101+
}
102+
103+
@Test
104+
fun `Sealed Generable variant gets a type discriminator`() {
105+
val approved = toLlmInput(Decision937.Approved(0.92))
106+
assertTrue(
107+
approved.contains("\"type\":\"Approved\""),
108+
"sealed-variant must include type discriminator: $approved",
109+
)
110+
assertTrue(approved.contains("\"confidence\":0.92"), "must include field: $approved")
111+
112+
val rejected = toLlmInput(Decision937.Rejected("not enough data"))
113+
assertTrue(
114+
rejected.contains("\"type\":\"Rejected\""),
115+
"sealed-variant must include type discriminator: $rejected",
116+
)
117+
assertTrue(rejected.contains("\"reason\":\"not enough data\""))
118+
}
119+
120+
@Test
121+
fun `non-Generable plain class falls back to toString`() {
122+
val out = toLlmInput(PlainNonGenerable("xyz"))
123+
assertEquals("PLAIN-xyz", out)
124+
}
125+
126+
@Test
127+
fun `top-level List of Strings renders as JSON array`() {
128+
val out = toLlmInput(listOf("a", "b", "c"))
129+
assertEquals("""["a","b","c"]""", out)
130+
}
131+
132+
@Test
133+
fun `top-level List of Generables renders as JSON array of objects`() {
134+
val out = toLlmInput(listOf(InputSingle("x"), InputSingle("y")))
135+
assertEquals("""[{"v":"x"},{"v":"y"}]""", out)
136+
}
137+
138+
@Test
139+
fun `top-level Map renders as JSON object`() {
140+
val out = toLlmInput(mapOf("a" to 1, "b" to "two"))
141+
assertEquals("""{"a":1,"b":"two"}""", out)
142+
}
143+
144+
// Round-trip: serialized output should re-parse via fromLlmOutput.
145+
@Test
146+
fun `round-trip toLlmInput then fromLlmOutput reconstructs the original`() {
147+
val original = InputMulti(name = "alice", count = 3, active = true)
148+
val json = toLlmInput(original)
149+
val reconstructed = InputMulti::class.fromLlmOutput(json)
150+
assertNotNull(reconstructed)
151+
assertEquals(original, reconstructed)
152+
}
153+
154+
@Test
155+
fun `round-trip works for sealed variant`() {
156+
val original: Decision937 = Decision937.Rejected("insufficient")
157+
val json = toLlmInput(original)
158+
val reconstructed = Decision937::class.fromLlmOutput(json)
159+
assertEquals(original, reconstructed)
160+
}
161+
}

0 commit comments

Comments
 (0)