fix(kernel,talkie,context): three instruction-corruption fixes from the 2026-08-26 context audit

Each of the three lost or rewrote an instruction before the model saw it.

1. Orphan corrective nudges. pushBack() and the final tools-disabled emission
   built their nudge as a toolResult with a fresh sourceId, so it had no matching
   assistantToolCall and reconcileToolPairs() deleted it — the orchestrator
   believed it had corrected the model while the correction never reached the
   prompt. Affected the invalid-emit_artifact, premature-stage_complete,
   missing-write, read-loop, rejection-loop and final-JSON nudges. They are now
   USER turns (sourceType orchestratorCorrection, REQUIRED bucket, STRUCTURED in
   ContextClassifier so pruning cannot shred them), appended last so the builder's
   positional ordinal puts them at the end of the transcript. A superseded nudge
   is dropped rather than stacking stale demands.

2. Steering laundered through the router. The SteeringNoteAddedEvent carried the
   router model's paraphrase of the operator's message, not the message —
   negations, filenames, constraints and priority could change before the
   orchestrator saw them. It now carries the raw input; the router turn is still
   produced and shown as conversational acknowledgement, it is just not the
   mandate. Resolves the ponytail: note at TalkieFacade.kt:220.

3. Journal compaction erased its own history. compactIfNeeded() summarized only
   state.records while the reducer overwrote summaryArtifactId and dropped covered
   records, so the second compaction lost everything the first had preserved — and
   a low-salience-only batch replaced it with the "(no high-salience decisions)"
   fallback. Compaction is cumulative now, and a blank or fallback-only rewrite
   never replaces real history.

Tests: rendered-prompt regression for (1) — verified to fail against the old
toolResult shape — updated steering expectations for (2), two cumulative-compaction
tests for (3). Full build green, 1831 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 01:33:16 +04:00
parent d892587420
commit 519290368f
7 changed files with 180 additions and 31 deletions
@@ -5,8 +5,10 @@ import com.correx.core.context.builder.RequiredContextOverflowException
import com.correx.core.context.model.ContextBucket
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.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId
import com.correx.core.inference.PromptRenderer
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
@@ -42,6 +44,55 @@ class DefaultContextPackBuilderTest {
tokenEstimate = tokens
)
@Test
fun `an orchestrator correction reaches the rendered prompt as the last user turn`() =
kotlinx.coroutines.runBlocking {
// The nudge used to be built as a toolResult with a fresh sourceId, so it had no
// matching assistantToolCall and reconcileToolPairs() deleted it: the orchestrator
// "corrected" the model and the correction never reached the provider. Assert on the
// RENDERED prompt, not the entry list — that is where the defect was invisible.
val entries = listOf(
typedEntry("sys", ContextLayer.L0, 100, "systemPrompt"),
typedEntry("task", ContextLayer.L1, 50, "agentPrompt"),
ContextEntry(
id = ContextEntryId("call1"),
layer = ContextLayer.L2,
content = "{\"tool\":\"file_read\"}",
sourceType = "assistantToolCall",
sourceId = "inv-1",
tokenEstimate = 40,
role = EntryRole.ASSISTANT,
),
ContextEntry(
id = ContextEntryId("res1"),
layer = ContextLayer.L2,
content = "file contents",
sourceType = "toolResult",
sourceId = "inv-1",
tokenEstimate = 40,
role = EntryRole.TOOL,
),
ContextEntry(
id = ContextEntryId("nudge"),
layer = ContextLayer.L2,
content = "STOP reading. You MUST now call file_write.",
sourceType = "orchestratorCorrection",
sourceId = "corr-1",
tokenEstimate = 20,
role = EntryRole.USER,
bucket = ContextBucket.REQUIRED,
),
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4096))
val messages = PromptRenderer.render(pack)
val last = messages.last()
assertEquals("user", last.role)
assertTrue(
last.content.contains("STOP reading"),
"correction must survive to the prompt; got: " + messages.map { it.role to it.content },
)
}
@Test
fun `oversized tool results from a stage tool loop are trimmed to budget`() = kotlinx.coroutines.runBlocking {
// Live repro (2026-06-11): analyst stage with three file_read results of ~5.6k/11.9k/10.7k
@@ -129,7 +129,8 @@ class TalkieFacadeTest {
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, chatEvents[0].role)
assertEquals("inference response", chatEvents[1].content)
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, chatEvents[1].role)
assertEquals("inference response", steeringEvents[0].content)
// The steering note is the operator's raw text, never the router's paraphrase of it.
assertEquals("steer this way", steeringEvents[0].content)
}
@Test
@@ -158,7 +159,7 @@ class TalkieFacadeTest {
val payloads = mockStore.appendedEvents.map { it.payload }
val steeringEvents = payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>()
assertEquals(1, steeringEvents.size)
assertEquals("steering response", steeringEvents[0].content)
assertEquals("Hello!", steeringEvents[0].content)
}
// --------------------------------------------------------------------------