fix(kernel): flatten tool-call array args to List so affected_paths isn't dropped

The orchestrator stringified every non-primitive tool argument, so a JSON array
arrived as its toString() and listParam (as? List<*>) read it empty — silently
dropping a task's affected_paths/acceptance_criteria. That left WriteScopeRule
(activeTask.scope empty -> no-op) and cite-before-claim with nothing to enforce
for agent-created tasks.

Extract parseToolArguments: a primitive array becomes List<String>; objects and
arrays-of-objects keep their JSON text for tools that re-parse them
(task_decompose). Unit-tested in ParseToolArgumentsTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 10:39:03 +00:00
parent 21e01da3ac
commit 5d61cca34c
2 changed files with 62 additions and 7 deletions
@@ -136,7 +136,9 @@ import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
@@ -623,13 +625,7 @@ abstract class SessionOrchestrator(
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
return toolCalls.flatMap { toolCall ->
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
val parameters = runCatching {
val element = Json.parseToJsonElement(toolCall.function.arguments)
val jsonObject = element as? kotlinx.serialization.json.JsonObject ?: return@runCatching emptyMap()
jsonObject.entries.associate { (k, v) ->
k to ((v as? kotlinx.serialization.json.JsonPrimitive)?.content ?: v.toString()) as Any
}
}.getOrElse { emptyMap() }
val parameters = parseToolArguments(toolCall.function.arguments)
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
@@ -2111,3 +2107,22 @@ internal sealed interface InferenceResult {
data class Failed(val reason: String) : InferenceResult
data object Cancelled : InferenceResult
}
/**
* Flattens an LLM tool call's JSON arguments into a [ToolRequest.parameters] map. A JSON array of
* primitives becomes a `List<String>` so list-param accessors read it — otherwise it arrived as a
* `.toString()` string and silently read empty, dropping e.g. a task's `affected_paths` (which in
* turn left the write-scope and cite-before-claim gates with nothing to enforce). A primitive
* becomes its content string; objects and arrays-of-objects keep their JSON text for tools that
* re-parse them (e.g. task_decompose). Malformed JSON yields an empty map.
*/
internal fun parseToolArguments(arguments: String): Map<String, Any> = runCatching {
val obj = Json.parseToJsonElement(arguments) as? JsonObject ?: return@runCatching emptyMap()
obj.entries.associate { (k, v) ->
k to when {
v is JsonPrimitive -> v.content
v is JsonArray && v.all { it is JsonPrimitive } -> v.map { (it as JsonPrimitive).content }
else -> v.toString()
} as Any
}
}.getOrElse { emptyMap() }
@@ -0,0 +1,40 @@
package com.correx.core.kernel.orchestration
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ParseToolArgumentsTest {
@Test
fun `a string array becomes a List so list-param accessors read it`() {
// Regression: arrays were stringified and read back empty, dropping a task's affected_paths.
val params = parseToolArguments("""{"affected_paths":["apps/web/**","build.gradle"]}""")
assertEquals(listOf("apps/web/**", "build.gradle"), params["affected_paths"])
}
@Test
fun `primitives become their content strings`() {
val params = parseToolArguments("""{"project":"webui","force":true,"limit":5}""")
assertEquals("webui", params["project"])
assertEquals("true", params["force"])
assertEquals("5", params["limit"])
}
@Test
fun `objects and arrays-of-objects are kept as JSON text for tools that re-parse them`() {
val params = parseToolArguments(
"""{"parent":{"title":"Epic"},"tasks":[{"title":"A"},{"title":"B"}]}""",
)
assertTrue((params["parent"] as String).contains("\"title\""))
// re-parseable JSON, not a Kotlin List#toString
assertTrue((params["tasks"] as String).trim().startsWith("["))
assertTrue((params["tasks"] as String).contains("\"title\":\"A\""))
}
@Test
fun `malformed json yields an empty map`() {
assertTrue(parseToolArguments("not json").isEmpty())
assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object
}
}