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:
+9
-4
@@ -82,6 +82,8 @@ import com.correx.core.risk.RiskAssessor
|
|||||||
import com.correx.core.risk.RiskContext
|
import com.correx.core.risk.RiskContext
|
||||||
import com.correx.core.risk.toApprovalTier
|
import com.correx.core.risk.toApprovalTier
|
||||||
import com.correx.core.sessions.Session
|
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.ToolExecutor
|
||||||
import com.correx.core.tools.contract.ToolResult
|
import com.correx.core.tools.contract.ToolResult
|
||||||
import com.correx.core.tools.registry.ToolRegistry
|
import com.correx.core.tools.registry.ToolRegistry
|
||||||
@@ -365,7 +367,8 @@ abstract class SessionOrchestrator(
|
|||||||
toolName = toolCall.function.name,
|
toolName = toolCall.function.name,
|
||||||
parameters = parameters,
|
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(
|
emit(
|
||||||
sessionId,
|
sessionId,
|
||||||
ToolInvocationRequestedEvent(
|
ToolInvocationRequestedEvent(
|
||||||
@@ -378,7 +381,7 @@ abstract class SessionOrchestrator(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
||||||
sessionId, stageId, invocationId, toolCall.function.name, request,
|
sessionId, stageId, invocationId, toolCall.function.name, request, tool,
|
||||||
)?.let { assessment ->
|
)?.let { assessment ->
|
||||||
if (assessment.recommendedAction == RiskAction.BLOCK) {
|
if (assessment.recommendedAction == RiskAction.BLOCK) {
|
||||||
emit(
|
emit(
|
||||||
@@ -571,7 +574,9 @@ abstract class SessionOrchestrator(
|
|||||||
role = EntryRole.ASSISTANT,
|
role = EntryRole.ASSISTANT,
|
||||||
)
|
)
|
||||||
val resultContent = when (result) {
|
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 -> {
|
is ToolResult.Failure -> {
|
||||||
if (!result.recoverable) "FATAL: ${result.reason}"
|
if (!result.recoverable) "FATAL: ${result.reason}"
|
||||||
else "ERROR: ${result.reason}"
|
else "ERROR: ${result.reason}"
|
||||||
@@ -596,10 +601,10 @@ abstract class SessionOrchestrator(
|
|||||||
invocationId: ToolInvocationId,
|
invocationId: ToolInvocationId,
|
||||||
toolName: String,
|
toolName: String,
|
||||||
request: ToolRequest,
|
request: ToolRequest,
|
||||||
|
tool: Tool?,
|
||||||
): RiskSummary? {
|
): RiskSummary? {
|
||||||
val assessor = toolCallAssessor ?: return null
|
val assessor = toolCallAssessor ?: return null
|
||||||
val policy = workspacePolicy ?: return null
|
val policy = workspacePolicy ?: return null
|
||||||
val tool = toolRegistry?.resolve(toolName)
|
|
||||||
val assessment = assessor.assess(
|
val assessment = assessor.assess(
|
||||||
ToolCallAssessmentInput(
|
ToolCallAssessmentInput(
|
||||||
request = request,
|
request = request,
|
||||||
|
|||||||
@@ -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.approvals.Tier
|
||||||
import com.correx.core.events.events.ToolRequest
|
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
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
interface Tool {
|
interface Tool {
|
||||||
@@ -11,5 +13,12 @@ interface Tool {
|
|||||||
val tier: Tier
|
val tier: Tier
|
||||||
val requiredCapabilities: Set<ToolCapability>
|
val requiredCapabilities: Set<ToolCapability>
|
||||||
val paramRoles: Map<String, ParamRole> get() = emptyMap()
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -2,6 +2,11 @@ package com.correx.infrastructure.tools.filesystem
|
|||||||
|
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.events.events.ToolRequest
|
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.ParamRole
|
||||||
import com.correx.core.tools.contract.Tool
|
import com.correx.core.tools.contract.Tool
|
||||||
import com.correx.core.tools.contract.ToolCapability
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
@@ -49,6 +54,9 @@ class FileReadTool(
|
|||||||
override val tier: Tier = Tier.T1
|
override val tier: Tier = Tier.T1
|
||||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
|
||||||
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
|
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 {
|
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||||
val pathString = request.parameters["path"] as? String
|
val pathString = request.parameters["path"] as? String
|
||||||
|
|||||||
+9
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ToolRequest
|
|||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
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.ToolResult
|
||||||
import com.correx.core.tools.contract.ValidationResult
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -109,4 +110,12 @@ class FileReadToolTest {
|
|||||||
val failure = result as ToolResult.Failure
|
val failure = result as ToolResult.Failure
|
||||||
assertTrue(failure.reason.contains("is not in the allowed list"))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -2,6 +2,11 @@ package com.correx.infrastructure.tools.shell
|
|||||||
|
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.events.events.ToolRequest
|
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.ParamRole
|
||||||
import com.correx.core.tools.contract.Tool
|
import com.correx.core.tools.contract.Tool
|
||||||
import com.correx.core.tools.contract.ToolCapability
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
@@ -44,6 +49,9 @@ class ShellTool(
|
|||||||
override val requiredCapabilities: Set<ToolCapability> =
|
override val requiredCapabilities: Set<ToolCapability> =
|
||||||
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
|
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
|
||||||
override val paramRoles: Map<String, ParamRole> = mapOf("argv" to ParamRole.EXEC_COMMAND)
|
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 {
|
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||||
val argv = when (val raw = request.parameters["argv"]) {
|
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) {
|
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
|
||||||
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
||||||
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
|
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
|
||||||
|
|||||||
+22
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ToolRequest
|
|||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
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.ToolResult
|
||||||
import com.correx.core.tools.contract.ValidationResult
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -112,4 +113,25 @@ class ShellToolTest {
|
|||||||
assertEquals("Process timed out after 100ms", failure.reason)
|
assertEquals("Process timed out after 100ms", failure.reason)
|
||||||
assertFalse(failure.recoverable)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ import com.correx.core.toolintent.ToolCallAssessmentInput
|
|||||||
import com.correx.core.toolintent.ToolCallAssessor
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
import com.correx.core.toolintent.ToolCallRule
|
import com.correx.core.toolintent.ToolCallRule
|
||||||
import com.correx.core.toolintent.WorkspacePolicy
|
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.Tool
|
||||||
import com.correx.core.tools.contract.ToolCapability
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
import com.correx.core.tools.contract.ToolExecutor
|
import com.correx.core.tools.contract.ToolExecutor
|
||||||
@@ -89,6 +92,16 @@ class ToolCallGateTest {
|
|||||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
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 {
|
private inner class FakeToolRegistry(private val tool: Tool) : ToolRegistry {
|
||||||
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
|
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
|
||||||
override fun all(): List<Tool> = listOf(tool)
|
override fun all(): List<Tool> = listOf(tool)
|
||||||
@@ -106,9 +119,11 @@ class ToolCallGateTest {
|
|||||||
override val id = ProviderId("tool-caller")
|
override val id = ProviderId("tool-caller")
|
||||||
override val name = "tool-caller"
|
override val name = "tool-caller"
|
||||||
override val tokenizer: Tokenizer = MockTokenizer()
|
override val tokenizer: Tokenizer = MockTokenizer()
|
||||||
|
val requests = mutableListOf<InferenceRequest>()
|
||||||
private var callCount = 0
|
private var callCount = 0
|
||||||
|
|
||||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||||
|
requests += request
|
||||||
callCount++
|
callCount++
|
||||||
return if (callCount == 1) {
|
return if (callCount == 1) {
|
||||||
InferenceResponse(
|
InferenceResponse(
|
||||||
@@ -150,7 +165,7 @@ class ToolCallGateTest {
|
|||||||
executor: ToolExecutor,
|
executor: ToolExecutor,
|
||||||
tool: Tool,
|
tool: Tool,
|
||||||
assessorRule: ToolCallRule?,
|
assessorRule: ToolCallRule?,
|
||||||
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
|
): Triple<DefaultSessionOrchestrator, InMemoryEventStore, ToolCallingProvider> {
|
||||||
val eventStore = InMemoryEventStore()
|
val eventStore = InMemoryEventStore()
|
||||||
val artifactStore = NoopArtifactStore()
|
val artifactStore = NoopArtifactStore()
|
||||||
val toolRegistry = FakeToolRegistry(tool)
|
val toolRegistry = FakeToolRegistry(tool)
|
||||||
@@ -203,7 +218,7 @@ class ToolCallGateTest {
|
|||||||
artifactStore = artifactStore,
|
artifactStore = artifactStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Pair(orchestrator, eventStore)
|
return Triple(orchestrator, eventStore, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun singleStageGraph(allowedTools: Set<String> = setOf("file_write")): WorkflowGraph =
|
private fun singleStageGraph(allowedTools: Set<String> = setOf("file_write")): WorkflowGraph =
|
||||||
@@ -221,7 +236,7 @@ class ToolCallGateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `BLOCK rule prevents executor from being called and emits rejected event`(): Unit = runBlocking {
|
fun `BLOCK rule prevents executor from being called and emits rejected event`(): Unit = runBlocking {
|
||||||
val executor = RecordingExecutor()
|
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 sessionId = SessionId("gate-block")
|
||||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
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 {
|
fun `PROMPT_USER rule on T2 tool triggers approval with plane2 risk summary`(): Unit = runBlocking {
|
||||||
val executor = RecordingExecutor()
|
val executor = RecordingExecutor()
|
||||||
// Use T2 so the engine does not auto-approve (PROMPT mode auto-approves up to T1 only)
|
// 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),
|
executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER),
|
||||||
)
|
)
|
||||||
val sessionId = SessionId("gate-prompt")
|
val sessionId = SessionId("gate-prompt")
|
||||||
@@ -264,7 +279,7 @@ class ToolCallGateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `null assessor means executor is called normally (regression guard)`(): Unit = runBlocking {
|
fun `null assessor means executor is called normally (regression guard)`(): Unit = runBlocking {
|
||||||
val executor = RecordingExecutor()
|
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 sessionId = SessionId("gate-null")
|
||||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
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")
|
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")
|
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