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:
@@ -344,9 +344,12 @@ fun main() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val observationStore = InfrastructureModule.createObservationStore()
|
||||||
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
||||||
val wsRegistry = InfrastructureModule.createToolRegistry(
|
val wsRegistry = InfrastructureModule.createToolRegistry(
|
||||||
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
|
buildToolConfigForWorkspace(
|
||||||
|
workspace, shellAllowedExecutables, toolsConfig, researchToolConfig, observationStore,
|
||||||
|
),
|
||||||
extraTools = extraTools,
|
extraTools = extraTools,
|
||||||
)
|
)
|
||||||
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
||||||
@@ -390,7 +393,6 @@ fun main() {
|
|||||||
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
||||||
)
|
)
|
||||||
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
||||||
val observationStore = InfrastructureModule.createObservationStore()
|
|
||||||
val defaultOrchestrationConfig = OrchestrationConfig(
|
val defaultOrchestrationConfig = OrchestrationConfig(
|
||||||
sandboxRoot = sandboxRoot,
|
sandboxRoot = sandboxRoot,
|
||||||
defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath,
|
defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath,
|
||||||
@@ -963,6 +965,7 @@ private fun buildToolConfigForWorkspace(
|
|||||||
shellAllowedExecutables: Set<String>,
|
shellAllowedExecutables: Set<String>,
|
||||||
toolsConfig: com.correx.core.config.ToolsConfig,
|
toolsConfig: com.correx.core.config.ToolsConfig,
|
||||||
research: com.correx.infrastructure.tools.ResearchToolConfig,
|
research: com.correx.infrastructure.tools.ResearchToolConfig,
|
||||||
|
observationStore: com.correx.core.context.observation.ObservationStore? = null,
|
||||||
): ToolConfig = ToolConfig(
|
): ToolConfig = ToolConfig(
|
||||||
research = research,
|
research = research,
|
||||||
shell = ShellConfig(
|
shell = ShellConfig(
|
||||||
@@ -974,6 +977,8 @@ private fun buildToolConfigForWorkspace(
|
|||||||
enabled = toolsConfig.fileReadEnabled,
|
enabled = toolsConfig.fileReadEnabled,
|
||||||
allowedPaths = workspace.allowedPaths,
|
allowedPaths = workspace.allowedPaths,
|
||||||
workingDir = workspace.workingDir,
|
workingDir = workspace.workingDir,
|
||||||
|
observationStore = observationStore,
|
||||||
|
repoRoot = workspace.workingDir?.toString(),
|
||||||
),
|
),
|
||||||
fileWrite = FileWriteConfig(
|
fileWrite = FileWriteConfig(
|
||||||
enabled = toolsConfig.fileWriteEnabled,
|
enabled = toolsConfig.fileWriteEnabled,
|
||||||
|
|||||||
+51
@@ -4,8 +4,12 @@ import com.correx.core.context.model.ContextEntry
|
|||||||
import com.correx.core.context.model.ContextLayer
|
import com.correx.core.context.model.ContextLayer
|
||||||
import com.correx.core.context.model.EntryRole
|
import com.correx.core.context.model.EntryRole
|
||||||
import com.correx.core.events.events.ConceptPromotedEvent
|
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.ContextEntryId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.kernel.concept.ConceptCompilerProjection
|
import com.correx.core.kernel.concept.ConceptCompilerProjection
|
||||||
|
import com.correx.core.kernel.concept.conceptClassKey
|
||||||
import com.correx.core.transitions.graph.StageConfig
|
import com.correx.core.transitions.graph.StageConfig
|
||||||
import java.util.UUID
|
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<StoredEvent>,
|
||||||
|
stageId: StageId,
|
||||||
|
): List<ContextEntry> {
|
||||||
|
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
|
private const val MAX_PROMOTED_CONCEPTS = 3
|
||||||
|
|||||||
+4
-1
@@ -245,6 +245,9 @@ internal suspend fun SessionOrchestrator.executeStage(
|
|||||||
?.let { listOf(it) } ?: emptyList()
|
?.let { listOf(it) } ?: emptyList()
|
||||||
val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId)
|
val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId)
|
||||||
?.let { listOf(it) } ?: emptyList()
|
?.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
|
val vocabularyEntries = artifactKindRegistry
|
||||||
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
|
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
|
||||||
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
|
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
|
||||||
@@ -282,7 +285,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
|||||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||||
rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries +
|
rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries +
|
||||||
recoveryTicketEntries + remainingDeltaEntries,
|
recoveryTicketEntries + unconfirmedFixHints + remainingDeltaEntries,
|
||||||
)
|
)
|
||||||
val contextPack = runCatching {
|
val contextPack = runCatching {
|
||||||
contextPackBuilder.build(
|
contextPackBuilder.build(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies {
|
|||||||
implementation project(":infrastructure:tools:filesystem")
|
implementation project(":infrastructure:tools:filesystem")
|
||||||
implementation project(":core:artifacts")
|
implementation project(":core:artifacts")
|
||||||
implementation project(":core:artifacts-store")
|
implementation project(":core:artifacts-store")
|
||||||
|
implementation project(":core:context")
|
||||||
|
|
||||||
// Web research tools (web_search, web_fetch) + deterministic HTML→markdown extraction.
|
// Web research tools (web_search, web_fetch) + deterministic HTML→markdown extraction.
|
||||||
implementation 'org.jsoup:jsoup:1.20.1'
|
implementation 'org.jsoup:jsoup:1.20.1'
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ dependencies {
|
|||||||
implementation project(':core:approvals')
|
implementation project(':core:approvals')
|
||||||
implementation project(':core:artifacts')
|
implementation project(':core:artifacts')
|
||||||
implementation project(':core:artifacts-store')
|
implementation project(':core:artifacts-store')
|
||||||
|
implementation project(':core:context')
|
||||||
|
implementation project(':core:sourcedesc')
|
||||||
implementation 'org.slf4j:slf4j-api:2.0.16'
|
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-2
@@ -1,7 +1,10 @@
|
|||||||
package com.correx.infrastructure.tools.filesystem
|
package com.correx.infrastructure.tools.filesystem
|
||||||
|
|
||||||
import com.correx.core.approvals.Tier
|
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.events.events.ToolRequest
|
||||||
|
import com.correx.core.sourcedesc.describe
|
||||||
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
|
||||||
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
|
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
|
||||||
import com.correx.core.tools.compression.DeclarativeCompressor
|
import com.correx.core.tools.compression.DeclarativeCompressor
|
||||||
@@ -32,6 +35,12 @@ import kotlin.io.path.name
|
|||||||
class FileReadTool(
|
class FileReadTool(
|
||||||
private val allowedPaths: Set<Path> = emptySet(),
|
private val allowedPaths: Set<Path> = emptySet(),
|
||||||
private val workingDir: Path? = null,
|
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 {
|
) : Tool, ToolExecutor {
|
||||||
|
|
||||||
// Resolve relative paths against the bound workspace's working dir, NOT the JVM
|
// 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,
|
// 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).
|
// so a full read no longer hits the disk twice (readAllLines + readAllBytes for the hash).
|
||||||
val bytes = Files.readAllBytes(path)
|
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 —
|
// against what the agent actually saw. A partial or truncated view establishes no baseline —
|
||||||
// the agent must read the relevant range before editing.
|
// the agent must read the relevant range before editing.
|
||||||
val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped
|
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(
|
ToolResult.Success(
|
||||||
invocationId = request.invocationId,
|
invocationId = request.invocationId,
|
||||||
output = content + note,
|
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 =
|
private fun sha256(bytes: ByteArray): String =
|
||||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
|
java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||||
.joinToString("") { "%02x".format(it) }
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|||||||
+19
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.infrastructure.tools.filesystem
|
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.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
|
||||||
@@ -193,6 +194,24 @@ class FileReadToolTest {
|
|||||||
assertTrue((result as ToolResult.Success).metadata["contentHash"] != null)
|
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
|
@Test
|
||||||
fun `outputCompressor strips blank lines and leading whitespace`() {
|
fun `outputCompressor strips blank lines and leading whitespace`() {
|
||||||
val tool = FileReadTool()
|
val tool = FileReadTool()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.infrastructure.tools
|
package com.correx.infrastructure.tools
|
||||||
|
|
||||||
|
import com.correx.core.context.observation.ObservationStore
|
||||||
import com.correx.core.tools.contract.Tool
|
import com.correx.core.tools.contract.Tool
|
||||||
import com.correx.infrastructure.tools.filesystem.FileDeleteTool
|
import com.correx.infrastructure.tools.filesystem.FileDeleteTool
|
||||||
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
||||||
@@ -39,6 +40,9 @@ data class FileReadConfig(
|
|||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val allowedPaths: Set<Path> = emptySet(),
|
val allowedPaths: Set<Path> = emptySet(),
|
||||||
val workingDir: Path? = null,
|
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(
|
data class FileWriteConfig(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
@@ -75,6 +79,8 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
|||||||
FileReadTool(
|
FileReadTool(
|
||||||
allowedPaths = fileRead.allowedPaths,
|
allowedPaths = fileRead.allowedPaths,
|
||||||
workingDir = fileRead.workingDir,
|
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.
|
// list_dir is read-only enumeration; shares the reader's jail + anchor and the same toggle.
|
||||||
|
|||||||
Reference in New Issue
Block a user