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.datetime.Clock
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import com.correx.core.journal.DecisionJournalRenderer
|
import com.correx.core.journal.DecisionJournalRenderer
|
||||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
|
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" }
|
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
|
||||||
return toolCalls.flatMap { toolCall ->
|
return toolCalls.flatMap { toolCall ->
|
||||||
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||||
val parameters = runCatching {
|
val parameters = parseToolArguments(toolCall.function.arguments)
|
||||||
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 request = ToolRequest(
|
val request = ToolRequest(
|
||||||
invocationId = invocationId,
|
invocationId = invocationId,
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -2111,3 +2107,22 @@ internal sealed interface InferenceResult {
|
|||||||
data class Failed(val reason: String) : InferenceResult
|
data class Failed(val reason: String) : InferenceResult
|
||||||
data object Cancelled : 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