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:
2026-05-31 21:20:16 +04:00
parent 7fa1db8345
commit d03479cea7
11 changed files with 342 additions and 9 deletions
@@ -2,6 +2,11 @@ package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
import com.correx.core.tools.compression.DeclarativeCompressor
import com.correx.core.tools.compression.OutputCompressionSpec
import com.correx.core.tools.compression.ToolOutputCompressor
import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
@@ -49,6 +54,9 @@ class FileReadTool(
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override val outputCompressor: ToolOutputCompressor = DeclarativeCompressor(
OutputCompressionSpec(listOf(StripBlankLines, StripLeadingWhitespace)),
)
override fun validateRequest(request: ToolRequest): ValidationResult {
val pathString = request.parameters["path"] as? String
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
@@ -109,4 +110,12 @@ class FileReadToolTest {
val failure = result as ToolResult.Failure
assertTrue(failure.reason.contains("is not in the allowed list"))
}
@Test
fun `outputCompressor strips blank lines and leading whitespace`() {
val tool = FileReadTool()
val raw = " fun main() {\n\n println()\n }\n"
val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 0))
assertEquals("fun main() {\nprintln()\n}", compressed)
}
}
@@ -2,6 +2,11 @@ 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.HeadTail
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
import com.correx.core.tools.compression.DeclarativeCompressor
import com.correx.core.tools.compression.OutputCompressionSpec
import com.correx.core.tools.compression.ToolOutputCompressor
import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
@@ -44,6 +49,9 @@ class ShellTool(
override val requiredCapabilities: Set<ToolCapability> =
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),
)
override fun validateRequest(request: ToolRequest): ValidationResult {
val argv = when (val raw = request.parameters["argv"]) {
@@ -103,6 +111,11 @@ class ShellTool(
}
}
private companion object {
const val HEAD_LINES = 40
const val TAIL_LINES = 40
}
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
@@ -112,4 +113,25 @@ class ShellToolTest {
assertEquals("Process timed out after 100ms", failure.reason)
assertFalse(failure.recoverable)
}
@Test
fun `outputCompressor strips blanks and head-tail elides long output`() {
val tool = ShellTool()
val body = (1..100).joinToString("\n") { "line$it" }
val raw = "\n$body\n\n"
val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 0))
val lines = compressed.split("\n")
assertEquals("line1", lines.first())
assertEquals("line100", lines.last())
assertEquals(81, lines.size) // 40 head + 1 marker + 40 tail
assertTrue(compressed.contains("lines elided"))
}
@Test
fun `outputCompressor passes raw through on non-zero exit`() {
val tool = ShellTool()
val raw = "a\n\nb\n\nc"
val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 1))
assertEquals(raw, compressed)
}
}