From 3f59faaa082579c265ddfc80bd12a42de3e69f7b Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 8 Jun 2026 16:37:20 +0400 Subject: [PATCH] fix(inference,tools): unblock llama.cpp tool stages + workspace-relative file_read Two fixes surfaced by the 2026-06-08 QA audit running role_pipeline on a local llama-server. F-001: LlamaCppInferenceProvider sent both a GBNF grammar and tools on the same request; llama.cpp rejects the combination ("Cannot use custom grammar constraints with tools"), failing every stage that has tools and produces a schema-constrained artifact. Drop the grammar when tools are present and rely on post-hoc schema validation + retry (invariant #7 still holds). TODO left for a first-class emit_artifact tool as the proper fix. F-005: FileReadTool resolved relative paths via toAbsolutePath() against the JVM process cwd instead of the bound workspace, so the file-read jail anchored to the wrong root and disagreed with the Plane-2 PATH_CONTAINMENT check. Add workingDir to FileReadConfig, wire workspace.workingDir in the per-workspace tool builder, and give FileReadTool a resolvePath() mirroring FileWriteTool/FileEditTool. --- .../main/kotlin/com/correx/apps/server/Main.kt | 1 + .../llama/cpp/LlamaCppInferenceProvider.kt | 15 +++++++++++++-- .../tools/filesystem/FileReadTool.kt | 17 +++++++++++++++-- .../correx/infrastructure/tools/ToolConfig.kt | 7 ++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index b6773db9..4cc07d0b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -555,6 +555,7 @@ private fun buildToolConfigForWorkspace( fileRead = FileReadConfig( enabled = toolsConfig.fileReadEnabled, allowedPaths = workspace.allowedPaths, + workingDir = workspace.workingDir, ), fileWrite = FileWriteConfig( enabled = toolsConfig.fileWriteEnabled, diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt index ad38d0ae..b3ade9db 100644 --- a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt @@ -86,8 +86,19 @@ class LlamaCppInferenceProvider( ) } + val tools = request.tools.takeIf { it.isNotEmpty() } + + // llama.cpp rejects requests that carry BOTH a custom grammar and tools + // ("Cannot use custom grammar constraints with tools"). When a stage has tools, + // we drop the grammar and rely on post-hoc schema validation + retry to keep the + // artifact constrained (invariant #7: LLM output is untrusted until validated). + // TODO: replace this implicit "free-form JSON, validate after" path with a + // first-class artifact-emission tool — the LLM calls e.g. emit_artifact(path, + // content, description) the same way it calls file_read/file_write, so artifact + // production is an explicit, schema-checked tool call rather than a grammar + // constraint that can't coexist with other tools. val grammar = when (val fmt = request.responseFormat) { - is ResponseFormat.Json -> GbnfGrammarConverter.convert(fmt.schema) + is ResponseFormat.Json -> if (tools == null) GbnfGrammarConverter.convert(fmt.schema) else null ResponseFormat.Text -> null } @@ -101,7 +112,7 @@ class LlamaCppInferenceProvider( seed = request.generationConfig.seed, stream = false, grammar = grammar, - tools = request.tools.takeIf { it.isNotEmpty() }, + tools = tools, ) val encoded = json.encodeToString(body) diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 931693a7..269ca94f 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -29,8 +29,21 @@ import java.nio.file.Paths class FileReadTool( private val allowedPaths: Set = emptySet(), + private val workingDir: Path? = null, ) : Tool, ToolExecutor { + // Resolve relative paths against the bound workspace's working dir, NOT the JVM + // process cwd. Mirrors FileWriteTool/FileEditTool so all three jail against the + // same anchor and agree with the Plane-2 tool-call-intent containment check. + 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 val name: String = "file_read" override val description: String = "Read content from a file at the specified path" override val parametersSchema: JsonObject = buildJsonObject { @@ -72,7 +85,7 @@ class FileReadTool( return ValidationResult.Invalid("'end_line' must be >= 'start_line'.") return runCatching { - val path = Paths.get(pathString) + val path = resolvePath(pathString) if (!PathJail.isContained(path, allowedPaths)) ValidationResult.Invalid("Path '$pathString' is not in the allowed list") else @@ -97,7 +110,7 @@ class FileReadTool( ) val pathString = request.parameters["path"] as String - val path = Paths.get(pathString).normalize().toAbsolutePath() + val path = resolvePath(pathString) val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull() val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull() diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt index a352b809..7ac06aa2 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt @@ -14,7 +14,11 @@ data class ToolConfig( val fileEdit: FileEditConfig = FileEditConfig(), ) -data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set = emptySet()) +data class FileReadConfig( + val enabled: Boolean = false, + val allowedPaths: Set = emptySet(), + val workingDir: Path? = null, +) data class FileWriteConfig( val enabled: Boolean = false, val allowedPaths: Set = emptySet(), @@ -49,6 +53,7 @@ fun ToolConfig.buildTools(): List = buildList { add( FileReadTool( allowedPaths = fileRead.allowedPaths, + workingDir = fileRead.workingDir, ), ) }