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:
+32
-4
@@ -41,10 +41,38 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
|
||||
|
||||
private fun applyHeadTail(rule: HeadTail, lines: List<String>): List<String> {
|
||||
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<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> {
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
+24
@@ -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" }
|
||||
|
||||
+8
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user