feat: tool-declared output compression (Tier A)
Tools can declare how their output is compressed before it enters the context pack, replacing uniform handling. Open-threads 6.3 / ADR-0003. - core:tools/compression: ToolOutputCompressor interface, IdentityCompressor default, declarative OutputCompressionSpec + CompressionRule (StripBlankLines, StripLeadingWhitespace, DropMatching, HeadTail), and the pure DeclarativeCompressor engine (regexes compiled at construction; bypassOnError passes raw on non-zero exit; never throws on input). - Tool gains `outputCompressor` (default IdentityCompressor). FileReadTool strips blanks + leading whitespace; ShellTool strips blanks + head/tail. - SessionOrchestrator applies the resolved tool's compressor on the ToolResult -> ContextEntry path only. Raw output stays authoritative in the event log (invariant #6); compression is a pure function of (raw, exitCode, spec), so no new event and replay stays deterministic. - Spec is @Serializable/config-shaped (JSON round-trip tested). No custom-tool-from-config path exists today, so first-party tools wire in code; TOML-declared compression rides along when that path lands. Tier B (format-aware parsers: failures-only test output, git-log oneline) is a documented per-tool extension behind the interface, not built here.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
package com.correx.core.tools.compression
|
||||
|
||||
import com.correx.core.tools.compression.CompressionRule.DropMatching
|
||||
import com.correx.core.tools.compression.CompressionRule.HeadTail
|
||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
|
||||
|
||||
/**
|
||||
* Applies an [OutputCompressionSpec] as an ordered, line-oriented pipeline.
|
||||
*
|
||||
* `DropMatching` regexes are compiled once at construction: an invalid pattern
|
||||
* fails at startup, never mid-run. [compress] never throws on input.
|
||||
*/
|
||||
class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutputCompressor {
|
||||
|
||||
/** Compiled regexes, indexed by the rule's position among [DropMatching] rules. */
|
||||
private val compiledPatterns: Map<DropMatching, Regex> =
|
||||
spec.rules.filterIsInstance<DropMatching>().associateWith { Regex(it.pattern) }
|
||||
|
||||
override fun compress(raw: String, ctx: ToolOutputContext): String {
|
||||
if (spec.bypassOnError && ctx.exitCode != 0) return raw
|
||||
if (spec.rules.isEmpty()) return raw
|
||||
|
||||
var lines = raw.split("\n")
|
||||
for (rule in spec.rules) {
|
||||
lines = applyRule(rule, lines)
|
||||
}
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun applyRule(rule: CompressionRule, lines: List<String>): List<String> = when (rule) {
|
||||
StripBlankLines -> lines.filter { it.isNotBlank() }
|
||||
StripLeadingWhitespace -> lines.map { it.trimStart() }
|
||||
is DropMatching -> compiledPatterns[rule]
|
||||
?.let { regex -> lines.filterNot { regex.containsMatchIn(it) } }
|
||||
?: lines
|
||||
is HeadTail -> applyHeadTail(rule, lines)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.correx.core.tools.compression
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Declarative, config-reachable description of how a tool's output is compressed
|
||||
* before entering the context pack. Rules apply in order, line-oriented.
|
||||
*/
|
||||
@Serializable
|
||||
data class OutputCompressionSpec(
|
||||
val rules: List<CompressionRule> = emptyList(),
|
||||
/** Non-zero exit code -> pass raw output through untouched. */
|
||||
val bypassOnError: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* A single line-oriented compression step. Sealed hierarchy with kotlinx
|
||||
* automatic polymorphic serialization (each subtype is @Serializable with a
|
||||
* stable @SerialName). NOT an EventPayload — no eventModule registration.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface CompressionRule {
|
||||
/** Drop lines that are empty or whitespace-only. */
|
||||
@Serializable
|
||||
@SerialName("strip_blank_lines")
|
||||
data object StripBlankLines : CompressionRule
|
||||
|
||||
/** Trim leading whitespace from every line. */
|
||||
@Serializable
|
||||
@SerialName("strip_leading_whitespace")
|
||||
data object StripLeadingWhitespace : CompressionRule
|
||||
|
||||
/** Drop every line matching [pattern] (compiled at compressor construction). */
|
||||
@Serializable
|
||||
@SerialName("drop_matching")
|
||||
data class DropMatching(val pattern: String) : CompressionRule
|
||||
|
||||
/**
|
||||
* When line count exceeds [head] + [tail], keep the first [head] and last
|
||||
* [tail] lines, replacing the middle with a single elision marker line.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("head_tail")
|
||||
data class HeadTail(val head: Int, val tail: Int) : CompressionRule
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.correx.core.tools.compression
|
||||
|
||||
/**
|
||||
* Side-channel facts a [ToolOutputCompressor] may read when deriving a compact
|
||||
* representation of raw tool output. Compression is a pure function of
|
||||
* (raw output, [ToolOutputContext]); it reads no environment state, keeping the
|
||||
* derivation deterministic and replay-safe (invariant #6 / #8).
|
||||
*
|
||||
* Additive by design: a future Tier B compressor (e.g. shell dispatching on
|
||||
* `argv[0]`) grows this with an `argv` field without breaking callers.
|
||||
*/
|
||||
data class ToolOutputContext(val exitCode: Int = 0)
|
||||
|
||||
/**
|
||||
* Derives the context-bound representation of raw tool output. Applied on the
|
||||
* `ToolResult -> ContextEntry` path only; the raw output stays authoritative in
|
||||
* the event log (`ToolReceipt.outputSummary`). Implementations MUST NOT throw on
|
||||
* input.
|
||||
*/
|
||||
interface ToolOutputCompressor {
|
||||
fun compress(raw: String, ctx: ToolOutputContext): String
|
||||
}
|
||||
|
||||
/** No-op compressor: returns raw output unchanged. The [com.correx.core.tools.contract.Tool] default. */
|
||||
object IdentityCompressor : ToolOutputCompressor {
|
||||
override fun compress(raw: String, ctx: ToolOutputContext): String = raw
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.correx.core.tools.contract
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.compression.IdentityCompressor
|
||||
import com.correx.core.tools.compression.ToolOutputCompressor
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
interface Tool {
|
||||
@@ -11,5 +13,12 @@ interface Tool {
|
||||
val tier: Tier
|
||||
val requiredCapabilities: Set<ToolCapability>
|
||||
val paramRoles: Map<String, ParamRole> get() = emptyMap()
|
||||
|
||||
/**
|
||||
* How this tool's raw output is compressed before it enters the context pack.
|
||||
* Applied on the derivation path only; raw output stays authoritative in the
|
||||
* event log (invariant #6). Defaults to no-op.
|
||||
*/
|
||||
val outputCompressor: ToolOutputCompressor get() = IdentityCompressor
|
||||
fun validateRequest(request: ToolRequest): ValidationResult
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.correx.core.tools.compression
|
||||
|
||||
import com.correx.core.tools.compression.CompressionRule.DropMatching
|
||||
import com.correx.core.tools.compression.CompressionRule.HeadTail
|
||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DeclarativeCompressorTest {
|
||||
|
||||
private fun compress(spec: OutputCompressionSpec, raw: String, exitCode: Int = 0): String =
|
||||
DeclarativeCompressor(spec).compress(raw, ToolOutputContext(exitCode))
|
||||
|
||||
@Test
|
||||
fun `StripBlankLines drops empty and whitespace-only lines`() {
|
||||
val out = compress(OutputCompressionSpec(listOf(StripBlankLines)), "a\n\n \nb\n\t\nc")
|
||||
assertEquals("a\nb\nc", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StripLeadingWhitespace trims leading whitespace per line`() {
|
||||
val out = compress(OutputCompressionSpec(listOf(StripLeadingWhitespace)), " a\n\tb\n c")
|
||||
assertEquals("a\nb\nc", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DropMatching drops lines matching the pattern but keeps non-matching errors`() {
|
||||
val out = compress(
|
||||
OutputCompressionSpec(listOf(DropMatching("^Progress: "))),
|
||||
"Progress: 10%\nERROR: boom\nProgress: 50%\ndone",
|
||||
)
|
||||
assertEquals("ERROR: boom\ndone", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HeadTail elides the middle once line count exceeds head plus tail`() {
|
||||
val raw = (1..10).joinToString("\n") { "line$it" }
|
||||
val out = compress(OutputCompressionSpec(listOf(HeadTail(2, 2))), 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" }
|
||||
val out = compress(OutputCompressionSpec(listOf(HeadTail(2, 2))), raw)
|
||||
assertEquals(raw, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read_file spec strips blanks then leading whitespace`() {
|
||||
val spec = OutputCompressionSpec(listOf(StripBlankLines, StripLeadingWhitespace))
|
||||
val out = compress(spec, " fun main() {\n\n println()\n }\n")
|
||||
assertEquals("fun main() {\nprintln()\n}", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell spec strips blanks then applies head-tail with bypassOnError`() {
|
||||
val spec = OutputCompressionSpec(listOf(StripBlankLines, HeadTail(2, 2)), bypassOnError = true)
|
||||
val body = (1..6).joinToString("\n") { "row$it" }
|
||||
val raw = "\n$body\n\n"
|
||||
val out = compress(spec, raw)
|
||||
assertEquals("row1\nrow2\n… 2 lines elided …\nrow5\nrow6", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bypassOnError returns raw unchanged on non-zero exit`() {
|
||||
val spec = OutputCompressionSpec(listOf(StripBlankLines, HeadTail(1, 1)), bypassOnError = true)
|
||||
val raw = "a\n\nb\n\nc\n\nd"
|
||||
assertEquals(raw, compress(spec, raw, exitCode = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bypassOnError disabled still compresses on non-zero exit`() {
|
||||
val spec = OutputCompressionSpec(listOf(StripBlankLines), bypassOnError = false)
|
||||
assertEquals("a\nb", compress(spec, "a\n\nb", exitCode = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty spec is identity`() {
|
||||
val raw = " a\n\n b "
|
||||
assertEquals(raw, compress(OutputCompressionSpec(), raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid DropMatching regex throws at construction`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
DeclarativeCompressor(OutputCompressionSpec(listOf(DropMatching("("))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress never throws on adversarial input`() {
|
||||
val spec = OutputCompressionSpec(listOf(StripBlankLines, StripLeadingWhitespace, HeadTail(1, 1)))
|
||||
val out = DeclarativeCompressor(spec).compress("", ToolOutputContext(0))
|
||||
assertTrue(out.isEmpty())
|
||||
}
|
||||
|
||||
@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)),
|
||||
bypassOnError = false,
|
||||
)
|
||||
val encoded = Json.encodeToString(OutputCompressionSpec.serializer(), spec)
|
||||
val decoded = Json.decodeFromString(OutputCompressionSpec.serializer(), encoded)
|
||||
assertEquals(spec, decoded)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user