diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 7515962f..af7a37fe 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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` 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 = 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() } diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt new file mode 100644 index 00000000..d855eb5e --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt @@ -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 + } +}