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> {
|
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> {
|
||||||
|
|||||||
+11
-2
@@ -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
|
||||||
|
|||||||
+24
@@ -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" }
|
||||||
|
|||||||
+8
-1
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user