From e5a5cddc96bff29ff616cb978681749bd5b8f9c6 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 17 Jul 2026 12:13:31 +0400 Subject: [PATCH] =?UTF-8?q?feat(tools):=20salience-aware=20shell=20output?= =?UTF-8?q?=20compression=20=E2=80=94=20retain=20error=20lines=20through?= =?UTF-8?q?=20HeadTail=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HeadTail now accepts an optional salience regex + cap: middle lines matching it (error|fail|exception|panic|traceback|✗, case-insensitive) survive truncation in place instead of being silently dropped, so a decisive error buried in the middle of a long build/test log still reaches the model-facing context entry. ShellTool's outputCompressor spec wires this in; the raw build-gate receipt path is untouched. Co-Authored-By: Claude Sonnet 5 --- .../compression/DeclarativeCompressor.kt | 36 ++++++++++++++++--- .../compression/OutputCompressionSpec.kt | 13 +++++-- .../compression/DeclarativeCompressorTest.kt | 24 +++++++++++++ .../infrastructure/tools/shell/ShellTool.kt | 9 ++++- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/compression/DeclarativeCompressor.kt b/core/tools/src/main/kotlin/com/correx/core/tools/compression/DeclarativeCompressor.kt index 785557e7..7ee1cc1c 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/compression/DeclarativeCompressor.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/compression/DeclarativeCompressor.kt @@ -41,10 +41,38 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu private fun applyHeadTail(rule: HeadTail, lines: List): List { if (lines.size <= rule.head + rule.tail) return lines - val elided = lines.size - rule.head - rule.tail - return lines.take(rule.head) + - "… $elided lines elided …" + - lines.takeLast(rule.tail) + val middle = lines.subList(rule.head, lines.size - rule.tail) + val salienceRegex = rule.salience?.let { Regex(it, RegexOption.IGNORE_CASE) } + val middleOut = if (salienceRegex == null) { + listOf("… ${middle.size} lines elided …") + } else { + renderSalientMiddle(middle, salienceRegex, rule.salienceCap) + } + return lines.take(rule.head) + middleOut + lines.takeLast(rule.tail) + } + + // Walks the elided middle, keeping order and retaining up to [cap] salient lines in place; + // runs of dropped lines around them collapse into a single elision marker each, so a decisive + // error at line 250 of 500 survives even though the bulk of the log is still bounded. + private fun renderSalientMiddle(middle: List, salienceRegex: Regex, cap: Int): List { + val out = ArrayList() + var elidedCount = 0 + var keptSalient = 0 + for (line in middle) { + val isSalient = keptSalient < cap && salienceRegex.containsMatchIn(line) + if (isSalient) { + if (elidedCount > 0) { + out += "… $elidedCount lines elided …" + elidedCount = 0 + } + out += line + keptSalient++ + } else { + elidedCount++ + } + } + if (elidedCount > 0) out += "… $elidedCount lines elided …" + return out } private fun collapseConsecutive(lines: List): List { diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/compression/OutputCompressionSpec.kt b/core/tools/src/main/kotlin/com/correx/core/tools/compression/OutputCompressionSpec.kt index 614b5837..9e05fec1 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/compression/OutputCompressionSpec.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/compression/OutputCompressionSpec.kt @@ -38,11 +38,20 @@ sealed interface CompressionRule { /** * When line count exceeds [head] + [tail], keep the first [head] and last - * [tail] lines, replacing the middle with a single elision marker line. + * [tail] lines. If [salience] is set, middle lines matching it (case-insensitive) + * are also retained in place — up to [salienceCap] of them — so a decisive error + * buried in the middle of a long log survives truncation; remaining elided runs + * collapse to a single "… N lines elided …" marker. Without [salience] the whole + * middle collapses to one marker, as before. */ @Serializable @SerialName("head_tail") - data class HeadTail(val head: Int, val tail: Int) : CompressionRule + data class HeadTail( + val head: Int, + val tail: Int, + val salience: String? = null, + val salienceCap: Int = 30, + ) : CompressionRule /** * Collapse runs of identical adjacent lines into a single line, appending an diff --git a/core/tools/src/test/kotlin/com/correx/core/tools/compression/DeclarativeCompressorTest.kt b/core/tools/src/test/kotlin/com/correx/core/tools/compression/DeclarativeCompressorTest.kt index db2a01eb..d9778a7e 100644 --- a/core/tools/src/test/kotlin/com/correx/core/tools/compression/DeclarativeCompressorTest.kt +++ b/core/tools/src/test/kotlin/com/correx/core/tools/compression/DeclarativeCompressorTest.kt @@ -44,6 +44,30 @@ class DeclarativeCompressorTest { assertEquals("line1\nline2\n… 6 lines elided …\nline9\nline10", out) } + @Test + fun `HeadTail with salience retains a matching middle line otherwise elided`() { + val lines = (1..20).map { if (it == 10) "boom: ERROR occurred" else "line$it" } + val out = compress(OutputCompressionSpec(listOf(HeadTail(2, 2, salience = "error"))), lines.joinToString("\n")) + assertEquals( + "line1\nline2\n… 7 lines elided …\nboom: ERROR occurred\n… 8 lines elided …\nline19\nline20", + out, + ) + } + + @Test + fun `HeadTail with salience caps the number of retained salient lines`() { + val lines = (1..20).map { "error$it" } + val out = compress(OutputCompressionSpec(listOf(HeadTail(0, 0, salience = "error", salienceCap = 3))), lines.joinToString("\n")) + assertEquals("error1\nerror2\nerror3\n… 17 lines elided …", out) + } + + @Test + fun `HeadTail with salience preserves order and behaves like plain HeadTail when nothing matches`() { + val raw = (1..10).joinToString("\n") { "line$it" } + val out = compress(OutputCompressionSpec(listOf(HeadTail(2, 2, salience = "error"))), raw) + assertEquals("line1\nline2\n… 6 lines elided …\nline9\nline10", out) + } + @Test fun `HeadTail is a no-op at or under threshold`() { val raw = (1..4).joinToString("\n") { "line$it" } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt index 85faa146..123f947e 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -62,7 +62,11 @@ class ShellTool( // head/tail window holds distinct content rather than duplicates. Dedup is lossless // (count is preserved) and order-preserving, so it is safe as a default — unlike a // progress-dropping regex, which can silently eat real output. - listOf(StripBlankLines, CollapseConsecutiveDuplicates, HeadTail(HEAD_LINES, TAIL_LINES)), + listOf( + StripBlankLines, + CollapseConsecutiveDuplicates, + HeadTail(HEAD_LINES, TAIL_LINES, salience = SALIENCE_PATTERN), + ), bypassOnError = true, ), ) @@ -213,6 +217,9 @@ class ShellTool( private companion object { const val HEAD_LINES = 40 const val TAIL_LINES = 40 + // Case-insensitive: a decisive error line buried in the middle of a long build/test log + // (line 250 of 500) survives the head/tail window instead of being silently dropped. + const val SALIENCE_PATTERN = "error|fail|exception|panic|traceback|✗" const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings." val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit") val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1")