From 97ad456778c6e58b94570c5b2aa96f6e2d5f9606 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 2 Jun 2026 13:57:57 +0400 Subject: [PATCH] feat(tools): collapse-consecutive-duplicates compression rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../compression/DeclarativeCompressor.kt | 23 +++++++++++++++++++ .../compression/OutputCompressionSpec.kt | 9 ++++++++ .../compression/DeclarativeCompressorTest.kt | 20 +++++++++++++++- .../infrastructure/tools/shell/ShellTool.kt | 10 +++++++- 4 files changed, 60 insertions(+), 2 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 4636de8b..785557e7 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 @@ -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): List { @@ -44,4 +46,25 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu "… $elided lines elided …" + lines.takeLast(rule.tail) } + + private fun collapseConsecutive(lines: List): List { + if (lines.isEmpty()) return lines + val out = ArrayList(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 } 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 7d300221..614b5837 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 @@ -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 } 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 57a90479..db2a01eb 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 @@ -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) 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 f74da053..7ff09e29 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 @@ -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 = 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 {