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:
@@ -555,6 +555,7 @@ private fun buildToolConfigForWorkspace(
|
|||||||
fileRead = FileReadConfig(
|
fileRead = FileReadConfig(
|
||||||
enabled = toolsConfig.fileReadEnabled,
|
enabled = toolsConfig.fileReadEnabled,
|
||||||
allowedPaths = workspace.allowedPaths,
|
allowedPaths = workspace.allowedPaths,
|
||||||
|
workingDir = workspace.workingDir,
|
||||||
),
|
),
|
||||||
fileWrite = FileWriteConfig(
|
fileWrite = FileWriteConfig(
|
||||||
enabled = toolsConfig.fileWriteEnabled,
|
enabled = toolsConfig.fileWriteEnabled,
|
||||||
|
|||||||
+13
-2
@@ -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) {
|
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
|
ResponseFormat.Text -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +112,7 @@ class LlamaCppInferenceProvider(
|
|||||||
seed = request.generationConfig.seed,
|
seed = request.generationConfig.seed,
|
||||||
stream = false,
|
stream = false,
|
||||||
grammar = grammar,
|
grammar = grammar,
|
||||||
tools = request.tools.takeIf { it.isNotEmpty() },
|
tools = tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
val encoded = json.encodeToString(body)
|
val encoded = json.encodeToString(body)
|
||||||
|
|||||||
+15
-2
@@ -29,8 +29,21 @@ import java.nio.file.Paths
|
|||||||
|
|
||||||
class FileReadTool(
|
class FileReadTool(
|
||||||
private val allowedPaths: Set<Path> = emptySet(),
|
private val allowedPaths: Set<Path> = emptySet(),
|
||||||
|
private val workingDir: Path? = null,
|
||||||
) : Tool, ToolExecutor {
|
) : 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 name: String = "file_read"
|
||||||
override val description: String = "Read content from a file at the specified path"
|
override val description: String = "Read content from a file at the specified path"
|
||||||
override val parametersSchema: JsonObject = buildJsonObject {
|
override val parametersSchema: JsonObject = buildJsonObject {
|
||||||
@@ -72,7 +85,7 @@ class FileReadTool(
|
|||||||
return ValidationResult.Invalid("'end_line' must be >= 'start_line'.")
|
return ValidationResult.Invalid("'end_line' must be >= 'start_line'.")
|
||||||
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val path = Paths.get(pathString)
|
val path = resolvePath(pathString)
|
||||||
if (!PathJail.isContained(path, allowedPaths))
|
if (!PathJail.isContained(path, allowedPaths))
|
||||||
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
|
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
|
||||||
else
|
else
|
||||||
@@ -97,7 +110,7 @@ class FileReadTool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val pathString = request.parameters["path"] as String
|
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 startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
|
||||||
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
|
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ data class ToolConfig(
|
|||||||
val fileEdit: FileEditConfig = FileEditConfig(),
|
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(
|
data class FileWriteConfig(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val allowedPaths: Set<Path> = emptySet(),
|
val allowedPaths: Set<Path> = emptySet(),
|
||||||
@@ -49,6 +53,7 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
|||||||
add(
|
add(
|
||||||
FileReadTool(
|
FileReadTool(
|
||||||
allowedPaths = fileRead.allowedPaths,
|
allowedPaths = fileRead.allowedPaths,
|
||||||
|
workingDir = fileRead.workingDir,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user