diff --git a/CHANGELOG.md b/CHANGELOG.md index d465b6b..b56b671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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}`, `[]`). 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. diff --git a/README.md b/README.md index ac6dd2e..5245c37 100644 --- a/README.md +++ b/README.md @@ -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") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 4bfa642..527fe2f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "ai.deep-code" -version = "0.2.2" +version = "0.2.3" repositories { mavenCentral() diff --git a/src/main/kotlin/agents_engine/generation/LenientJsonParser.kt b/src/main/kotlin/agents_engine/generation/LenientJsonParser.kt index e123b0a..771e5a7 100644 --- a/src/main/kotlin/agents_engine/generation/LenientJsonParser.kt +++ b/src/main/kotlin/agents_engine/generation/LenientJsonParser.kt @@ -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" + ) } } @@ -97,6 +104,7 @@ internal object LenientJsonParser { val map = linkedMapOf() skipWs() while (pos < s.length && s[pos] != '}') { + val before = pos skipWs() val key = parseString() skipWs() @@ -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 @@ -116,10 +130,17 @@ internal object LenientJsonParser { val list = mutableListOf() 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 diff --git a/src/main/kotlin/agents_engine/mcp/McpRunner.kt b/src/main/kotlin/agents_engine/mcp/McpRunner.kt index d610ae5..4b93ac9 100644 --- a/src/main/kotlin/agents_engine/mcp/McpRunner.kt +++ b/src/main/kotlin/agents_engine/mcp/McpRunner.kt @@ -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<*, *>, diff --git a/src/main/kotlin/agents_engine/runtime/LiveRunner.kt b/src/main/kotlin/agents_engine/runtime/LiveRunner.kt index 70dcc96..55b787a 100644 --- a/src/main/kotlin/agents_engine/runtime/LiveRunner.kt +++ b/src/main/kotlin/agents_engine/runtime/LiveRunner.kt @@ -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, diff --git a/src/test/kotlin/agents_engine/generation/LenientJsonParserOomGuardTest.kt b/src/test/kotlin/agents_engine/generation/LenientJsonParserOomGuardTest.kt new file mode 100644 index 0000000..5848ace --- /dev/null +++ b/src/test/kotlin/agents_engine/generation/LenientJsonParserOomGuardTest.kt @@ -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("[]") }) + 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\": , \"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(), LenientJsonParser.parse("[]")) + assertEquals(emptyMap(), 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}")) + } +}