feat(tools): collapse-consecutive-duplicates compression rule
adr-0003 §1.3 dedup: collapse runs of identical adjacent lines into `line (×N)`, order-preserving and lossless (count retained). Wired into ShellTool's default spec before HeadTail so the head/tail window holds distinct content. Safe as a default, unlike DropMatching.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.tools.compression
|
||||
|
||||
import com.correx.core.tools.compression.CompressionRule.CollapseConsecutiveDuplicates
|
||||
import com.correx.core.tools.compression.CompressionRule.DropMatching
|
||||
import com.correx.core.tools.compression.CompressionRule.HeadTail
|
||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||
@@ -35,6 +36,7 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
|
||||
?.let { regex -> lines.filterNot { regex.containsMatchIn(it) } }
|
||||
?: lines
|
||||
is HeadTail -> applyHeadTail(rule, lines)
|
||||
CollapseConsecutiveDuplicates -> collapseConsecutive(lines)
|
||||
}
|
||||
|
||||
private fun applyHeadTail(rule: HeadTail, lines: List<String>): List<String> {
|
||||
@@ -44,4 +46,25 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
|
||||
"… $elided lines elided …" +
|
||||
lines.takeLast(rule.tail)
|
||||
}
|
||||
|
||||
private fun collapseConsecutive(lines: List<String>): List<String> {
|
||||
if (lines.isEmpty()) return lines
|
||||
val out = ArrayList<String>(lines.size)
|
||||
var prev = lines.first()
|
||||
var count = 1
|
||||
for (line in lines.drop(1)) {
|
||||
if (line == prev) {
|
||||
count++
|
||||
} else {
|
||||
out += render(prev, count)
|
||||
prev = line
|
||||
count = 1
|
||||
}
|
||||
}
|
||||
out += render(prev, count)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun render(line: String, count: Int): String =
|
||||
if (count > 1) "$line (×$count)" else line
|
||||
}
|
||||
|
||||
@@ -43,4 +43,13 @@ sealed interface CompressionRule {
|
||||
@Serializable
|
||||
@SerialName("head_tail")
|
||||
data class HeadTail(val head: Int, val tail: Int) : CompressionRule
|
||||
|
||||
/**
|
||||
* Collapse runs of identical adjacent lines into a single line, appending an
|
||||
* occurrence count (e.g. `Error: timeout (×14)`) when the run is longer than
|
||||
* one. Order-preserving; never drops unique content — safe as a default.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("collapse_consecutive_duplicates")
|
||||
data object CollapseConsecutiveDuplicates : CompressionRule
|
||||
}
|
||||
|
||||
+19
-1
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.tools.compression
|
||||
|
||||
import com.correx.core.tools.compression.CompressionRule.CollapseConsecutiveDuplicates
|
||||
import com.correx.core.tools.compression.CompressionRule.DropMatching
|
||||
import com.correx.core.tools.compression.CompressionRule.HeadTail
|
||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||
@@ -50,6 +51,20 @@ class DeclarativeCompressorTest {
|
||||
assertEquals(raw, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CollapseConsecutiveDuplicates collapses runs of identical adjacent lines with a count`() {
|
||||
val raw = "Error: timeout\nError: timeout\nError: timeout\nok\nError: timeout"
|
||||
val out = compress(OutputCompressionSpec(listOf(CollapseConsecutiveDuplicates)), raw)
|
||||
assertEquals("Error: timeout (×3)\nok\nError: timeout", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CollapseConsecutiveDuplicates leaves singletons and non-adjacent duplicates untouched`() {
|
||||
val raw = "a\nb\na"
|
||||
val out = compress(OutputCompressionSpec(listOf(CollapseConsecutiveDuplicates)), raw)
|
||||
assertEquals(raw, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read_file spec strips blanks then leading whitespace`() {
|
||||
val spec = OutputCompressionSpec(listOf(StripBlankLines, StripLeadingWhitespace))
|
||||
@@ -102,7 +117,10 @@ class DeclarativeCompressorTest {
|
||||
@Test
|
||||
fun `spec round-trips through JSON with every rule variant (config-reachable)`() {
|
||||
val spec = OutputCompressionSpec(
|
||||
rules = listOf(StripBlankLines, StripLeadingWhitespace, DropMatching("^Progress: "), HeadTail(3, 3)),
|
||||
rules = listOf(
|
||||
StripBlankLines, StripLeadingWhitespace, DropMatching("^Progress: "),
|
||||
HeadTail(3, 3), CollapseConsecutiveDuplicates,
|
||||
),
|
||||
bypassOnError = false,
|
||||
)
|
||||
val encoded = Json.encodeToString(OutputCompressionSpec.serializer(), spec)
|
||||
|
||||
+9
-1
@@ -2,6 +2,7 @@ package com.correx.infrastructure.tools.shell
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.compression.CompressionRule.CollapseConsecutiveDuplicates
|
||||
import com.correx.core.tools.compression.CompressionRule.HeadTail
|
||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||
import com.correx.core.tools.compression.DeclarativeCompressor
|
||||
@@ -50,7 +51,14 @@ class ShellTool(
|
||||
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
|
||||
override val paramRoles: Map<String, ParamRole> = mapOf("argv" to ParamRole.EXEC_COMMAND)
|
||||
override val outputCompressor: ToolOutputCompressor = DeclarativeCompressor(
|
||||
OutputCompressionSpec(listOf(StripBlankLines, HeadTail(HEAD_LINES, TAIL_LINES)), bypassOnError = true),
|
||||
OutputCompressionSpec(
|
||||
// Collapse repeated identical lines (retries, warning spam) before truncating, so the
|
||||
// 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)),
|
||||
bypassOnError = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
|
||||
Reference in New Issue
Block a user