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:
@@ -15,6 +15,7 @@ dependencies {
|
||||
implementation project(":infrastructure:inference")
|
||||
implementation project(":infrastructure:inference:commons")
|
||||
implementation project(":infrastructure:inference:llama_cpp")
|
||||
implementation project(":infrastructure:inference:openai_compat")
|
||||
implementation project(":infrastructure:persistence")
|
||||
implementation project(":infrastructure:router:turbovec")
|
||||
implementation project(":infrastructure:tools")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import com.correx.infrastructure.inference.commons.ResidencyMode
|
||||
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppEmbedder
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||
import com.correx.infrastructure.inference.openai.OpenAiCompatInferenceProvider
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
|
||||
@@ -127,6 +128,20 @@ object InfrastructureModule {
|
||||
baseUrl = baseUrl,
|
||||
)
|
||||
|
||||
fun createOpenAiCompatProvider(
|
||||
modelId: String,
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
idPrefix: String = "openai",
|
||||
capabilities: Set<CapabilityScore> = DEFAULT_LLAMA_CAPABILITIES,
|
||||
): OpenAiCompatInferenceProvider = OpenAiCompatInferenceProvider(
|
||||
modelId = modelId,
|
||||
baseUrl = baseUrl,
|
||||
apiKey = apiKey,
|
||||
idPrefix = idPrefix,
|
||||
capabilities = capabilities,
|
||||
)
|
||||
|
||||
fun createModelManager(
|
||||
settings: ModelsSettings,
|
||||
eventStore: EventStore,
|
||||
|
||||
@@ -16,6 +16,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
|
||||
- Web search and web fetch results are environment observations; they must be recorded as events by callers to preserve replay determinism (invariant #9).
|
||||
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
||||
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
||||
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
@@ -30,4 +31,4 @@ Standard adapter rules apply (see parent `AGENTS.md`). Network calls (web tools)
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
- `filesystem/` — filesystem read/write/list tools implementing `core:tools` contracts; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
- `filesystem/` — filesystem tools implementing `core:tools` contracts: `FileReadTool`, `FileWriteTool` (write-only), `FileDeleteTool`, `FileEditTool`, list; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.correx.infrastructure.tools.filesystem
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.FileAffectingTool
|
||||
import com.correx.core.tools.contract.ParamRole
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* Deletes a file. Split out of [FileWriteTool] so deletion is an explicitly-named capability a model
|
||||
* must choose deliberately — it can never happen by getting a write-mode parameter wrong. Carries the
|
||||
* [ToolCapability.FILE_WRITE] capability (file mutation) and the same path jail as the writer.
|
||||
*/
|
||||
class FileDeleteTool(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
) : Tool, FileAffectingTool, ToolExecutor {
|
||||
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_delete"
|
||||
override val description: String = "Delete the file at the specified relative path"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
put("description", "Relative path of the file to delete")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("path")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
|
||||
|
||||
override fun affectedPaths(request: ToolRequest): Set<Path> {
|
||||
val pathString = request.parameters["path"] as? String ?: return emptySet()
|
||||
return setOf(resolvePath(pathString))
|
||||
}
|
||||
|
||||
private fun resolvePath(pathString: String): Path {
|
||||
val raw = Paths.get(pathString)
|
||||
return when {
|
||||
raw.isAbsolute -> raw.normalize()
|
||||
workingDir != null -> workingDir.resolve(raw).normalize()
|
||||
else -> raw.toAbsolutePath().normalize()
|
||||
}
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val pathString = request.parameters["path"] as? String
|
||||
?: return ValidationResult.Invalid(
|
||||
"""Missing 'path' parameter (string). Call file_delete with {"path": "<relative path>"}.""",
|
||||
)
|
||||
return runCatching {
|
||||
val path = resolvePath(pathString)
|
||||
when {
|
||||
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
||||
!PathJail.isContained(path, normalizedAllowedPaths) ->
|
||||
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}.getOrElse { e -> mapExceptionToValidationResult(e) }
|
||||
}
|
||||
|
||||
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
|
||||
when (e) {
|
||||
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
|
||||
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
|
||||
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
|
||||
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
|
||||
val validation = validateRequest(request)
|
||||
if (validation is ValidationResult.Invalid) {
|
||||
return@withContext ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = validation.reason,
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
val pathString = request.parameters["path"] as String
|
||||
val path = resolvePath(pathString)
|
||||
runCatching {
|
||||
if (Files.deleteIfExists(path)) {
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "File deleted successfully: $pathString",
|
||||
)
|
||||
} else {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "File not found: $pathString",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> handleExecutionException(e, request.invocationId, pathString) }
|
||||
}
|
||||
|
||||
private fun handleExecutionException(
|
||||
e: Throwable,
|
||||
invocationId: ToolInvocationId,
|
||||
pathString: String,
|
||||
): ToolResult = when (e) {
|
||||
is CancellationException -> throw e
|
||||
is IOException -> ToolResult.Failure(invocationId, "IO error: ${e.message}", recoverable = false)
|
||||
is SecurityException ->
|
||||
ToolResult.Failure(invocationId, "Access denied: $pathString, ${e.message}", recoverable = false)
|
||||
|
||||
else -> ToolResult.Failure(invocationId, e.message ?: "Unknown error occurred", recoverable = false)
|
||||
}
|
||||
}
|
||||
+29
-74
@@ -25,6 +25,12 @@ import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* Writes content to a file. Write-only by design: deleting is the separate, explicitly-named
|
||||
* [FileDeleteTool] so a model can never delete a file by getting an `operation` mode wrong — a
|
||||
* destructive action must name itself. (Previously this tool carried an `operation: write|delete`
|
||||
* mode; splitting it removes the most-forgotten parameter and makes delete a distinct capability.)
|
||||
*/
|
||||
class FileWriteTool(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
@@ -33,7 +39,7 @@ class FileWriteTool(
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_write"
|
||||
override val description: String = "Write content to a file at the specified path or delete the file entirely"
|
||||
override val description: String = "Write content to a file at the specified relative path (creates or overwrites)"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
@@ -43,11 +49,7 @@ class FileWriteTool(
|
||||
}
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "File content")
|
||||
}
|
||||
putJsonObject("operation") {
|
||||
put("type", "string")
|
||||
put("description", "Either 'write' or 'delete'")
|
||||
put("description", "The full file content to write")
|
||||
}
|
||||
}
|
||||
put(
|
||||
@@ -55,7 +57,6 @@ class FileWriteTool(
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("path"))
|
||||
add(JsonPrimitive("content"))
|
||||
add(JsonPrimitive("operation"))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -78,45 +79,37 @@ class FileWriteTool(
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val operation = request.parameters["operation"] as? String
|
||||
val pathString = request.parameters["path"] as? String
|
||||
val hasContent = request.parameters.containsKey("content")
|
||||
|
||||
return when {
|
||||
operation == null ->
|
||||
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.")
|
||||
|
||||
pathString == null ->
|
||||
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
|
||||
ValidationResult.Invalid(
|
||||
"Missing 'path' parameter (string). Call file_write with " +
|
||||
"""{"path": "<relative path>", "content": "<full file content>"}.""",
|
||||
)
|
||||
|
||||
!hasContent ->
|
||||
ValidationResult.Invalid(
|
||||
"Missing 'content' parameter (string). Call file_write with " +
|
||||
"""{"path": "$pathString", "content": "<full file content>"}.""",
|
||||
)
|
||||
|
||||
else -> runCatching {
|
||||
val path = resolvePath(pathString)
|
||||
checkPathAllowed(path, pathString, operation, request)
|
||||
checkPathAllowed(path, pathString)
|
||||
}.getOrElse { e ->
|
||||
mapExceptionToValidationResult(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPathAllowed(
|
||||
path: Path,
|
||||
pathString: String,
|
||||
operation: String,
|
||||
request: ToolRequest,
|
||||
): ValidationResult {
|
||||
return when {
|
||||
private fun checkPathAllowed(path: Path, pathString: String): ValidationResult =
|
||||
when {
|
||||
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
||||
!isPathAllowed(path) ->
|
||||
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
|
||||
operation == "write" && !request.parameters.containsKey("content") ->
|
||||
ValidationResult.Invalid("Missing 'content' parameter for 'write' operation.")
|
||||
|
||||
operation != "write" && operation != "delete" ->
|
||||
ValidationResult.Invalid("Unknown operation: $operation")
|
||||
|
||||
!isPathAllowed(path) -> ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPathAllowed(path: Path): Boolean =
|
||||
PathJail.isContained(path, normalizedAllowedPaths)
|
||||
@@ -139,13 +132,17 @@ class FileWriteTool(
|
||||
)
|
||||
}
|
||||
|
||||
val operation = request.parameters["operation"] as String
|
||||
val pathString = request.parameters["path"] as String
|
||||
val content = request.parameters["content"] as String
|
||||
val path = resolvePath(pathString)
|
||||
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
|
||||
|
||||
val result = runCatching {
|
||||
performOperation(operation, path, pathString, request)
|
||||
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "File written successfully to $pathString",
|
||||
)
|
||||
}.getOrElse { e ->
|
||||
handleExecutionException(e, request.invocationId, pathString)
|
||||
}
|
||||
@@ -165,48 +162,6 @@ class FileWriteTool(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOperation(
|
||||
operation: String,
|
||||
path: Path,
|
||||
pathString: String,
|
||||
request: ToolRequest,
|
||||
): ToolResult = when (operation) {
|
||||
"write" -> {
|
||||
val content = request.parameters["content"] as String
|
||||
withContext(Dispatchers.IO) {
|
||||
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "File written successfully to $pathString",
|
||||
)
|
||||
}
|
||||
|
||||
"delete" -> {
|
||||
if (Files.exists(path)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.deleteIfExists(path)
|
||||
}
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "File deleted successfully: $pathString",
|
||||
)
|
||||
} else {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "File not found: $pathString",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Unknown operation: $operation",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleExecutionException(
|
||||
e: Throwable,
|
||||
invocationId: ToolInvocationId,
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.correx.infrastructure.tools.filesystem
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.util.*
|
||||
|
||||
class FileDeleteToolTest {
|
||||
|
||||
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
|
||||
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = SessionId(UUID.randomUUID().toString()),
|
||||
stageId = StageId(UUID.randomUUID().toString()),
|
||||
toolName = "file_delete",
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Valid for existing file in allowed directory`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val filePath = tempDir.resolve("existing.txt")
|
||||
Files.writeString(filePath, "content")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(mapOf("path" to filePath.toString()))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing path with actionable message`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
val result = tool.validateRequest(createRequest(emptyMap()))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("Missing 'path'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for disallowed path`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val otherDir = Files.createTempDirectory("other_dir")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
val result = tool.validateRequest(createRequest(mapOf("path" to otherDir.resolve("x.txt").toString())))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute deletes an existing file`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val filePath = tempDir.resolve("to_delete.txt")
|
||||
Files.writeString(filePath, "content to delete")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
|
||||
val result = tool.execute(createRequest(mapOf("path" to filePath.toString())))
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
assertTrue((result as ToolResult.Success).output.startsWith("File deleted successfully: $filePath"))
|
||||
assertFalse(Files.exists(filePath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure for deleting non-existent file`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||
val filePath = tempDir.resolve("non_existent.txt")
|
||||
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||
|
||||
val result = tool.execute(createRequest(mapOf("path" to filePath.toString())))
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertEquals("File not found: $filePath", failure.reason)
|
||||
assertFalse(failure.recoverable)
|
||||
}
|
||||
}
|
||||
+27
-159
@@ -8,7 +8,6 @@ import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
@@ -20,29 +19,20 @@ class FileWriteToolTest {
|
||||
private val stageId = StageId(UUID.randomUUID().toString())
|
||||
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
|
||||
private fun createRequest(
|
||||
parameters: Map<String, String>,
|
||||
toolName: String = "file_write",
|
||||
): ToolRequest {
|
||||
return ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = toolName,
|
||||
parameters = parameters,
|
||||
)
|
||||
}
|
||||
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "file_write",
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Valid for allowed path and valid operation`(): Unit = runBlocking {
|
||||
fun `validateRequest returns Valid for allowed path with content`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to tempDir.resolve("file.txt").toString(),
|
||||
"content" to "hello",
|
||||
),
|
||||
mapOf("path" to tempDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||
)
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
||||
}
|
||||
@@ -53,11 +43,7 @@ class FileWriteToolTest {
|
||||
val otherDir = Files.createTempDirectory("other_dir")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to otherDir.resolve("file.txt").toString(),
|
||||
"content" to "hello",
|
||||
),
|
||||
mapOf("path" to otherDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
@@ -65,171 +51,58 @@ class FileWriteToolTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing operation`(): Unit = runBlocking {
|
||||
fun `validateRequest returns Invalid for missing path with actionable message`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"path" to tempDir.resolve("file.txt").toString(),
|
||||
"content" to "hello",
|
||||
),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
val result = tool.validateRequest(createRequest(mapOf("content" to "hello")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals(
|
||||
"Missing 'operation' parameter. Expected 'write' or 'delete'.",
|
||||
(result as ValidationResult.Invalid).reason,
|
||||
)
|
||||
val reason = (result as ValidationResult.Invalid).reason
|
||||
assertTrue(reason.contains("Missing 'path'"))
|
||||
// actionable: shows the required call shape
|
||||
assertTrue(reason.contains("\"content\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing path`(): Unit = runBlocking {
|
||||
fun `validateRequest returns Invalid for missing content with actionable message`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"content" to "hello",
|
||||
),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to tempDir.resolve("file.txt").toString()))
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals("Missing 'path' parameter. Expected String.", (result as ValidationResult.Invalid).reason)
|
||||
val reason = (result as ValidationResult.Invalid).reason
|
||||
assertTrue(reason.contains("Missing 'content'"))
|
||||
assertTrue(reason.contains("file_write"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing content on write`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to tempDir.resolve("file.txt").toString(),
|
||||
),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals("Missing 'content' parameter for 'write' operation.", (result as ValidationResult.Invalid).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for unknown operation`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "unknown",
|
||||
"path" to tempDir.resolve("file.txt").toString(),
|
||||
),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals("Unknown operation: unknown", (result as ValidationResult.Invalid).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Success for write operation`(): Unit = runBlocking {
|
||||
fun `execute writes content and attaches a diff`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val filePath = tempDir.resolve("test.txt")
|
||||
val content = "Hello, FileWriteTool!"
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to filePath.toString(),
|
||||
"content" to content,
|
||||
),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to filePath.toString(), "content" to content))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val success = result as ToolResult.Success
|
||||
// Output starts with the status line; a unified diff of the change is appended (F-019).
|
||||
assertTrue(success.output.startsWith("File written successfully to $filePath"))
|
||||
assertTrue(success.metadata.containsKey("diff"))
|
||||
assertEquals(invocationId, success.invocationId)
|
||||
assertEquals(content, Files.readString(filePath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Success for delete operation`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val filePath = tempDir.resolve("to_delete.txt")
|
||||
Files.writeString(filePath, "content to delete")
|
||||
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "delete",
|
||||
"path" to filePath.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = tool.execute(request)
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val success = result as ToolResult.Success
|
||||
assertTrue(success.output.startsWith("File deleted successfully: $filePath"))
|
||||
assertEquals(invocationId, success.invocationId)
|
||||
assertFalse(Files.exists(filePath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure for deleting non-existent file`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val filePath = tempDir.resolve("non_existent.txt")
|
||||
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "delete",
|
||||
"path" to filePath.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertEquals("File not found: $filePath", failure.reason)
|
||||
assertFalse(failure.recoverable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure for disallowed path`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val otherDir = Files.createTempDirectory("other_dir")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val filePath = otherDir.resolve("illegal.txt")
|
||||
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to filePath.toString(),
|
||||
"content" to "illegal",
|
||||
),
|
||||
mapOf("path" to otherDir.resolve("illegal.txt").toString(), "content" to "illegal"),
|
||||
)
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertTrue(failure.reason.contains("is not in the allowed list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Valid for existing file in allowed directory`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test_existing")
|
||||
val filePath = tempDir.resolve("existing.txt")
|
||||
Files.writeString(filePath, "content")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "delete",
|
||||
"path" to filePath.toString(),
|
||||
),
|
||||
)
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
||||
assertTrue((result as ToolResult.Failure).reason.contains("is not in the allowed list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -237,9 +110,7 @@ class FileWriteToolTest {
|
||||
val tempDir = Files.createTempDirectory("file_write_atomic")
|
||||
val target = tempDir.resolve("nested/deep/file.txt")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "hello"),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to target.toString(), "content" to "hello"))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
@@ -254,15 +125,12 @@ class FileWriteToolTest {
|
||||
val target = tempDir.resolve("file.txt")
|
||||
Files.writeString(target, "old")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "new"),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to target.toString(), "content" to "new"))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
assertEquals("new", Files.readString(target))
|
||||
// the staged temp file must not survive the swap
|
||||
val leftovers = Files.list(tempDir).use { stream ->
|
||||
stream.filter { it.fileName.toString().endsWith(".tmp") }.count()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.infrastructure.tools.filesystem.FileDeleteTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileReadTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
||||
@@ -81,6 +82,14 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
workingDir = fileWrite.workingDir,
|
||||
),
|
||||
)
|
||||
// file_delete is the explicit, separately-named deletion capability split out of
|
||||
// file_write; it shares the writer's path jail and is gated by the same fileWrite toggle.
|
||||
add(
|
||||
FileDeleteTool(
|
||||
allowedPaths = fileWrite.allowedPaths,
|
||||
workingDir = fileWrite.workingDir,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (fileEdit.enabled) {
|
||||
add(
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class SandboxedToolExecutorFileMutationTest {
|
||||
|
||||
private fun writeRequest(path: String, content: String) = ToolRequest(
|
||||
ToolInvocationId("inv"), SessionId("s"), StageId("st"), "file_write",
|
||||
mapOf("operation" to "write", "path" to path, "content" to content),
|
||||
mapOf("path" to path, "content" to content),
|
||||
)
|
||||
|
||||
private fun executor(tool: FileWriteTool, store: FakeArtifactStore, events: CapturingEventStore) =
|
||||
|
||||
Reference in New Issue
Block a user