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:
@@ -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<ToolCapability> = 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<Tool> = 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<InferenceRequest>()
|
||||
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<DefaultSessionOrchestrator, InMemoryEventStore> {
|
||||
): Triple<DefaultSessionOrchestrator, InMemoryEventStore, ToolCallingProvider> {
|
||||
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<String> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user