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:
+76
-10
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user