feat(context): reasoning-thread continuity, L3 doc filtering, and gate hardening

Threads reasoning_content across inference calls and journal renders, filters
markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks,
and tightens filesystem tool output/dir-listing behavior surfaced by prior
live QA (see project_readloop_campaign memory).
This commit is contained in:
2026-07-10 11:13:43 +04:00
parent f51a8dada4
commit 3d5e05c1fb
32 changed files with 742 additions and 85 deletions
@@ -62,6 +62,7 @@ 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.InitialIntentEvent
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.InferenceFailedEvent
@@ -582,6 +583,11 @@ abstract class SessionOrchestrator(
var toolRounds = 0
var consecutiveReadOnlyRounds = 0
var consecutiveRejectedRounds = 0
// Fingerprints ("tool:arguments") of every read-only call made during the current
// no-write streak. A round only counts against the read-loop breaker if it repeats calls
// already seen — a stage reading N distinct files before writing is legitimate context
// gathering, not a loop, and shouldn't trip the same counter as re-reading the same file.
val seenReadFingerprints = mutableSetOf<String>()
// Set when the model produces its artifact via the emit_artifact tool instead of a final
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
var llmArtifactOverride: String? = null
@@ -697,7 +703,8 @@ abstract class SessionOrchestrator(
}
val toolEntries = dispatchToolCalls(sessionId, stageId,
inferenceResult.response.toolCalls, stageConfig, effectives,
approvalModeFor(session.state.boundProfile?.approvalMode))
approvalModeFor(session.state.boundProfile?.approvalMode),
inferenceResult.response.reasoning)
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
if (fatalEntry != null) {
emitProcessResultEvents(sessionId, stageId, stageConfig)
@@ -718,14 +725,23 @@ abstract class SessionOrchestrator(
if (owesFileWrite() &&
inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
) {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
val roundFingerprints = inferenceResult.response.toolCalls
.map { "${it.function.name}:${it.function.arguments}" }
val madeProgress = roundFingerprints.any { it !in seenReadFingerprints }
seenReadFingerprints += roundFingerprints
if (madeProgress) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
} else {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
}
}
} else {
consecutiveReadOnlyRounds = 0
seenReadFingerprints.clear()
}
// Rejection-loop breaker (stage-type agnostic): every tool result this round was a
// rejection (plane-2 BLOCKED or a stage/policy ERROR) and nothing succeeded. A stage that
@@ -1028,10 +1044,14 @@ abstract class SessionOrchestrator(
// On a recoverable tool failure, append the tool's argument schema to the error fed back to the
// model so it can self-correct its next attempt (it already sees its own malformed call in the
// assistant entry above) rather than repeating the same mistake. Kept compact to bound context.
// NOTE: the "corrected arguments" phrasing was removed because it actively drives loops: a model
// that called list_dir frontend/ and got "directory not found" interprets "re-issue with corrected
// arguments" as "retry the same call" rather than "try a different path" — the arguments were
// valid, the path simply doesn't exist. The schema reference stays so malformed calls can still
// self-correct; the model must infer the tactic from the tool's error text.
private fun toolArgsHint(tool: Tool?): String =
tool?.let {
"\nThe '${it.name}' tool requires arguments matching this JSON schema — re-issue the " +
"call with corrected arguments: ${it.parametersSchema}"
"\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
}.orEmpty()
private suspend fun dispatchToolCalls(
@@ -1041,11 +1061,18 @@ abstract class SessionOrchestrator(
stageConfig: StageConfig,
effectives: RunEffectives,
approvalMode: ApprovalMode,
// The reasoning that preceded this whole batch of tool calls (they all came from one
// inference response). Attached only to the first call's assistant entry — one thought
// block per model turn, matching how the response actually arrived.
reasoning: String? = null,
): List<ContextEntry> {
val executor = effectives.executor ?: return emptyList()
val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
return toolCalls.flatMap { toolCall ->
return toolCalls.withIndex().flatMap { (toolCallIndex, toolCall) ->
// Only the first call's assistant entry carries the reasoning — the whole batch came
// from one model turn/one thought block, so later calls in the same batch get none.
val toolCallReasoning = reasoning.takeIf { toolCallIndex == 0 }
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
val parameters = parseToolArguments(toolCall.function.arguments)
val request = ToolRequest(
@@ -1099,6 +1126,7 @@ abstract class SessionOrchestrator(
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -1155,6 +1183,7 @@ abstract class SessionOrchestrator(
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -1223,6 +1252,7 @@ abstract class SessionOrchestrator(
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -1282,6 +1312,7 @@ abstract class SessionOrchestrator(
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -1353,6 +1384,7 @@ abstract class SessionOrchestrator(
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
)
val resultContent = when (result) {
is ToolResult.Success ->
@@ -1702,13 +1734,47 @@ abstract class SessionOrchestrator(
return closestPathByFilename(guessed, map.entries.map { it.path })
}
/**
* Resolves the actual prompt text a stage will see, for use as the repo-knowledge query.
* `metadata["prompt"]` holds a *path* to the prompt file (resolved into context separately,
* around line 431) — using that path string itself as the embedding query means the query the
* retriever compares against is a filesystem path, not the stage's real instructions, so
* similarity scores never reflect genuine relevance (2026-07-08 finding: no file scored a
* clear match, everything clustered in the embedder's baseline noise band for short strings).
*/
private fun resolveStagePromptText(stageId: StageId, stageConfig: StageConfig): String {
stageConfig.metadata["promptInline"]?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
stageConfig.metadata["prompt"]?.let { path ->
runCatching { promptResolver.resolve(path) }.getOrNull()
?.trim()?.takeIf { it.isNotBlank() }
?.let { return it }
}
return stageId.value
}
/**
* The role's own prompt text (see [resolveStagePromptText]) is identical across every session
* that runs the stage — it carries zero session-specific signal, so similarity against it can
* only ever reflect generic lexical overlap with the role's boilerplate ("file_read", "ls",
* "grep"...), never what the user actually asked for. The one thing that IS session-specific
* is the initial user intent, so it's prepended here as the real anchor for the query (2026-07-08
* finding: without it, the query matched a tool script's own symbol names over the files the
* user's request actually named).
*/
private fun repoKnowledgeQuery(sessionId: SessionId, stageId: StageId, stageConfig: StageConfig): String {
val intent = eventStore.read(sessionId)
.mapNotNull { it.payload as? InitialIntentEvent }
.firstOrNull()?.intent?.trim()
val stagePrompt = resolveStagePromptText(stageId, stageConfig)
return if (intent.isNullOrBlank()) stagePrompt else "$intent\n\n$stagePrompt"
}
private suspend fun buildContextualRepoEntries(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): List<ContextEntry> {
val stagePrompt = (stageConfig.metadata["promptInline"] ?: stageConfig.metadata["prompt"] ?: stageId.value)
.trim().ifBlank { stageId.value }
val stagePrompt = repoKnowledgeQuery(sessionId, stageId, stageConfig)
val recorded = eventStore.read(sessionId)
.mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
.lastOrNull { it.stageId == stageId }