fix(kernel): deterministic repo-map floor + 0.0-hit filter + closest-path suggestion
RepoMapComputed recorded per session (in-memory scan cache keeps the walk-once guarantee); zero-score embedder hits filtered before reaching the agent, falling back to the deterministic repo map; REFERENCE_EXISTS surfaces a closest-path hint via closestPathByFilename. Breaks the hallucinated-package-path read loop even with a noop embedder.
This commit is contained in:
+71
-8
@@ -48,6 +48,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeHit
|
||||
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
@@ -819,6 +820,18 @@ abstract class SessionOrchestrator(
|
||||
effectiveManifest,
|
||||
)?.let { assessment ->
|
||||
if (assessment.recommendedAction == RiskAction.BLOCK) {
|
||||
val rationale = assessment.rationale.joinToString("; ")
|
||||
// On a bad-path block, point the model at the closest real file so it fixes the
|
||||
// path instead of retrying the same wrong guess (deep package paths are easy to
|
||||
// misremember). Only fires when we can name a concrete match from the repo map.
|
||||
val suggestion = if (assessment.rationale.any { it.contains(REFERENCE_EXISTS_CODE) }) {
|
||||
(parameters["path"] as? String)
|
||||
?.let { suggestClosestPath(sessionId, it) }
|
||||
?.let { " Did you mean: $it ?" }
|
||||
.orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
emit(
|
||||
sessionId,
|
||||
ToolExecutionRejectedEvent(
|
||||
@@ -828,8 +841,7 @@ abstract class SessionOrchestrator(
|
||||
tier = tier,
|
||||
// Record the specific rule rationale, not a generic string, so the audit
|
||||
// trail (and task history) shows why a call was blocked.
|
||||
reason = assessment.rationale.joinToString("; ")
|
||||
.ifBlank { "blocked by tool-call policy" },
|
||||
reason = rationale.ifBlank { "blocked by tool-call policy" },
|
||||
),
|
||||
)
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
@@ -848,8 +860,8 @@ abstract class SessionOrchestrator(
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = sourceId,
|
||||
content = "BLOCKED: ${assessment.rationale.joinToString("; ")}",
|
||||
tokenEstimate = estimateTokens(assessment.rationale.joinToString("; ")),
|
||||
content = "BLOCKED: $rationale$suggestion",
|
||||
tokenEstimate = estimateTokens(rationale + suggestion),
|
||||
role = EntryRole.TOOL,
|
||||
),
|
||||
)
|
||||
@@ -1366,6 +1378,19 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best real path in the repo map that shares [guessed]'s filename, ranked by shared trailing
|
||||
* path segments — so a wrong package prefix (e.g. `com/correx/server/...` for the real
|
||||
* `com/correx/apps/server/...`) still resolves. Null when nothing shares the filename, so we
|
||||
* only ever suggest a concrete, existing file. Reads the recorded map (replay-safe).
|
||||
*/
|
||||
private fun suggestClosestPath(sessionId: SessionId, guessed: String): String? {
|
||||
val map = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? RepoMapComputedEvent }
|
||||
.lastOrNull() ?: return null
|
||||
return closestPathByFilename(guessed, map.entries.map { it.path })
|
||||
}
|
||||
|
||||
private suspend fun buildContextualRepoEntries(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
@@ -1374,7 +1399,7 @@ abstract class SessionOrchestrator(
|
||||
val recorded = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
|
||||
.lastOrNull { it.stageId == stageId }
|
||||
if (recorded != null) return listOf(buildRelevantFilesEntry(recorded.hits))
|
||||
if (recorded != null) return repoEntriesOrMapFloor(sessionId, recorded.hits)
|
||||
|
||||
val retriever = repoKnowledgeRetriever ?: return buildRepoMapEntries(sessionId)
|
||||
val query = (stageConfig.metadata["promptInline"] ?: stageConfig.metadata["prompt"] ?: stageId.value)
|
||||
@@ -1385,9 +1410,23 @@ abstract class SessionOrchestrator(
|
||||
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
|
||||
emptyList()
|
||||
}
|
||||
if (hits.isEmpty()) return buildRepoMapEntries(sessionId)
|
||||
emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, query, hits))
|
||||
return listOf(buildRelevantFilesEntry(hits))
|
||||
// Record the raw retrieval for audit (a degenerate all-zero result is itself the signal that
|
||||
// the embedder is noop / the index is empty), but only useful hits ever reach the agent.
|
||||
if (hits.isNotEmpty()) emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, query, hits))
|
||||
return repoEntriesOrMapFloor(sessionId, hits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
|
||||
* them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
|
||||
* useful survives, fall back to the deterministic repo map so the stage still has real grounding.
|
||||
*/
|
||||
private suspend fun repoEntriesOrMapFloor(
|
||||
sessionId: SessionId,
|
||||
hits: List<RepoKnowledgeHit>,
|
||||
): List<ContextEntry> {
|
||||
val useful = hits.filter { it.score > 0f }
|
||||
return if (useful.isEmpty()) buildRepoMapEntries(sessionId) else listOf(buildRelevantFilesEntry(useful))
|
||||
}
|
||||
|
||||
private suspend fun buildNeedsArtifactEntries(
|
||||
@@ -2425,6 +2464,30 @@ private fun buildDiffString(path: String, existingContent: String?, proposedCont
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate in [paths] that shares [guessed]'s filename and the most trailing path segments,
|
||||
* or null when nothing shares the filename. Lets a wrong package/dir prefix
|
||||
* (`com/correx/server/routes/SessionRoutes.kt`) still resolve to the real file
|
||||
* (`.../com/correx/apps/server/routes/SessionRoutes.kt`) without ever inventing a non-existent path.
|
||||
*/
|
||||
internal fun closestPathByFilename(guessed: String, paths: List<String>): String? {
|
||||
val guessedName = guessed.substringAfterLast('/')
|
||||
if (guessedName.isBlank()) return null
|
||||
val guessedSegs = guessed.split('/').filter { it.isNotBlank() }
|
||||
return paths.asSequence()
|
||||
.filter { it.substringAfterLast('/') == guessedName }
|
||||
.maxByOrNull { candidate ->
|
||||
val segs = candidate.split('/').filter { it.isNotBlank() }
|
||||
var shared = 0
|
||||
var i = segs.size - 1
|
||||
var j = guessedSegs.size - 1
|
||||
while (i >= 0 && j >= 0 && segs[i] == guessedSegs[j]) {
|
||||
shared++; i--; j--
|
||||
}
|
||||
shared
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface InferenceResult {
|
||||
data class Success(val response: InferenceResponse, val responseArtifactId: ArtifactId? = null) : InferenceResult
|
||||
data class Failed(val reason: String) : InferenceResult
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ClosestPathByFilenameTest {
|
||||
|
||||
private val repo = listOf(
|
||||
"apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt",
|
||||
"apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt",
|
||||
"core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a wrong package prefix resolves to the real file by shared trailing segments`() {
|
||||
// The model dropped the `apps` segment: com/correx/server/routes vs com/correx/apps/server/routes.
|
||||
val guess = "apps/server/src/main/kotlin/com/correx/server/routes/SessionRoutes.kt"
|
||||
assertEquals(
|
||||
"apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt",
|
||||
closestPathByFilename(guess, repo),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare filename resolves to its only owner`() {
|
||||
assertEquals(
|
||||
"apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt",
|
||||
closestPathByFilename("SessionStreamHandler.kt", repo),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no filename match yields null rather than a wild guess`() {
|
||||
assertNull(closestPathByFilename("com/correx/apps/server/routes/GhostRoutes.kt", repo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a directory path (no filename) yields null`() {
|
||||
assertNull(closestPathByFilename("apps/server/src/main/kotlin/com/correx/server/routes/", repo))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user