feat(qa): remote NIM provider + headless-QA robustness
Enable autonomous QA through a remote OpenAI-compatible provider (NVIDIA NIM) and harden the tool/approval path so unattended multi-stage runs complete. - inference: add openai_compat provider (Bearer chat-completions for NIM/OpenAI), dispatched by provider type "nim"/"openai"; key via api_key/api_key_env. - server: bind configured [server] host/port instead of a hardcoded 8080; POST /sessions accepts an optional `intent` (WS parity) for intent-driven workflows. - kernel: thread the bound operator profile's approval_mode into per-tool gating so auto/yolo enable unattended approval (engine still consulted; policy/plane-2 BLOCK stays terminal); on a recoverable tool failure feed the tool's arg-schema back into context so the model self-corrects instead of repeating a malformed call. - tools: split deletion out of file_write into a separate, explicitly-named file_delete tool — a model can no longer delete a file by getting a write-mode parameter wrong. - server: add GET /metrics/tool-reliability — per-model tool-call validity from the event log (measurement groundwork for capability-aware routing). - docs: update AGENTS.md across kernel, tools, server, inference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Inference adapter layer. The root module provides `DefaultProviderRegistry` (registers `InferenceProvider` instances) and `FirstAvailableRoutingStrategy`. Submodules cover shared HTTP client infrastructure (`commons/`) and the llama.cpp server adapter (`llama_cpp/`).
|
||||
Inference adapter layer. The root module provides `DefaultProviderRegistry` (registers `InferenceProvider` instances) and `FirstAvailableRoutingStrategy`. Submodules cover shared HTTP client infrastructure (`commons/`), the llama.cpp server adapter (`llama_cpp/`), and the remote OpenAI-compatible adapter (`openai_compat/`, e.g. NVIDIA NIM).
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -13,8 +13,9 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d
|
||||
- `DefaultProviderRegistry` implements `ProviderRegistry` from `core:inference`.
|
||||
- `FirstAvailableRoutingStrategy` is the default routing policy; extend only in `core:inference`, not here.
|
||||
- All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate.
|
||||
- Network calls to the llama.cpp server are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
|
||||
- Network calls to inference backends are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
|
||||
- Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md).
|
||||
- `openai_compat/` is fully remote (no local process/GPU). It speaks `POST {baseUrl}/chat/completions` with `Authorization: Bearer`; `baseUrl` must include the version segment (e.g. `/v1`). It has no `/tokenize`, so it uses a heuristic tokenizer, and no GBNF — JSON artifacts rely on the core's validate-after-retry. Server dispatch keys `[[providers]] type = "nim" | "openai"`; the key comes from `api_key` or `api_key_env`.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
@@ -26,9 +27,11 @@ Standard adapter rules apply (see parent `AGENTS.md`). HTTP client code uses Kto
|
||||
./gradlew :infrastructure:inference:test --rerun-tasks
|
||||
./gradlew :infrastructure:inference:commons:test --rerun-tasks
|
||||
./gradlew :infrastructure:inference:llama_cpp:test --rerun-tasks
|
||||
./gradlew :infrastructure:inference:openai_compat:test --rerun-tasks
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
- `commons/` — shared `ManagedInferenceProvider`, `ModelManager`, `ResourceProbe` (Nvidia/AMD), `ResidencyMode`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
- `llama_cpp/` — `LlamaCppInferenceProvider`, `LlamaProcess` (spawns/owns llama-server), `LlamaCppEmbedder`, `LlamaCppTokenizer`, `GbnfGrammarConverter`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
- `openai_compat/` — `OpenAiCompatInferenceProvider` (remote Bearer-auth chat completions for NVIDIA NIM/OpenAI), `HeuristicTokenizer`, `OpenAiApiModels`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
ext {
|
||||
ktor_version = '3.0.3'
|
||||
ext.ktor_version = '3.0.3'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:inference')
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:artifacts')
|
||||
implementation project(':core:context')
|
||||
implementation project(':infrastructure:inference:commons')
|
||||
|
||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
||||
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
|
||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure { enabled = false }
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.correx.infrastructure.inference.openai
|
||||
|
||||
import com.correx.core.inference.Token
|
||||
import com.correx.core.inference.Tokenizer
|
||||
|
||||
/**
|
||||
* Remote OpenAI-compatible backends (NIM, OpenAI) expose no `/tokenize` endpoint, so we can't
|
||||
* get exact token ids without shipping a tokenizer. This is a length-based approximation
|
||||
* (~4 chars/token) used only for context-budget estimates; the synthesized [Token] ids are
|
||||
* placeholders and must not be treated as real model tokens.
|
||||
*/
|
||||
class HeuristicTokenizer(private val charsPerToken: Int = 4) : Tokenizer {
|
||||
|
||||
override suspend fun tokenize(text: String): List<Token> {
|
||||
val count = countTokens(text)
|
||||
return List(count) { Token(it) }
|
||||
}
|
||||
|
||||
override suspend fun countTokens(text: String): Int =
|
||||
if (text.isEmpty()) 0 else (text.length + charsPerToken - 1) / charsPerToken
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.correx.infrastructure.inference.openai
|
||||
|
||||
import com.correx.core.inference.ToolCallRequest
|
||||
import com.correx.core.inference.ToolDefinition
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire models for the OpenAI-compatible `/chat/completions` endpoint (NVIDIA NIM, OpenAI,
|
||||
* vLLM, etc.). Unlike the llama.cpp models there is no `grammar` field — structured output
|
||||
* on these backends is `response_format`/tools, not GBNF. JSON artifacts rely on the
|
||||
* orchestrator's validate-after-retry path (invariant #7).
|
||||
*/
|
||||
@Serializable
|
||||
data class OpenAiChatCompletionRequest(
|
||||
val model: String,
|
||||
val messages: List<OpenAiChatMessage>,
|
||||
val temperature: Double,
|
||||
@SerialName("top_p") val topP: Double,
|
||||
@SerialName("max_tokens") val maxTokens: Int,
|
||||
@SerialName("stop") val stopSequences: List<String>? = null,
|
||||
val seed: Long? = null,
|
||||
val stream: Boolean = false,
|
||||
val tools: List<ToolDefinition>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenAiChatMessage(
|
||||
val role: String,
|
||||
val content: String? = null,
|
||||
@SerialName("tool_calls") val toolCalls: List<ToolCallRequest> = emptyList(),
|
||||
@SerialName("tool_call_id") val toolCallId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenAiChatCompletionResponse(
|
||||
val id: String? = null,
|
||||
val choices: List<OpenAiChoice> = emptyList(),
|
||||
val usage: OpenAiUsage? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenAiChoice(
|
||||
val message: OpenAiChatMessage,
|
||||
@SerialName("finish_reason") val finishReason: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenAiUsage(
|
||||
@SerialName("prompt_tokens") val promptTokens: Int = 0,
|
||||
@SerialName("completion_tokens") val completionTokens: Int = 0,
|
||||
@SerialName("total_tokens") val totalTokens: Int = 0,
|
||||
)
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.correx.infrastructure.inference.openai
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.PromptRenderer
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.inference.ToolCallRequest
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.accept
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private const val DEFAULT_REQUEST_TIMEOUT_MS = 600_000L
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) { json(json) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS }
|
||||
}
|
||||
|
||||
private val log = LoggerFactory.getLogger(OpenAiCompatInferenceProvider::class.java)
|
||||
|
||||
// Some models emit a tool call as a raw JSON blob in `content` instead of the native
|
||||
// tool_calls array; salvage it so the orchestrator gets a real call rather than treating
|
||||
// the blob as a (failing) artifact. Mirrors the llama.cpp provider's recovery path.
|
||||
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() }
|
||||
}
|
||||
|
||||
/**
|
||||
* [InferenceProvider] for OpenAI-compatible chat APIs reached over HTTPS with a Bearer key —
|
||||
* NVIDIA NIM (`https://integrate.api.nvidia.com/v1`), OpenAI, vLLM, etc. Inference is fully
|
||||
* remote: no local process, no GPU, no GGUF. [baseUrl] must already include the API version
|
||||
* segment (e.g. `.../v1`); this class appends `/chat/completions` and `/models`.
|
||||
*
|
||||
* @param idPrefix label for the [ProviderId] (e.g. `nim`, `openai`).
|
||||
* @param apiKey Bearer token; sent as `Authorization: Bearer <key>` when non-blank.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught", "MagicNumber")
|
||||
class OpenAiCompatInferenceProvider(
|
||||
private val modelId: String,
|
||||
private val baseUrl: String,
|
||||
private val apiKey: String,
|
||||
private val capabilities: Set<CapabilityScore>,
|
||||
private val idPrefix: String = "openai",
|
||||
private val httpClient: HttpClient = defaultHttpClient(),
|
||||
) : InferenceProvider {
|
||||
|
||||
override val id: ProviderId = ProviderId("$idPrefix:$modelId")
|
||||
override val name: String = "OpenAI-compatible ($idPrefix:$modelId)"
|
||||
override val tokenizer: Tokenizer = HeuristicTokenizer()
|
||||
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val messages = PromptRenderer.render(request.contextPack).map { msg ->
|
||||
// Strict OpenAI-compatible servers reject a `tool` role that isn't bound to a prior
|
||||
// tool_calls turn (we don't carry tool_call_ids through the context pack). Fold any
|
||||
// non-standard role into a user turn so the model still sees the content.
|
||||
if (msg.role in STANDARD_ROLES) {
|
||||
OpenAiChatMessage(role = msg.role, content = msg.content)
|
||||
} else {
|
||||
OpenAiChatMessage(role = "user", content = "[${msg.role}] ${msg.content}")
|
||||
}
|
||||
}
|
||||
|
||||
val tools = request.tools.takeIf { it.isNotEmpty() }
|
||||
|
||||
val body = OpenAiChatCompletionRequest(
|
||||
model = modelId,
|
||||
messages = messages,
|
||||
temperature = request.generationConfig.temperature,
|
||||
topP = request.generationConfig.topP,
|
||||
maxTokens = request.generationConfig.maxTokens,
|
||||
stopSequences = request.generationConfig.stopSequences.ifEmpty { null },
|
||||
seed = request.generationConfig.seed,
|
||||
stream = false,
|
||||
tools = tools,
|
||||
)
|
||||
|
||||
val encoded = json.encodeToString(body)
|
||||
log.debug("sending request to {}: {}", id.value, encoded)
|
||||
|
||||
val httpResponse = httpClient.post("$baseUrl/chat/completions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
accept(ContentType.Application.Json)
|
||||
if (apiKey.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $apiKey")
|
||||
setBody(encoded)
|
||||
}
|
||||
if (httpResponse.status.value !in 200..299) {
|
||||
val errorBody = httpResponse.bodyAsText()
|
||||
error("$idPrefix returned ${httpResponse.status.value} ${httpResponse.status.description}: $errorBody")
|
||||
}
|
||||
val response = httpResponse.body<OpenAiChatCompletionResponse>()
|
||||
log.debug("got response from {}: {}", id.value, response)
|
||||
|
||||
val choice = response.choices.firstOrNull()
|
||||
?: error("$idPrefix returned no choices")
|
||||
val message = choice.message
|
||||
val salvaged = if (message.toolCalls.isEmpty()) salvageToolCalls(message.content) else emptyList()
|
||||
val toolCalls = message.toolCalls.ifEmpty { salvaged }
|
||||
val finishReason = when {
|
||||
toolCalls.isNotEmpty() -> FinishReason.ToolCall
|
||||
choice.finishReason?.lowercase() == "length" -> FinishReason.Length
|
||||
else -> FinishReason.Stop
|
||||
}
|
||||
|
||||
return InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = if (salvaged.isNotEmpty()) "" else message.content ?: "",
|
||||
finishReason = finishReason,
|
||||
tokensUsed = TokenUsage(
|
||||
promptTokens = response.usage?.promptTokens ?: 0,
|
||||
completionTokens = response.usage?.completionTokens ?: 0,
|
||||
),
|
||||
latencyMs = System.currentTimeMillis() - startTime,
|
||||
toolCalls = toolCalls,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun healthCheck(): ProviderHealth = try {
|
||||
val response = httpClient.get("$baseUrl/models") {
|
||||
if (apiKey.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $apiKey")
|
||||
}
|
||||
if (response.status.value in 200..299) {
|
||||
ProviderHealth.Healthy
|
||||
} else {
|
||||
ProviderHealth.Unavailable("Health check returned non-2xx status: ${response.status}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProviderHealth.Unavailable("Health check failed: ${e.message}")
|
||||
}
|
||||
|
||||
override fun capabilities(): Set<CapabilityScore> = capabilities
|
||||
|
||||
private companion object {
|
||||
private val STANDARD_ROLES = setOf("system", "user", "assistant")
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.correx.infrastructure.inference.openai
|
||||
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class OpenAiCompatInferenceProviderTest {
|
||||
|
||||
private fun request() = InferenceRequest(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage1"),
|
||||
contextPack = ContextPack(
|
||||
id = ContextPackId("cp1"),
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage1"),
|
||||
layers = emptyMap(),
|
||||
budgetUsed = 0,
|
||||
budgetLimit = 4096,
|
||||
),
|
||||
generationConfig = GenerationConfig(temperature = 0.2, topP = 0.9, maxTokens = 256),
|
||||
)
|
||||
|
||||
private fun clientReturning(body: String, capture: (HttpRequestData) -> Unit = {}): HttpClient {
|
||||
val engine = MockEngine { req ->
|
||||
capture(req)
|
||||
respond(
|
||||
content = body,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||
)
|
||||
}
|
||||
return HttpClient(engine) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun provider(client: HttpClient) = OpenAiCompatInferenceProvider(
|
||||
modelId = "deepseek-ai/deepseek-v4-flash",
|
||||
baseUrl = "https://integrate.api.nvidia.com/v1",
|
||||
apiKey = "nvapi-test",
|
||||
capabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||
idPrefix = "nim",
|
||||
httpClient = client,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `infer sends bearer auth to chat completions and returns text`(): Unit = runBlocking {
|
||||
var seenAuth: String? = null
|
||||
var seenUrl: String? = null
|
||||
val client = clientReturning(
|
||||
"""
|
||||
{"id":"x","choices":[{"message":{"role":"assistant","content":"hello there"},
|
||||
"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":3,"total_tokens":14}}
|
||||
""".trimIndent(),
|
||||
) { req ->
|
||||
seenAuth = req.headers[HttpHeaders.Authorization]
|
||||
seenUrl = req.url.toString()
|
||||
}
|
||||
|
||||
val resp = provider(client).infer(request())
|
||||
|
||||
assertEquals("Bearer nvapi-test", seenAuth)
|
||||
assertEquals("https://integrate.api.nvidia.com/v1/chat/completions", seenUrl)
|
||||
assertEquals("hello there", resp.text)
|
||||
assertEquals(FinishReason.Stop, resp.finishReason)
|
||||
assertEquals(11, resp.tokensUsed.promptTokens)
|
||||
assertEquals(3, resp.tokensUsed.completionTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infer surfaces native tool calls as ToolCall finish`(): Unit = runBlocking {
|
||||
val client = clientReturning(
|
||||
"""
|
||||
{"id":"x","choices":[{"message":{"role":"assistant","content":null,
|
||||
"tool_calls":[{"id":"c1","type":"function","function":{"name":"file_read","arguments":"{}"}}]},
|
||||
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val resp = provider(client).infer(request())
|
||||
|
||||
assertEquals(FinishReason.ToolCall, resp.finishReason)
|
||||
assertEquals(1, resp.toolCalls.size)
|
||||
assertEquals("file_read", resp.toolCalls.first().function.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infer salvages a tool call emitted as a JSON blob in content`(): Unit = runBlocking {
|
||||
val client = clientReturning(
|
||||
"""
|
||||
{"id":"x","choices":[{"message":{"role":"assistant",
|
||||
"content":"{\"function\":{\"name\":\"file_read\",\"arguments\":\"{}\"}}"},
|
||||
"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":9,"total_tokens":13}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val resp = provider(client).infer(request())
|
||||
|
||||
assertEquals(FinishReason.ToolCall, resp.finishReason)
|
||||
assertEquals("file_read", resp.toolCalls.first().function.name)
|
||||
assertEquals("", resp.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `salvageToolCalls leaves plain artifact JSON alone`() {
|
||||
assertTrue(salvageToolCalls("""{"title":"a plan","steps":[]}""").isEmpty())
|
||||
assertTrue(salvageToolCalls("not json").isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user