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:
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-13
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+16
-1
@@ -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(
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+4
-2
@@ -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))
|
||||
}
|
||||
|
||||
+9
@@ -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)) }
|
||||
|
||||
Reference in New Issue
Block a user