fix(kernel,tools,validation): unblock role_pipeline end-to-end + QA audit

Live QA audit of role_pipeline against a sample repo surfaced and fixed a
chain of defects that prevented any tool+artifact workflow from running
end-to-end. The pipeline now completes cleanly with a real, validated,
reviewed file change.

- F-008 workflow: propagate stage token_budget -> generationConfig.maxTokens
  (TomlWorkflowLoader) so large stages stop truncating at the 2048 default.
- F-009 tools/kernel: file_read missing-file is recoverable; recoverable tool
  errors feed back into the loop instead of aborting the stage.
- F-010/F-018 kernel: reject premature stage_complete and nudge the model to
  emit the owed artifact / invoke a write tool (bounded by MAX_TOOL_ROUNDS).
- F-011 schemas: list-typed artifact fields string -> array (analysis/design/
  impl_plan) to match model output.
- F-012 kernel: strip an outer markdown code fence from LLM artifacts.
- F-013 validation: payload-validation failures are retryable; structural
  failures stay terminal (wires the invariant-#7 reject+retry contract).
- F-014 tools: file_edit schema matches its real params; edits emit a diff.
- F-015 kernel: record tool-execution events + materialise a real file_written
  artifact from the on-disk write; gate validation so a no-write stage cannot
  be rubber-stamped (no more false-success runs).
- F-016 server: raise stageTimeoutMs 60s -> 180s for slow local models.
- F-019 artifacts/kernel: file_written artifact carries the unified diff so the
  reviewer can verify the change (shared DiffUtil; file_write emits a diff too).
- F-020 tools: model-correctable file_edit failures are recoverable + steer
  toward replace/file_write over the fragile patch op.

Full findings register (F-001..F-021, incl. open F-021 resume rehydrate bug)
in qa/audit-report-2026-06-08.md. All module tests green.
This commit is contained in:
2026-06-08 21:51:21 +04:00
parent d518400b5f
commit b407b47503
16 changed files with 664 additions and 45 deletions
@@ -0,0 +1,36 @@
package com.correx.infrastructure.tools.filesystem
import java.io.IOException
import java.nio.file.Files
/**
* Produce a `diff -u` unified diff of a file mutation (labelled `a/path` → `b/path`), so the
* change is auditable downstream — the reviewer stage and the operator can see WHAT changed, not
* just that something did. Returns "" when there is no change. Best-effort: a diff that cannot be
* produced is non-fatal.
*/
fun unifiedDiff(pathString: String, original: String, updated: String): String {
if (original == updated) return ""
val tmpDir = Files.createTempDirectory("correx-diff")
val a = tmpDir.resolve("a")
val b = tmpDir.resolve("b")
return try {
Files.write(a, original.toByteArray())
Files.write(b, updated.toByteArray())
val process = ProcessBuilder(
"diff", "-u", "--label", "a/$pathString", "--label", "b/$pathString",
a.toString(), b.toString(),
).redirectErrorStream(true).start()
val out = process.inputStream.bufferedReader().use { it.readText() }
process.waitFor()
out.trim()
} catch (e: IOException) {
"(diff unavailable: ${e.message})"
} finally {
runCatching {
Files.deleteIfExists(a)
Files.deleteIfExists(b)
Files.deleteIfExists(tmpDir)
}
}
}
@@ -43,7 +43,9 @@ class FileEditTool(
}
override val name: String = "file_edit"
override val description: String = "Edit content in a file at the specified path"
override val description: String = "Edit a file. Prefer operation 'replace' (exact string " +
"swap) for targeted edits; use file_write to replace the whole file. Use 'patch' only " +
"when you can produce a valid unified diff that applies cleanly with 'patch -p1'."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
@@ -53,19 +55,24 @@ class FileEditTool(
}
putJsonObject("operation") {
put("type", "string")
put("description", "Either 'append', 'replace', or 'patch'")
put("description", "'replace' (recommended: exact string swap via target/" +
"replacement), 'append', or 'patch' (a unified diff; only if it applies cleanly)")
}
putJsonObject("content") {
put("type", "string")
put("description", "Content to write. Used for 'append' and 'replace'.")
put("description", "Content to append. Required for 'append'.")
}
putJsonObject("old_str") {
putJsonObject("target") {
put("type", "string")
put("description", "Exact string to find. Required for 'patch'.")
put("description", "Exact, unique string to find. Required for 'replace'.")
}
putJsonObject("new_str") {
putJsonObject("replacement") {
put("type", "string")
put("description", "Replacement string. Required for 'patch'.")
put("description", "String to substitute for 'target'. Required for 'replace'.")
}
putJsonObject("patch") {
put("type", "string")
put("description", "A unified diff (patch -p1 format) to apply. Required for 'patch'.")
}
}
put("required", buildJsonArray {
@@ -149,22 +156,39 @@ class FileEditTool(
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid) {
// Recoverable: malformed/missing params are model-correctable — feed the error back so
// the model can fix the call (e.g. switch operation or supply the right argument)
// instead of aborting the whole stage.
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
recoverable = true,
)
}
val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String
val path = resolvePath(pathString)
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
runCatching {
val result = runCatching {
performOperation(operation, path, pathString, request)
}.getOrElse { e ->
handleExecutionException(e, request.invocationId, pathString)
}
// Attach a unified diff of the actual change so the side effect is auditable downstream
// (the file_written artifact and its reviewer/operator surface carry real evidence).
if (result is ToolResult.Success) {
val updatedContent = runCatching { Files.readString(path) }.getOrDefault(originalContent)
val diff = unifiedDiff(pathString, originalContent, updatedContent)
result.copy(
output = if (diff.isBlank()) result.output else "${result.output}\n\n$diff",
metadata = result.metadata + ("diff" to diff),
)
} else {
result
}
}
private fun performOperation(
@@ -179,7 +203,7 @@ class FileEditTool(
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Unknown operation: $operation",
recoverable = false,
recoverable = true,
)
}
@@ -213,7 +237,8 @@ class FileEditTool(
} else {
"Target '$target' found $occurrences times, expected exactly once"
},
recoverable = false,
// Recoverable: the model can adjust the target string and retry.
recoverable = true,
)
}
}
@@ -239,10 +264,13 @@ class FileEditTool(
output = "Patch applied successfully to $pathString: $output",
)
} else {
// Recoverable: a non-applying patch is model-correctable — feed the error back so
// the model can fix the diff or fall back to 'replace'/file_write, not abort.
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Patch failed with exit code $exitCode: $output",
recoverable = false,
reason = "Patch failed with exit code $exitCode: $output. " +
"Consider using operation 'replace' (exact string swap) or file_write instead.",
recoverable = true,
)
}
} finally {
@@ -115,10 +115,12 @@ class FileReadTool(
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
if (!Files.exists(path))
// Recoverable: the model often probes for files that may not exist (e.g. a
// speculative __init__.py). Feed the miss back so it can adapt, don't kill the stage.
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
recoverable = true,
)
runCatching {
@@ -142,12 +142,27 @@ class FileWriteTool(
val operation = request.parameters["operation"] as String
val pathString = request.parameters["path"] as String
val path = resolvePath(pathString)
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
runCatching {
val result = runCatching {
performOperation(operation, path, pathString, request)
}.getOrElse { e ->
handleExecutionException(e, request.invocationId, pathString)
}
// Attach a unified diff of the change so the side effect is auditable downstream — the
// reviewer stage reads the file_written artifact and needs to see WHAT changed, not just
// that a write happened (F-019).
if (result is ToolResult.Success) {
val updatedContent = runCatching { Files.readString(path) }.getOrDefault("")
val diff = unifiedDiff(pathString, originalContent, updatedContent)
result.copy(
output = if (diff.isBlank()) result.output else "${result.output}\n\n$diff",
metadata = result.metadata + ("diff" to diff),
)
} else {
result
}
}
private suspend fun performOperation(
@@ -9,7 +9,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
@@ -95,7 +94,9 @@ class FileReadToolTest {
assertTrue(result is ToolResult.Failure)
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("File not found"))
assertFalse(failure.recoverable)
// A missing file is recoverable (F-009): the model often probes for files that may not
// exist; the orchestrator feeds the miss back so it can adapt rather than failing the stage.
assertTrue(failure.recoverable)
}
@Test
@@ -145,7 +145,9 @@ class FileWriteToolTest {
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals("File written successfully to $filePath", success.output)
// 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))
}
@@ -167,7 +169,7 @@ class FileWriteToolTest {
val result = tool.execute(request)
assertTrue(result is ToolResult.Success)
val success = result as ToolResult.Success
assertEquals("File deleted successfully: $filePath", success.output)
assertTrue(success.output.startsWith("File deleted successfully: $filePath"))
assertEquals(invocationId, success.invocationId)
assertFalse(Files.exists(filePath))
}
@@ -6,6 +6,7 @@ import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.GenerationConfig
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
@@ -91,6 +92,14 @@ class TomlWorkflowLoader(
needs = s.needs.map { ArtifactId(it) }.toSet(),
allowedTools = s.allowedTools.toSet(),
tokenBudget = s.tokenBudget,
// Propagate the declared token budget to the inference completion cap.
// Without this the StageConfig default (maxTokens=2048) is used, truncating
// larger artifacts (finishReason=length) → invalid JSON → validation failure.
generationConfig = GenerationConfig(
temperature = 0.7,
topP = 1.0,
maxTokens = s.tokenBudget,
),
maxRetries = s.maxRetries,
metadata = buildMap {
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }