Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to Agents.KT are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-1.0, minor bumps may add new public API; existing API surface is preserved.

## [0.2.3] — 2026-05-04

Hotfix patch — single bug.

### Fixed
- `LenientJsonParser` no longer infinite-loops / OOMs on input where a JSON array or object contains a non-numeric, non-string, non-keyword character (e.g. `[abc]`, `{"k": foo}`, `[<html>]`). The previous `parseValue()` fell through to `parseNumber()` for any unrecognized character; `parseNumber()` returned 0 without advancing `pos`, so `parseArray()` / `parseObject()` spun forever, accumulating zeros until the heap was exhausted. The 0.2.2 `MAX_NESTING_DEPTH` guard (#854) only caught deep nesting, not zero-progress in a single loop body. Two-layer fix: `parseValue()` is now strict on the `else` branch (throws on unknown chars; the throw is caught by the top-level `parse(input)` try/catch and returns `null`, preserving the lenient contract); `parseArray()` and `parseObject()` carry zero-progress guards as defense-in-depth (#1028).

### Trigger path in the wild
Any LLM response or HTTP body containing `[…non-JSON content…]` would hit this — including non-Ollama responses on `localhost:11434` (HTML error pages, JSON error blobs with embedded brackets), small-model output that emitted markdown tables or pseudo-JSON, or test fixtures pointing the agent at unrelated services. Surfaced as `OutOfMemoryError` during agent invocation, several seconds after the request started.

## [0.2.2] — 2026-05-03

A feature-heavy patch release — REPL deployment, multi-agent JAR composition (Swarm), four new observability hooks, two new budget controls, classpath-resource prompt loading, and a slimmer README. Pre-1.0 patch bump — no breaking changes; all existing API surface preserved.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Topical guides:
```kotlin
// build.gradle.kts
dependencies {
implementation("ai.deep-code:agents-kt:0.2.2")
implementation("ai.deep-code:agents-kt:0.2.3")
}
```

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group = "ai.deep-code"
version = "0.2.2"
version = "0.2.3"

repositories {
mavenCentral()
Expand Down
23 changes: 22 additions & 1 deletion src/main/kotlin/agents_engine/generation/LenientJsonParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@ internal object LenientJsonParser {
'"' -> parseString()
't', 'f' -> parseBoolean()
'n' -> parseNull()
else -> parseNumber()
'-', in '0'..'9' -> parseNumber()
// #1028 — refuse to fall through to parseNumber on non-numeric chars.
// The old `else -> parseNumber()` returned 0 without advancing `pos`
// (empty digit run), causing parseArray/parseObject to spin forever
// on input like `[abc]`. Throw → caught by parse(input) → returns null.
else -> throw IllegalStateException(
"LenientJsonParser: unexpected character '${s[pos]}' at pos $pos"
)
}
}

Expand All @@ -97,6 +104,7 @@ internal object LenientJsonParser {
val map = linkedMapOf<String, Any?>()
skipWs()
while (pos < s.length && s[pos] != '}') {
val before = pos
skipWs()
val key = parseString()
skipWs()
Expand All @@ -106,6 +114,12 @@ internal object LenientJsonParser {
skipWs()
if (pos < s.length && s[pos] == ',') pos++
skipWs()
// #1028 — defense-in-depth: refuse to spin if no progress was made.
if (pos == before) {
throw IllegalStateException(
"LenientJsonParser: zero-progress at pos $pos in object"
)
}
}
if (pos < s.length) pos++ // consume '}'
return map
Expand All @@ -116,10 +130,17 @@ internal object LenientJsonParser {
val list = mutableListOf<Any?>()
skipWs()
while (pos < s.length && s[pos] != ']') {
val before = pos
list.add(parseValue())
skipWs()
if (pos < s.length && s[pos] == ',') pos++
skipWs()
// #1028 — defense-in-depth: refuse to spin if no progress was made.
if (pos == before) {
throw IllegalStateException(
"LenientJsonParser: zero-progress at pos $pos in array"
)
}
}
if (pos < s.length) pos++ // consume ']'
return list
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/agents_engine/mcp/McpRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import java.util.concurrent.CountDownLatch
*/
object McpRunner {

private const val VERSION = "0.2.2"
private const val VERSION = "0.2.3"

fun serve(
agent: Agent<*, *>,
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/agents_engine/runtime/LiveRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import kotlinx.coroutines.runBlocking
*/
object LiveRunner {

private const val VERSION = "0.2.2"
private const val VERSION = "0.2.3"

fun serve(
agent: Agent<String, *>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package agents_engine.generation

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

/**
* #1028 — `LenientJsonParser` infinite-loop / OOM regression guard.
*
* In 0.2.2, `parseValue()` fell through to `parseNumber()` on any unrecognized
* character. `parseNumber()` returned 0 without advancing `pos` when the input
* had no digits. `parseArray()` / `parseObject()` spun forever, growing the
* accumulator until the heap was exhausted.
*
* The fix has two layers:
* 1. `parseValue()` throws on a non-JSON-prefix char instead of falling
* through to `parseNumber()`. Caught by `parse(input)` → returns null.
* 2. `parseArray()` / `parseObject()` throw on zero-progress in their loop
* body — defense-in-depth against any future regression.
*
* These tests are bounded by `Thread`-side time guards because a regression
* would manifest as OOM (slow) rather than as an assertion failure.
*/
class LenientJsonParserOomGuardTest {

private fun assertCompletesIn(maxMs: Long, block: () -> Any?): Any? {
var result: Any? = null
val thread = Thread { result = block() }
thread.isDaemon = true
thread.start()
thread.join(maxMs)
if (thread.isAlive) {
// Don't try to interrupt — the bug spins on heap allocation, not on a
// checkpoint that responds to interrupts. Just fail the test.
throw AssertionError("LenientJsonParser did not complete within ${maxMs}ms — likely an infinite loop")
}
return result
}

@Test
fun `array with non-JSON content does not OOM`() {
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[abc]") })
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[<html>]") })
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[1, abc, 3]") })
}

@Test
fun `object with unquoted bare-word value does not OOM`() {
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("{\"k\": abc}") })
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("{\"x\": <html>, \"y\": 1}") })
}

@Test
fun `nested unquoted bare-word values do not OOM`() {
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("{\"outer\": [abc, def]}") })
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[[abc], [def]]") })
}

@Test
fun `legitimate parses still work`() {
assertEquals(listOf(1, 2, 3), LenientJsonParser.parse("[1, 2, 3]"))
assertEquals(mapOf("k" to "v"), LenientJsonParser.parse("{\"k\":\"v\"}"))
assertEquals(listOf("a", "b"), LenientJsonParser.parse("[\"a\", \"b\"]"))
assertEquals(listOf(1, "two", true, null), LenientJsonParser.parse("[1, \"two\", true, null]"))
assertEquals(emptyList<Any?>(), LenientJsonParser.parse("[]"))
assertEquals(emptyMap<String, Any?>(), LenientJsonParser.parse("{}"))
assertEquals(mapOf("nested" to listOf(1, 2)), LenientJsonParser.parse("{\"nested\":[1, 2]}"))
}

@Test
fun `negative numbers and decimals still parse`() {
assertEquals(listOf(-1, -2.5), LenientJsonParser.parse("[-1, -2.5]"))
assertEquals(mapOf("temp" to -40), LenientJsonParser.parse("{\"temp\":-40}"))
}
}
Loading