feat(tools): salience-aware shell output compression — retain error lines through HeadTail (#67)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 12:13:31 +04:00
parent 3e31ebcc1e
commit e5a5cddc96
4 changed files with 75 additions and 7 deletions
@@ -41,10 +41,38 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
private fun applyHeadTail(rule: HeadTail, lines: List<String>): List<String> { private fun applyHeadTail(rule: HeadTail, lines: List<String>): List<String> {
if (lines.size <= rule.head + rule.tail) return lines if (lines.size <= rule.head + rule.tail) return lines
val elided = lines.size - rule.head - rule.tail val middle = lines.subList(rule.head, lines.size - rule.tail)
return lines.take(rule.head) + val salienceRegex = rule.salience?.let { Regex(it, RegexOption.IGNORE_CASE) }
"$elided lines elided …" + val middleOut = if (salienceRegex == null) {
lines.takeLast(rule.tail) 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<String>, salienceRegex: Regex, cap: Int): List<String> {
val out = ArrayList<String>()
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<String>): List<String> { private fun collapseConsecutive(lines: List<String>): List<String> {
@@ -38,11 +38,20 @@ sealed interface CompressionRule {
/** /**
* When line count exceeds [head] + [tail], keep the first [head] and last * 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 @Serializable
@SerialName("head_tail") @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 * Collapse runs of identical adjacent lines into a single line, appending an
@@ -44,6 +44,30 @@ class DeclarativeCompressorTest {
assertEquals("line1\nline2\n… 6 lines elided …\nline9\nline10", out) 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 @Test
fun `HeadTail is a no-op at or under threshold`() { fun `HeadTail is a no-op at or under threshold`() {
val raw = (1..4).joinToString("\n") { "line$it" } val raw = (1..4).joinToString("\n") { "line$it" }
@@ -62,7 +62,11 @@ class ShellTool(
// head/tail window holds distinct content rather than duplicates. Dedup is lossless // 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 // (count is preserved) and order-preserving, so it is safe as a default — unlike a
// progress-dropping regex, which can silently eat real output. // 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, bypassOnError = true,
), ),
) )
@@ -213,6 +217,9 @@ class ShellTool(
private companion object { private companion object {
const val HEAD_LINES = 40 const val HEAD_LINES = 40
const val TAIL_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." 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_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit")
val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1") val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1")