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:
+22
-7
@@ -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() }
|
||||
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user