From d03479cea7d9b1f48c6334e4a65fa65600b5d31e Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 31 May 2026 21:20:16 +0400 Subject: [PATCH] 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. --- .../orchestration/SessionOrchestrator.kt | 13 +- .../compression/DeclarativeCompressor.kt | 47 ++++++++ .../compression/OutputCompressionSpec.kt | 46 +++++++ .../tools/compression/ToolOutputCompressor.kt | 27 +++++ .../com/correx/core/tools/contract/Tool.kt | 9 ++ .../compression/DeclarativeCompressorTest.kt | 112 ++++++++++++++++++ .../tools/filesystem/FileReadTool.kt | 8 ++ .../tools/filesystem/FileReadToolTest.kt | 9 ++ .../infrastructure/tools/shell/ShellTool.kt | 13 ++ .../tools/shell/ShellToolTest.kt | 22 ++++ .../src/test/kotlin/ToolCallGateTest.kt | 45 ++++++- 11 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/compression/DeclarativeCompressor.kt create mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/compression/OutputCompressionSpec.kt create mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/compression/ToolOutputCompressor.kt create mode 100644 core/tools/src/test/kotlin/com/correx/core/tools/compression/DeclarativeCompressorTest.kt diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index eb0bbcae..a5ee4cfd 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -82,6 +82,8 @@ import com.correx.core.risk.RiskAssessor import com.correx.core.risk.RiskContext import com.correx.core.risk.toApprovalTier import com.correx.core.sessions.Session +import com.correx.core.tools.compression.ToolOutputContext +import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.registry.ToolRegistry @@ -365,7 +367,8 @@ abstract class SessionOrchestrator( toolName = toolCall.function.name, parameters = parameters, ) - val tier = toolRegistry?.resolve(toolCall.function.name)?.tier ?: Tier.T2 + val tool = toolRegistry?.resolve(toolCall.function.name) + val tier = tool?.tier ?: Tier.T2 emit( sessionId, ToolInvocationRequestedEvent( @@ -378,7 +381,7 @@ abstract class SessionOrchestrator( ), ) val plane2Risk: RiskSummary? = runPlane2Assessment( - sessionId, stageId, invocationId, toolCall.function.name, request, + sessionId, stageId, invocationId, toolCall.function.name, request, tool, )?.let { assessment -> if (assessment.recommendedAction == RiskAction.BLOCK) { emit( @@ -571,7 +574,9 @@ abstract class SessionOrchestrator( role = EntryRole.ASSISTANT, ) val resultContent = when (result) { - is ToolResult.Success -> result.output + is ToolResult.Success -> + tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode)) + ?: result.output is ToolResult.Failure -> { if (!result.recoverable) "FATAL: ${result.reason}" else "ERROR: ${result.reason}" @@ -596,10 +601,10 @@ abstract class SessionOrchestrator( invocationId: ToolInvocationId, toolName: String, request: ToolRequest, + tool: Tool?, ): RiskSummary? { val assessor = toolCallAssessor ?: return null val policy = workspacePolicy ?: return null - val tool = toolRegistry?.resolve(toolName) val assessment = assessor.assess( ToolCallAssessmentInput( request = request, 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 new file mode 100644 index 00000000..4636de8b --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/compression/DeclarativeCompressor.kt @@ -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 = + spec.rules.filterIsInstance().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): List = 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): List { + 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) + } +} 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 new file mode 100644 index 00000000..7d300221 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/compression/OutputCompressionSpec.kt @@ -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 = 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 +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/compression/ToolOutputCompressor.kt b/core/tools/src/main/kotlin/com/correx/core/tools/compression/ToolOutputCompressor.kt new file mode 100644 index 00000000..834adcd6 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/compression/ToolOutputCompressor.kt @@ -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 +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt index be6a3fe3..dd9729e9 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt @@ -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 val paramRoles: Map 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 } 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 new file mode 100644 index 00000000..57a90479 --- /dev/null +++ b/core/tools/src/test/kotlin/com/correx/core/tools/compression/DeclarativeCompressorTest.kt @@ -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 { + 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) + } +} diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 38382c5a..931693a7 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -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 = setOf(ToolCapability.FILE_READ) override val paramRoles: Map = 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 diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt index 1653bcbb..6a2f2062 100644 --- a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileReadToolTest.kt @@ -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) + } } 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 f2906e37..f74da053 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,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 = 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), + ) 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() } } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt index e4d8c5df..1599b099 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt @@ -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) + } } diff --git a/testing/integration/src/test/kotlin/ToolCallGateTest.kt b/testing/integration/src/test/kotlin/ToolCallGateTest.kt index 6e8ac8d9..5a7c0483 100644 --- a/testing/integration/src/test/kotlin/ToolCallGateTest.kt +++ b/testing/integration/src/test/kotlin/ToolCallGateTest.kt @@ -47,6 +47,9 @@ import com.correx.core.toolintent.ToolCallAssessmentInput import com.correx.core.toolintent.ToolCallAssessor import com.correx.core.toolintent.ToolCallRule import com.correx.core.toolintent.WorkspacePolicy +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.contract.Tool import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolExecutor @@ -89,6 +92,16 @@ class ToolCallGateTest { override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid } + /** A tool that declares an output compressor (strips blank lines) — the Tier-A seam under test. */ + private inner class FakeCompressingTool(override val tier: Tier = Tier.T1) : Tool { + override val name = "file_write" + override val description = "fake compressing tool" + override val parametersSchema: JsonObject = buildJsonObject {} + override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) + override val outputCompressor = DeclarativeCompressor(OutputCompressionSpec(listOf(StripBlankLines))) + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid + } + private inner class FakeToolRegistry(private val tool: Tool) : ToolRegistry { override fun resolve(name: String): Tool? = if (name == tool.name) tool else null override fun all(): List = listOf(tool) @@ -106,9 +119,11 @@ class ToolCallGateTest { override val id = ProviderId("tool-caller") override val name = "tool-caller" override val tokenizer: Tokenizer = MockTokenizer() + val requests = mutableListOf() private var callCount = 0 override suspend fun infer(request: InferenceRequest): InferenceResponse { + requests += request callCount++ return if (callCount == 1) { InferenceResponse( @@ -150,7 +165,7 @@ class ToolCallGateTest { executor: ToolExecutor, tool: Tool, assessorRule: ToolCallRule?, - ): Pair { + ): Triple { val eventStore = InMemoryEventStore() val artifactStore = NoopArtifactStore() val toolRegistry = FakeToolRegistry(tool) @@ -203,7 +218,7 @@ class ToolCallGateTest { artifactStore = artifactStore, ) - return Pair(orchestrator, eventStore) + return Triple(orchestrator, eventStore, provider) } private fun singleStageGraph(allowedTools: Set = setOf("file_write")): WorkflowGraph = @@ -221,7 +236,7 @@ class ToolCallGateTest { @Test fun `BLOCK rule prevents executor from being called and emits rejected event`(): Unit = runBlocking { val executor = RecordingExecutor() - val (orchestrator, eventStore) = buildOrchestrator(executor, FakeFileWriteTool(), ruleReturning(RiskAction.BLOCK)) + val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), ruleReturning(RiskAction.BLOCK)) val sessionId = SessionId("gate-block") val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) @@ -237,7 +252,7 @@ class ToolCallGateTest { fun `PROMPT_USER rule on T2 tool triggers approval with plane2 risk summary`(): Unit = runBlocking { val executor = RecordingExecutor() // Use T2 so the engine does not auto-approve (PROMPT mode auto-approves up to T1 only) - val (orchestrator, eventStore) = buildOrchestrator( + val (orchestrator, eventStore, _) = buildOrchestrator( executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER), ) val sessionId = SessionId("gate-prompt") @@ -264,7 +279,7 @@ class ToolCallGateTest { @Test fun `null assessor means executor is called normally (regression guard)`(): Unit = runBlocking { val executor = RecordingExecutor() - val (orchestrator, eventStore) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null) + val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null) val sessionId = SessionId("gate-null") val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) @@ -274,4 +289,24 @@ class ToolCallGateTest { assertTrue(executor.executeCalled.get(), "executor must be called when assessor is null") assertNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must NOT be emitted when assessor is null") } + + @Test + fun `tool output is compressed on the path into context`(): Unit = runBlocking { + val rawOutput = "line1\n\nline2\n\n\nline3" + val executor = object : ToolExecutor { + override suspend fun execute(request: ToolRequest): ToolResult = + ToolResult.Success(invocationId = request.invocationId, output = rawOutput, exitCode = 0) + } + val (orchestrator, _, provider) = buildOrchestrator(executor, FakeCompressingTool(), assessorRule = null) + val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + + orchestrator.run(SessionId("compress"), singleStageGraph(), config) + + // The second inference carries round-1's tool result in its context pack — compressed + // (blank lines stripped), not raw. Raw output stays authoritative on the executor's + // event path (invariant #6); the compressor only shapes the derived context entry. + val secondPack = provider.requests[1].contextPack + val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" } + assertEquals("line1\nline2\nline3", toolEntry.content) + } }