fix(inference): recover tool calls emitted as JSON in message content
Some local models write the tool call as a JSON blob in `content` instead of the native tool_calls array (toolCalls arrives empty). The orchestrator then treated the blob as a failing artifact and retry-looped to exhaustion. salvageToolCalls parses content as a ToolCallRequest (object or array) when tool_calls is empty; real artifact JSON lacks a `function` field so it's left untouched.
This commit is contained in:
+23
-5
@@ -11,6 +11,7 @@ import com.correx.core.inference.ProviderHealth
|
|||||||
import com.correx.core.inference.ResponseFormat
|
import com.correx.core.inference.ResponseFormat
|
||||||
import com.correx.core.inference.TokenUsage
|
import com.correx.core.inference.TokenUsage
|
||||||
import com.correx.core.inference.Tokenizer
|
import com.correx.core.inference.Tokenizer
|
||||||
|
import com.correx.core.inference.ToolCallRequest
|
||||||
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
@@ -62,6 +63,18 @@ private val json = Json {
|
|||||||
|
|
||||||
private val log = LoggerFactory.getLogger(LlamaCppInferenceProvider::class.java)
|
private val log = LoggerFactory.getLogger(LlamaCppInferenceProvider::class.java)
|
||||||
|
|
||||||
|
// salvageToolCalls parses a tool call the model wrote into `content` as raw JSON (single object
|
||||||
|
// or array) instead of the native tool_calls array. Real artifact JSON has no `function` field,
|
||||||
|
// so it fails to decode and is left alone — only genuine tool-call shapes are recovered.
|
||||||
|
internal fun salvageToolCalls(content: String?): List<ToolCallRequest> {
|
||||||
|
val s = content?.trim().orEmpty()
|
||||||
|
if (s.isEmpty() || (s.first() != '{' && s.first() != '[')) return emptyList()
|
||||||
|
return runCatching { json.decodeFromString<List<ToolCallRequest>>(s) }
|
||||||
|
.recoverCatching { listOf(json.decodeFromString<ToolCallRequest>(s)) }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
.filter { it.function.name.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("TooGenericExceptionCaught", "MagicNumber")
|
@Suppress("TooGenericExceptionCaught", "MagicNumber")
|
||||||
class LlamaCppInferenceProvider(
|
class LlamaCppInferenceProvider(
|
||||||
private val descriptor: ModelDescriptor,
|
private val descriptor: ModelDescriptor,
|
||||||
@@ -132,22 +145,27 @@ class LlamaCppInferenceProvider(
|
|||||||
log.debug("got response from llm: {}", response)
|
log.debug("got response from llm: {}", response)
|
||||||
|
|
||||||
val message = response.choices.first().message
|
val message = response.choices.first().message
|
||||||
val finishReason = when (response.choices.first().finishReason.lowercase()) {
|
// Some local models emit the tool call as a JSON blob in `content` instead of the native
|
||||||
"length" -> FinishReason.Length
|
// tool_calls array; salvage it so the orchestrator gets a real call rather than treating
|
||||||
"tool_calls" -> FinishReason.ToolCall
|
// the blob as a (failing) artifact and retry-looping to exhaustion.
|
||||||
|
val salvaged = if (message.toolCalls.isEmpty()) salvageToolCalls(message.content) else emptyList()
|
||||||
|
val toolCalls = message.toolCalls.ifEmpty { salvaged }
|
||||||
|
val finishReason = when {
|
||||||
|
toolCalls.isNotEmpty() -> FinishReason.ToolCall
|
||||||
|
response.choices.first().finishReason.lowercase() == "length" -> FinishReason.Length
|
||||||
else -> FinishReason.Stop
|
else -> FinishReason.Stop
|
||||||
}
|
}
|
||||||
|
|
||||||
return InferenceResponse(
|
return InferenceResponse(
|
||||||
requestId = request.requestId,
|
requestId = request.requestId,
|
||||||
text = message.content ?: "",
|
text = if (salvaged.isNotEmpty()) "" else message.content ?: "",
|
||||||
finishReason = finishReason,
|
finishReason = finishReason,
|
||||||
tokensUsed = TokenUsage(
|
tokensUsed = TokenUsage(
|
||||||
promptTokens = response.usage.promptTokens,
|
promptTokens = response.usage.promptTokens,
|
||||||
completionTokens = response.usage.completionTokens,
|
completionTokens = response.usage.completionTokens,
|
||||||
),
|
),
|
||||||
latencyMs = System.currentTimeMillis() - startTime,
|
latencyMs = System.currentTimeMillis() - startTime,
|
||||||
toolCalls = message.toolCalls,
|
toolCalls = toolCalls,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.correx.infrastructure.inference.llama.cpp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SalvageToolCallsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recovers a tool call emitted as a JSON object in content`() {
|
||||||
|
val args = """{\"path\":\"apps/server/src\"}"""
|
||||||
|
val content = """{"id":"abc","function":{"name":"file_read","arguments":"$args"}}"""
|
||||||
|
val calls = salvageToolCalls(content)
|
||||||
|
assertEquals(1, calls.size)
|
||||||
|
assertEquals("file_read", calls.first().function.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recovers a JSON array of tool calls`() {
|
||||||
|
val one = """{"function":{"name":"file_read","arguments":"{}"}}"""
|
||||||
|
val two = """{"function":{"name":"ls","arguments":"{}"}}"""
|
||||||
|
val calls = salvageToolCalls("[$one,$two]")
|
||||||
|
assertEquals(listOf("file_read", "ls"), calls.map { it.function.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `leaves a real artifact JSON alone`() {
|
||||||
|
val artifact = """{"summary":"add a web ui","requirements":["one","two"]}"""
|
||||||
|
assertTrue(salvageToolCalls(artifact).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ignores empty, prose, and null content`() {
|
||||||
|
assertTrue(salvageToolCalls(null).isEmpty())
|
||||||
|
assertTrue(salvageToolCalls(" ").isEmpty())
|
||||||
|
assertTrue(salvageToolCalls("Let me explore the frontend directory.").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user