diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 8f433214..ca648ca8 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -344,9 +344,12 @@ fun main() { ), ) + val observationStore = InfrastructureModule.createObservationStore() val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace -> val wsRegistry = InfrastructureModule.createToolRegistry( - buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig), + buildToolConfigForWorkspace( + workspace, shellAllowedExecutables, toolsConfig, researchToolConfig, observationStore, + ), extraTools = extraTools, ) val wsExecutor = DispatchingToolExecutor(wsRegistry) @@ -390,7 +393,6 @@ fun main() { semanticReviewer = SemanticReviewerImpl(inferenceRouter), ) val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore) - val observationStore = InfrastructureModule.createObservationStore() val defaultOrchestrationConfig = OrchestrationConfig( sandboxRoot = sandboxRoot, defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath, @@ -963,6 +965,7 @@ private fun buildToolConfigForWorkspace( shellAllowedExecutables: Set, toolsConfig: com.correx.core.config.ToolsConfig, research: com.correx.infrastructure.tools.ResearchToolConfig, + observationStore: com.correx.core.context.observation.ObservationStore? = null, ): ToolConfig = ToolConfig( research = research, shell = ShellConfig( @@ -974,6 +977,8 @@ private fun buildToolConfigForWorkspace( enabled = toolsConfig.fileReadEnabled, allowedPaths = workspace.allowedPaths, workingDir = workspace.workingDir, + observationStore = observationStore, + repoRoot = workspace.workingDir?.toString(), ), fileWrite = FileWriteConfig( enabled = toolsConfig.fileWriteEnabled, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt index 090a45d9..88c2ae3e 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorConcepts.kt @@ -4,8 +4,12 @@ import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.EntryRole import com.correx.core.events.events.ConceptPromotedEvent +import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ContextEntryId +import com.correx.core.events.types.StageId import com.correx.core.kernel.concept.ConceptCompilerProjection +import com.correx.core.kernel.concept.conceptClassKey import com.correx.core.transitions.graph.StageConfig import java.util.UUID @@ -66,4 +70,51 @@ internal suspend fun SessionOrchestrator.promotedConceptEntries(stageConfig: Sta } } +/** + * Store 2 fix-confidence lifecycle (docs/plans/2026-07-21-acr-knowledge-accretion.md §Store 2): a + * classKey below the hard-promotion threshold isn't silence — [ConceptCompilerProjection] already + * tracks it as `unconfirmed` (validatedFixes 1..threshold-1) or `falsified` (contradicted). Reactively + * matched against the CURRENT retry's own classKey (same normalization the compiler uses) and + * delivered as a soft, one-shot hint (or steer-away) BEFORE the cluster earns hard promotion. Once + * `classKey` is in [state.promoted][com.correx.core.kernel.concept.ConceptCompilerState.promoted] the + * hard-promoted delivery ([promotedConceptEntries]) already covers it, so this is skipped to avoid + * double delivery. + */ +internal suspend fun SessionOrchestrator.unconfirmedFixEntries( + sessionEvents: List, + stageId: StageId, +): List { + val latest = sessionEvents.mapNotNull { it.payload as? RetryAttemptedEvent }.lastOrNull { it.stageId == stageId } + val sig = latest?.failureReason?.lineSequence()?.firstOrNull()?.take(SIGNATURE_MAX)?.trim().orEmpty() + val classKey = latest?.let { conceptClassKey(it.gate, sig) } + val projection = ConceptCompilerProjection() + val state = eventStore.allEvents().fold(projection.initial(), projection::apply) + val cluster = classKey?.takeIf { it !in state.promoted }?.let { state.clusters[it] } + val content = when { + cluster == null -> null + cluster.contradicted -> + "## Steer away from a known dead end\nA prior attempt at this exact failure class " + + "(${cluster.signature}) was tried before and a later failure showed it did NOT hold. " + + "Do not repeat that approach — find a materially different fix." + cluster.validatedFixes >= 1 -> + "## Unconfirmed prior fix (validated ${cluster.validatedFixes}x, not yet settled)\n" + + "This failure class (${cluster.signature}) was resolved before" + + (cluster.fixPath?.let { " in `$it`" } ?: "") + " — worth trying first, but it hasn't " + + "recurred enough times across sessions to be a certain fix here. Verify it actually applies." + else -> null + } ?: return emptyList() + return listOf( + ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L1, + content = content, + sourceType = "unconfirmedFix", + sourceId = classKey.orEmpty(), + tokenEstimate = estimateTokens(content), + role = EntryRole.USER, + ), + ) +} + +private const val SIGNATURE_MAX = 200 private const val MAX_PROMOTED_CONCEPTS = 3 diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index 94af0b58..796086f9 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -245,6 +245,9 @@ internal suspend fun SessionOrchestrator.executeStage( ?.let { listOf(it) } ?: emptyList() val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId) ?.let { listOf(it) } ?: emptyList() + // Store 2 soft-confidence lifecycle (below hard-promotion threshold): reactive hint/steer-away + // matched to THIS retry's own classKey, see unconfirmedFixEntries. + val unconfirmedFixHints = unconfirmedFixEntries(sessionEvents, stageId) val vocabularyEntries = artifactKindRegistry ?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" } ?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList() @@ -282,7 +285,7 @@ internal suspend fun SessionOrchestrator.executeStage( journalEntries + repoMapEntries + claimedTaskEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries + - recoveryTicketEntries + remainingDeltaEntries, + recoveryTicketEntries + unconfirmedFixHints + remainingDeltaEntries, ) val contextPack = runCatching { contextPackBuilder.build( diff --git a/infrastructure/tools/build.gradle b/infrastructure/tools/build.gradle index c9dc23c3..55540369 100644 --- a/infrastructure/tools/build.gradle +++ b/infrastructure/tools/build.gradle @@ -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' diff --git a/infrastructure/tools/filesystem/build.gradle b/infrastructure/tools/filesystem/build.gradle index 5190029e..8a04f7da 100644 --- a/infrastructure/tools/filesystem/build.gradle +++ b/infrastructure/tools/filesystem/build.gradle @@ -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' } 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 9c5c6277..9aa4f2e1 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 @@ -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 = 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) } 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 1110784e..e5ef6dd6 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 @@ -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() diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt index d1a53945..313920ed 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt @@ -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 = 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 = 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.