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:
+42
-1
@@ -142,9 +142,15 @@ class LlamaCppInferenceProvider(
|
||||
|
||||
val baseMessages = PromptRenderer.render(request.contextPack)
|
||||
val messages = baseMessages.map { msg ->
|
||||
val toolCalls = if (msg.role == "assistant") {
|
||||
parseAssistantToolCalls(msg.content)
|
||||
} else emptyList()
|
||||
LlamaCppChatMessage(
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
content = if (toolCalls.isNotEmpty()) null else msg.content,
|
||||
reasoningContent = msg.reasoning,
|
||||
toolCalls = toolCalls,
|
||||
toolCallId = msg.toolCallId,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,5 +236,40 @@ class LlamaCppInferenceProvider(
|
||||
ProviderHealth.Unavailable("Health check failed: ${e.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an assistant message's content back into [ToolCallRequest] objects.
|
||||
*
|
||||
* The context builder's FORMAT_COMPRESS stage converts raw ToolCallRequest JSON into a compact
|
||||
* dotted-line format (id = ..., function.name = ...). If compression is disabled, the content
|
||||
* is still valid JSON. This handles both forms so the Gemma4 Jinja template sees the native
|
||||
* `tool_calls` array — which it requires to render `reasoning_content` as a `<|channel>thought`
|
||||
* block (without `tool_calls` the reasoning field is silently dropped and the model re-enters
|
||||
* thought mode from scratch, causing the degenerate `<|channel>thought`~16384-token loop).
|
||||
*/
|
||||
private fun parseAssistantToolCalls(content: String): List<ToolCallRequest> {
|
||||
// Try JSON parse first (FORMAT_COMPRESS disabled — raw ToolCallRequest JSON).
|
||||
if (content.startsWith("{\"id\"")) {
|
||||
runCatching {
|
||||
return listOf(json.decodeFromString<ToolCallRequest>(content))
|
||||
}
|
||||
}
|
||||
// Try dotted-line format (FORMAT_COMPRESS enabled).
|
||||
if (!content.contains("function.name")) return emptyList()
|
||||
val lines = content.split('\n')
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var arguments: String? = null
|
||||
for (line in lines) {
|
||||
when {
|
||||
line.startsWith("id = ") -> id = line.removePrefix("id = ").trim()
|
||||
line.startsWith("function.name = ") -> name = line.removePrefix("function.name = ").trim()
|
||||
line.startsWith("function.arguments = ") -> arguments = line.removePrefix("function.arguments = ").trim()
|
||||
}
|
||||
}
|
||||
return if (name != null && arguments != null) {
|
||||
listOf(ToolCallRequest(id = id, function = ToolCallFunction(name = name, arguments = arguments)))
|
||||
} else emptyList()
|
||||
}
|
||||
|
||||
override fun capabilities(): Set<CapabilityScore> = descriptor.capabilities
|
||||
}
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ class OpenAiCompatInferenceProvider(
|
||||
// tool_calls turn (we don't carry tool_call_ids through the context pack). Fold any
|
||||
// non-standard role into a user turn so the model still sees the content.
|
||||
if (msg.role in STANDARD_ROLES) {
|
||||
OpenAiChatMessage(role = msg.role, content = msg.content)
|
||||
OpenAiChatMessage(role = msg.role, content = msg.content, reasoningContent = msg.reasoning)
|
||||
} else {
|
||||
OpenAiChatMessage(role = "user", content = "[${msg.role}] ${msg.content}")
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+30
-1
@@ -41,7 +41,7 @@ object PlanLinter {
|
||||
|
||||
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
|
||||
PlanLintResult(
|
||||
hardFailures = unproducedNeeds(graph, seeds) + trapStates(graph),
|
||||
hardFailures = unproducedNeeds(graph, seeds) + trapStates(graph) + unreferencedPromptArtifacts(graph, seeds),
|
||||
softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph),
|
||||
)
|
||||
|
||||
@@ -89,6 +89,35 @@ object PlanLinter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H3: a stage's inline prompt mentions an artifact ID that is produced by the plan or
|
||||
* provided by a seed, but the stage does not declare it in its `needs`. The stage will
|
||||
* not have access to that artifact at runtime, so any reference in the prompt is a dangling
|
||||
* assumption that will confuse or stall the model — force the architect to either add the
|
||||
* artifact to `needs` or reword the prompt.
|
||||
*/
|
||||
private fun unreferencedPromptArtifacts(graph: WorkflowGraph, seeds: Set<String>): List<PlanLintFinding> {
|
||||
val produced = graph.stages.values.flatMap { it.produces }.map { it.name.value }.toSet() + seeds
|
||||
return graph.stages.entries.flatMap { (id, stage) ->
|
||||
val prompt = briefOf(stage)
|
||||
val needIds = stage.needs.map { it.value }.toSet()
|
||||
val ownProduces = stage.produces.map { it.name.value }.toSet()
|
||||
produced.filter { prodId ->
|
||||
prompt.isNotBlank() &&
|
||||
prodId.length >= 2 && // skip single-char IDs (false-positive risk with substring matching)
|
||||
Regex("\\b${Regex.escape(prodId)}\\b").containsMatchIn(prompt) &&
|
||||
prodId !in needIds &&
|
||||
prodId !in ownProduces
|
||||
}.map { prodId ->
|
||||
PlanLintFinding(
|
||||
code = "unreferenced_prompt_artifact",
|
||||
stageId = id.value,
|
||||
detail = "prompt mentions '$prodId' but it is not declared in needs",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S1: more stages than the ceiling. */
|
||||
private fun stageCount(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
if (graph.stages.size > STAGE_CEILING) {
|
||||
|
||||
+31
@@ -92,6 +92,37 @@ class PlanLinterTest {
|
||||
assertTrue(result.passed, "analysis is a planning-phase seed, not an unproduced need")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage mentioning its own produced artifact in the prompt is not a failure`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(produces = listOf("plan"), prompt = "write the plan artifact"),
|
||||
"b" to stage(needs = listOf("plan"), prompt = "build"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed, "expected pass, hard=${result.hardFailures}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prompt mentioning another stage's artifact not in needs is a hard failure`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(produces = listOf("plan"), prompt = "write the plan artifact"),
|
||||
"b" to stage(produces = listOf("report"), prompt = "use plan to build"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertFalse(result.passed)
|
||||
val h = result.hardFailures.single { it.code == "unreferenced_prompt_artifact" }
|
||||
assertEquals("b", h.stageId)
|
||||
assertTrue(h.detail.contains("plan"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cycle with no exit is a trap-state hard failure`() {
|
||||
val g = graph(
|
||||
|
||||
Reference in New Issue
Block a user