feat(tools): bound+frame tool results, spill full output to CAS, tool_output retrieval
Tool results injected into model context are now consistently framed and globally bounded. Success output over the floor (TOOL_RESULT_MAX_CHARS=8000) is head/tail-truncated with a marker naming a retrieval ref; the full raw output spills to the artifact store (CAS) and its hash is recorded on the ToolReceipt (fullOutputHash) in the event log. Agents recover the full text via the new read-only tool_output(ref=...) tool. - SessionOrchestrator: frameTruncatedToolResult + renderToolResult; char-cap head/tail so a single pathological long line can't defeat the bound. - ToolReceipt.fullOutputHash (additive, nullable). - ToolOutputTool (Tier T1, no fs capability) resolves ref -> full bytes. - Wired via extraTools in Main.kt; tool_output added to ALWAYS_AVAILABLE_READ_TOOLS. - Failure path keeps ERROR:/FATAL: prefixes (all-rejected breaker dependency). Tests: FrameTruncatedToolResultTest, ToolOutputToolTest.
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
/**
|
||||
* Retrieves the full text of an earlier tool result that was truncated for model context. When a
|
||||
* Success output exceeds the context floor, the kernel spills the full raw output to the artifact
|
||||
* store and shows a marker containing its `ref` (the content hash, also recorded on the
|
||||
* ToolExecutionCompleted receipt in the event log). This tool resolves that `ref` back to the full
|
||||
* content. Read-only (Tier T1, no filesystem capability — it reads the content-addressed store by a
|
||||
* hash the model was explicitly handed, never arbitrary paths).
|
||||
*/
|
||||
class ToolOutputTool(private val artifactStore: ArtifactStore) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "tool_output"
|
||||
override val description: String =
|
||||
"Retrieve the FULL output of an earlier tool call that was truncated. Pass the ref shown in " +
|
||||
"the truncation marker (e.g. tool_output(ref=\"<hash>\"))."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("ref") {
|
||||
put("type", "string")
|
||||
put("description", "The ref/hash from a '… output truncated …' marker.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("ref")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult =
|
||||
if ((request.parameters["ref"] as? String).isNullOrBlank()) {
|
||||
ValidationResult.Invalid("tool_output requires a non-empty 'ref'")
|
||||
} else {
|
||||
ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val ref = (request.parameters["ref"] as? String)?.takeIf { it.isNotBlank() }
|
||||
?: return ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"tool_output requires a non-empty 'ref'",
|
||||
recoverable = true,
|
||||
)
|
||||
return artifactStore.get(ArtifactId(ref))
|
||||
?.let { ToolResult.Success(request.invocationId, output = it.toString(Charsets.UTF_8)) }
|
||||
?: ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"No stored output for ref '$ref' — it may have expired or the ref is wrong.",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
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.contract.ToolResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.*
|
||||
|
||||
class ToolOutputToolTest {
|
||||
|
||||
private class FakeStore(private val entries: Map<String, ByteArray>) : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = error("unused")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = entries[id.value]
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
private fun request(ref: String?) = ToolRequest(
|
||||
invocationId = ToolInvocationId(UUID.randomUUID().toString()),
|
||||
sessionId = SessionId(UUID.randomUUID().toString()),
|
||||
stageId = StageId(UUID.randomUUID().toString()),
|
||||
toolName = "tool_output",
|
||||
parameters = ref?.let { mapOf("ref" to it) } ?: emptyMap(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `returns the full stored output for a known ref`(): Unit = runBlocking {
|
||||
val tool = ToolOutputTool(FakeStore(mapOf("hash1" to "the full output".toByteArray())))
|
||||
val out = (tool.execute(request("hash1")) as ToolResult.Success).output
|
||||
assertEquals("the full output", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown ref fails recoverably`(): Unit = runBlocking {
|
||||
val tool = ToolOutputTool(FakeStore(emptyMap()))
|
||||
val result = tool.execute(request("missing"))
|
||||
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing ref fails recoverably`(): Unit = runBlocking {
|
||||
val tool = ToolOutputTool(FakeStore(emptyMap()))
|
||||
val result = tool.execute(request(null))
|
||||
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user