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.
This commit is contained in:
2026-06-08 16:37:20 +04:00
parent 54a54f4760
commit 3f59faaa08
4 changed files with 35 additions and 5 deletions
@@ -29,8 +29,21 @@ import java.nio.file.Paths
class FileReadTool(
private val allowedPaths: Set<Path> = 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()
@@ -14,7 +14,11 @@ data class ToolConfig(
val fileEdit: FileEditConfig = FileEditConfig(),
)
data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
data class FileReadConfig(
val enabled: Boolean = false,
val allowedPaths: Set<Path> = emptySet(),
val workingDir: Path? = null,
)
data class FileWriteConfig(
val enabled: Boolean = false,
val allowedPaths: Set<Path> = emptySet(),
@@ -49,6 +53,7 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
add(
FileReadTool(
allowedPaths = fileRead.allowedPaths,
workingDir = fileRead.workingDir,
),
)
}