feat(acr): Store 2 soft-confidence hints + Store 1 FileReadTool priming (#305)

Store 2 (Fixes): ConceptCompilerProjection already tracked the
unconfirmed(validatedFixes>=1)/falsified(contradicted)/confirmed(promoted)
lifecycle as data, but only confirmed (>=threshold) clusters were ever
delivered. unconfirmedFixEntries reactively matches the CURRENT retry's
classKey against that state and injects a soft one-shot hint (below
threshold) or steer-away (contradicted) before hard promotion — wired
into stage context build in SessionOrchestratorExecution.kt.

Store 3 (Plan-shapes): already shipped as SessionOrchestratorPlanPatterns.kt
(deterministic keyword-Jaccard over eventStore.allEvents(), no embedder
needed) — confirmed complete, no new code required.

FileReadTool hot-path extension to Store 1: a whole-file read now primes
the cross-session ObservationStore (when wired) the same way the write
path already does, so a later describeCached lookup for the same content
hash is warm even if the file was only read, not written, this session.
This commit is contained in:
2026-07-21 17:55:19 +04:00
parent 5df35879eb
commit 8806de1628
8 changed files with 114 additions and 5 deletions
+1
View File
@@ -17,6 +17,7 @@ dependencies {
implementation project(":infrastructure:tools:filesystem")
implementation project(":core:artifacts")
implementation project(":core:artifacts-store")
implementation project(":core:context")
// Web research tools (web_search, web_fetch) + deterministic HTML→markdown extraction.
implementation 'org.jsoup:jsoup:1.20.1'
@@ -10,6 +10,8 @@ dependencies {
implementation project(':core:approvals')
implementation project(':core:artifacts')
implementation project(':core:artifacts-store')
implementation project(':core:context')
implementation project(':core:sourcedesc')
implementation 'org.slf4j:slf4j-api:2.0.16'
}
@@ -1,7 +1,10 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.context.observation.Observation
import com.correx.core.context.observation.ObservationStore
import com.correx.core.events.events.ToolRequest
import com.correx.core.sourcedesc.describe
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
import com.correx.core.tools.compression.DeclarativeCompressor
@@ -32,6 +35,12 @@ import kotlin.io.path.name
class FileReadTool(
private val allowedPaths: Set<Path> = emptySet(),
private val workingDir: Path? = null,
// ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md): primes the cross-session
// observation cache on a whole-file read, not just on write, so a later describeCached lookup
// (SessionOrchestratorArtifacts.kt) for this exact content hash is already warm. Both null by
// default — no-op unless a workspace wiring supplies them.
private val observationStore: ObservationStore? = null,
private val repoRoot: String? = null,
) : Tool, ToolExecutor {
// Resolve relative paths against the bound workspace's working dir, NOT the JVM
@@ -140,7 +149,7 @@ class FileReadTool(
}
}
private fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching {
private suspend fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching {
// Read the file once. Lines and the (whole-read) content hash both derive from these bytes,
// so a full read no longer hits the disk twice (readAllLines + readAllBytes for the hash).
val bytes = Files.readAllBytes(path)
@@ -169,7 +178,9 @@ class FileReadTool(
// against what the agent actually saw. A partial or truncated view establishes no baseline —
// the agent must read the relevant range before editing.
val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped
val metadata = if (sawWholeFile) mapOf("contentHash" to sha256(bytes)) else emptyMap()
val hash = if (sawWholeFile) sha256(bytes) else null
val metadata = hash?.let { mapOf("contentHash" to it) } ?: emptyMap()
hash?.let { primeObservation(path, bytes, it) }
ToolResult.Success(
invocationId = request.invocationId,
output = content + note,
@@ -183,6 +194,17 @@ class FileReadTool(
)
}
/** No-op unless both [observationStore] and [repoRoot] are wired; skips if already cached for this hash. */
private suspend fun primeObservation(path: Path, bytes: ByteArray, hash: String) {
val store = observationStore ?: return
val root = repoRoot ?: return
val relPath = workingDir?.let { runCatching { it.relativize(path).toString() }.getOrNull() } ?: path.toString()
val cached = store.get(root, relPath)
if (cached?.contentHash == hash) return
val rendered = describe(relPath, bytes).render().takeIf { it.isNotBlank() }
store.put(Observation(root, relPath, hash, rendered, System.currentTimeMillis()))
}
private fun sha256(bytes: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) }
@@ -1,5 +1,6 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.context.observation.InMemoryObservationStore
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
@@ -193,6 +194,24 @@ class FileReadToolTest {
assertTrue((result as ToolResult.Success).metadata["contentHash"] != null)
}
@Test
fun `execute primes the observation store for a whole-file read`(): Unit = runBlocking {
val tempFile = Files.createTempFile("test_prime", ".kt")
Files.writeString(tempFile, "fun foo() {}")
val store = InMemoryObservationStore()
val tool = FileReadTool(
allowedPaths = setOf(tempFile.parent),
workingDir = tempFile.parent,
observationStore = store,
repoRoot = "repoA",
)
tool.execute(createRequest(tempFile.toString()))
val observed = store.get("repoA", tempFile.fileName.toString())
assertTrue(observed != null)
}
@Test
fun `outputCompressor strips blank lines and leading whitespace`() {
val tool = FileReadTool()
@@ -1,5 +1,6 @@
package com.correx.infrastructure.tools
import com.correx.core.context.observation.ObservationStore
import com.correx.core.tools.contract.Tool
import com.correx.infrastructure.tools.filesystem.FileDeleteTool
import com.correx.infrastructure.tools.filesystem.FileEditTool
@@ -39,6 +40,9 @@ data class FileReadConfig(
val enabled: Boolean = false,
val allowedPaths: Set<Path> = emptySet(),
val workingDir: Path? = null,
// ACR Store 1 hot-path priming (docs/plans/2026-07-21-acr-knowledge-accretion.md); both null is a no-op.
val observationStore: ObservationStore? = null,
val repoRoot: String? = null,
)
data class FileWriteConfig(
val enabled: Boolean = false,
@@ -75,6 +79,8 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
FileReadTool(
allowedPaths = fileRead.allowedPaths,
workingDir = fileRead.workingDir,
observationStore = fileRead.observationStore,
repoRoot = fileRead.repoRoot,
),
)
// list_dir is read-only enumeration; shares the reader's jail + anchor and the same toggle.