wip(freestyle/acr): grounding & edit-tool fixes + ACR-compiler experiment
This branch's uncommitted WIP, committed together (entangled at file level). Distinct pieces of work: Freestyle QA fixes (this session): - FileEditTool: pre-validate replace anchor in validateRequest — reject a missing/ambiguous target BEFORE the approval gate, mirroring read/write's file-not-found / read-before-write pre-checks. Shared not-found/ambiguous messages between validate and execute so they can't drift. - PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded, repoMapPaths is "unknown" not "empty workspace" — skip scope grounding (which proves a path ABSENT) so real paths (apps/server/**) aren't falsely rejected. Build-manifest check still runs. - FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted). - ServerModule: resolve project-memory workspace root from the session's bound workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing the workspace-binding divergence (correx vs empty scratch dir). Retire tracked in Vikunja #266. - LaunchRegistrationRaceTest: join registered jobs before asserting launchCount — computeIfAbsent returns the Job immediately but the fire-and-forget launch body lagged awaitAll (the 49-vs-50 flake). ACR concept-compiler experiment (pre-existing WIP on this branch): - ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts), LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace, config surface, workflow prompts/schemas, orchestrator advance-don't-rerun. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+31
-8
@@ -154,6 +154,12 @@ class FileEditTool(
|
||||
) ->
|
||||
ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.")
|
||||
|
||||
// Anchor validity is checkable now, so reject a bad target BEFORE the approval gate
|
||||
// fires — mirroring read/write's file-exists / read-before-write pre-checks. Without
|
||||
// this the operator is prompted to approve an edit that then dies "Target not found"
|
||||
// at execute time. Recoverable: the model corrects the anchor and retries.
|
||||
operation == "replace" -> validateReplaceAnchor(path, pathString, request)
|
||||
|
||||
operation == "patch" && !request.parameters.containsKey("patch") ->
|
||||
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
|
||||
|
||||
@@ -177,6 +183,29 @@ class FileEditTool(
|
||||
private fun isPathAllowed(path: Path): Boolean =
|
||||
PathJail.isContained(path, normalizedAllowedPaths)
|
||||
|
||||
/** Valid iff the replace target occurs exactly once. Same 0/>1 messaging as [replace] at execute. */
|
||||
private fun validateReplaceAnchor(path: Path, pathString: String, request: ToolRequest): ValidationResult {
|
||||
val target = request.parameters["target"] as? String ?: return ValidationResult.Valid
|
||||
val content = Files.readString(path)
|
||||
return when (val occurrences = content.split(target).size - 1) {
|
||||
1 -> ValidationResult.Valid
|
||||
0 -> ValidationResult.Invalid(targetNotFoundMessage(pathString, content, target))
|
||||
else -> ValidationResult.Invalid(targetAmbiguousMessage(pathString, occurrences))
|
||||
}
|
||||
}
|
||||
|
||||
private fun targetNotFoundMessage(pathString: String, content: String, target: String): String =
|
||||
buildString {
|
||||
append("Target not found in $pathString")
|
||||
nearestLine(content, target)?.let {
|
||||
append(". Did you mean (whitespace/content may differ): $it ?")
|
||||
}
|
||||
}
|
||||
|
||||
private fun targetAmbiguousMessage(pathString: String, occurrences: Int): String =
|
||||
"Target found $occurrences times in $pathString, expected exactly once — " +
|
||||
"extend it with surrounding lines to make it unique."
|
||||
|
||||
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
|
||||
when (e) {
|
||||
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
|
||||
@@ -270,18 +299,12 @@ class FileEditTool(
|
||||
// always whitespace/content drift the model can correct from a nudge.
|
||||
0 -> ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = buildString {
|
||||
append("Target not found in $pathString")
|
||||
nearestLine(currentContent, target)?.let {
|
||||
append(". Did you mean (whitespace/content may differ): $it ?")
|
||||
}
|
||||
},
|
||||
reason = targetNotFoundMessage(pathString, currentContent, target),
|
||||
recoverable = true,
|
||||
)
|
||||
else -> ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Target found $occurrences times in $pathString, expected exactly once — " +
|
||||
"extend it with surrounding lines to make it unique.",
|
||||
reason = targetAmbiguousMessage(pathString, occurrences),
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
|
||||
+28
@@ -95,6 +95,34 @@ class FileEditToolTest {
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("File not found"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest rejects a replace whose target is absent or ambiguous, before execute`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_edit_anchor")
|
||||
val filePath = tempDir.resolve("test.txt")
|
||||
Files.writeString(filePath, "alpha\nbeta\nbeta\n")
|
||||
val tool = FileEditTool(allowedPaths = setOf(tempDir))
|
||||
|
||||
val absent = tool.validateRequest(
|
||||
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
|
||||
"target" to "gamma", "replacement" to "x")),
|
||||
)
|
||||
assertTrue(absent is ValidationResult.Invalid)
|
||||
assertTrue((absent as ValidationResult.Invalid).reason.contains("Target not found"))
|
||||
|
||||
val ambiguous = tool.validateRequest(
|
||||
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
|
||||
"target" to "beta", "replacement" to "x")),
|
||||
)
|
||||
assertTrue(ambiguous is ValidationResult.Invalid)
|
||||
assertTrue((ambiguous as ValidationResult.Invalid).reason.contains("expected exactly once"))
|
||||
|
||||
val unique = tool.validateRequest(
|
||||
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
|
||||
"target" to "alpha", "replacement" to "x")),
|
||||
)
|
||||
assertEquals(ValidationResult.Valid, unique)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for missing parameters`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_edit_test")
|
||||
|
||||
Reference in New Issue
Block a user