feat(context): reasoning-thread continuity, L3 doc filtering, and gate hardening
Threads reasoning_content across inference calls and journal renders, filters markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks, and tightens filesystem tool output/dir-listing behavior surfaced by prior live QA (see project_readloop_campaign memory).
This commit is contained in:
+11
-1
@@ -135,8 +135,18 @@ class FileEditTool(
|
||||
operation == "patch" && !request.parameters.containsKey("patch") ->
|
||||
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
|
||||
|
||||
// Don't point the model at 'file_read'/'list_dir' here — this rejection is commonly hit
|
||||
// in exactly the round those tools have been deliberately withheld (the read-loop
|
||||
// breaker forces a write-only round after repeated reads with no write), so telling it
|
||||
// to go use tools that aren't in its tool list this round would just compound the stall.
|
||||
operation == "read" ->
|
||||
ValidationResult.Invalid(
|
||||
"'edit_file' has no 'read' operation. It only supports 'append', 'replace', or 'patch' — " +
|
||||
"proceed with one of those using what you already know about the file.",
|
||||
)
|
||||
|
||||
operation !in listOf("append", "replace", "patch") ->
|
||||
ValidationResult.Invalid("Unknown operation: $operation")
|
||||
ValidationResult.Invalid("Unknown operation: $operation. Expected 'append', 'replace', or 'patch'.")
|
||||
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
|
||||
+1
@@ -154,6 +154,7 @@ class FileWriteTool(
|
||||
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
|
||||
|
||||
val result = runCatching {
|
||||
Files.createDirectories(path.parent)
|
||||
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
|
||||
+102
-10
@@ -97,8 +97,17 @@ class ListDirTool(
|
||||
val recursive = request.parameters["recursive"]?.toString()?.toBoolean() ?: false
|
||||
val root = resolvePath(pathString)
|
||||
when {
|
||||
!Files.exists(root) ->
|
||||
ToolResult.Failure(request.invocationId, "Directory not found: $pathString", recoverable = true)
|
||||
!Files.exists(root) -> {
|
||||
val suggestions = similarEntries(root)
|
||||
val msg = buildString {
|
||||
append("Directory not found: $pathString")
|
||||
if (suggestions.isNotEmpty()) {
|
||||
append(". Did you mean ")
|
||||
append(suggestions.joinToString(", ", postfix = "?"))
|
||||
}
|
||||
}
|
||||
ToolResult.Failure(request.invocationId, msg, recoverable = true)
|
||||
}
|
||||
!Files.isDirectory(root) ->
|
||||
ToolResult.Failure(request.invocationId, "Not a directory: $pathString", recoverable = true)
|
||||
else -> runCatching { walk(root, recursive, request) }.getOrElse {
|
||||
@@ -109,8 +118,13 @@ class ListDirTool(
|
||||
|
||||
private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult {
|
||||
val matcher = GitignoreMatcher(workingDir ?: root, root)
|
||||
// The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire
|
||||
// subtree gitignored as scaffold output), don't let that same rule keep pruning everything
|
||||
// found underneath; other, unrelated rules (node_modules/ inside it, etc.) still apply.
|
||||
matcher.exemptRulesMatching(root)
|
||||
val out = ArrayList<String>()
|
||||
var truncated = false
|
||||
var ignoredCount = 0
|
||||
// Emit one entry; TERMINATE the walk once the cap is hit so a huge tree can't run away.
|
||||
fun emit(path: Path, isDir: Boolean): FileVisitResult {
|
||||
val slash = if (isDir) "/" else ""
|
||||
@@ -121,8 +135,11 @@ class ListDirTool(
|
||||
val maxDepth = if (recursive) Int.MAX_VALUE else 1
|
||||
Files.walkFileTree(root, emptySet(), maxDepth, object : SimpleFileVisitor<Path>() {
|
||||
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
if (dir != root && (dir.name == ".git" || matcher.ignored(dir, isDir = true)))
|
||||
if (dir != root && dir.name == ".git") return FileVisitResult.SKIP_SUBTREE
|
||||
if (dir != root && matcher.ignored(dir, isDir = true)) {
|
||||
ignoredCount++
|
||||
return FileVisitResult.SKIP_SUBTREE
|
||||
}
|
||||
matcher.loadFrom(dir)
|
||||
return if (dir == root) FileVisitResult.CONTINUE else emit(dir, isDir = true)
|
||||
}
|
||||
@@ -130,15 +147,83 @@ class ListDirTool(
|
||||
// A directory sitting exactly at maxDepth is delivered here (walkFileTree won't
|
||||
// descend it), so honour its dir-ness for both the ignore check and the trailing '/'.
|
||||
val isDir = attrs.isDirectory
|
||||
return if (matcher.ignored(file, isDir)) FileVisitResult.CONTINUE else emit(file, isDir)
|
||||
if (matcher.ignored(file, isDir)) {
|
||||
ignoredCount++
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
return emit(file, isDir)
|
||||
}
|
||||
override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
|
||||
})
|
||||
out.sort()
|
||||
val body = out.joinToString("\n").ifEmpty { "(empty)" }
|
||||
val note =
|
||||
if (truncated) "\n… truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory." else ""
|
||||
return ToolResult.Success(request.invocationId, output = body + note)
|
||||
return ToolResult.Success(request.invocationId, output = render(root, out, truncated, ignoredCount))
|
||||
}
|
||||
|
||||
/**
|
||||
* `<path>/<type>/<entries>` wrapper (matching the shape other coding-agent tools use) instead of
|
||||
* a bare newline list — the count and explicit `directory` tag let a small model tell "empty
|
||||
* listing" from "malformed output" and know how many entries to expect. Notes on truncation/
|
||||
* gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for
|
||||
* the real tree.
|
||||
*/
|
||||
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
|
||||
val notes = buildList {
|
||||
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
|
||||
if (ignoredCount > 0) add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
}
|
||||
return buildString {
|
||||
appendLine("<path>$root</path>")
|
||||
appendLine("<type>directory</type>")
|
||||
appendLine("<entries>")
|
||||
appendLine(entries.joinToString("\n").ifEmpty { "(empty)" })
|
||||
if (notes.isNotEmpty()) appendLine(notes.joinToString("; "))
|
||||
append("(${entries.size} ${if (entries.size == 1) "entry" else "entries"})")
|
||||
appendLine()
|
||||
append("</entries>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun similarEntries(target: Path): List<String> {
|
||||
val parent = target.parent ?: return emptyList()
|
||||
if (!Files.isDirectory(parent)) return emptyList()
|
||||
val targetName = target.fileName?.toString() ?: return emptyList()
|
||||
return try {
|
||||
Files.list(parent).use { stream ->
|
||||
stream.map { it.fileName.toString() }
|
||||
.filter { it != targetName && isSimilarName(it, targetName) }
|
||||
.toList()
|
||||
.sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it })
|
||||
.take(3)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun isSimilarName(a: String, b: String): Boolean {
|
||||
val prefix = commonPrefixLen(a, b)
|
||||
return prefix >= 2 || levenshtein(a, b) <= 3
|
||||
}
|
||||
|
||||
private fun commonPrefixLen(a: String, b: String): Int {
|
||||
var i = 0
|
||||
val minLen = minOf(a.length, b.length)
|
||||
while (i < minLen && a[i].lowercaseChar() == b[i].lowercaseChar()) i++
|
||||
return i
|
||||
}
|
||||
|
||||
private fun levenshtein(a: String, b: String): Int {
|
||||
val dp = Array(a.length + 1) { IntArray(b.length + 1) }
|
||||
for (i in 0..a.length) dp[i][0] = i
|
||||
for (j in 0..b.length) dp[0][j] = j
|
||||
for (i in 1..a.length) {
|
||||
for (j in 1..b.length) {
|
||||
dp[i][j] = minOf(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + if (a[i - 1].lowercaseChar() == b[i - 1].lowercaseChar()) 0 else 1
|
||||
)
|
||||
}
|
||||
}
|
||||
return dp[a.length][b.length]
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@@ -176,10 +261,17 @@ internal class GitignoreMatcher(anchor: Path, target: Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fun ignored(path: Path, isDir: Boolean): Boolean = rules.any { r ->
|
||||
fun ignored(path: Path, isDir: Boolean): Boolean = rules.any { matches(it, path, isDir) }
|
||||
|
||||
/** Drop whichever rules currently cover [path] — used so an explicitly-targeted (already
|
||||
* gitignored) listing root doesn't have that same rule keep pruning its own children. */
|
||||
fun exemptRulesMatching(path: Path) {
|
||||
rules.removeAll { matches(it, path, isDir = true) }
|
||||
}
|
||||
|
||||
private fun matches(r: Rule, path: Path, isDir: Boolean): Boolean =
|
||||
(!r.dirOnly || isDir) && path.startsWith(r.base) &&
|
||||
r.regex.matches(r.base.relativize(path).toString().replace('\\', '/'))
|
||||
}
|
||||
|
||||
private fun compile(base: Path, raw: String): Rule {
|
||||
var pat = raw
|
||||
|
||||
Reference in New Issue
Block a user