fix(context): render the stage role prompt as the system prompt (#416)
The role prompt was an L1/USER entry, so PromptRenderer emitted it as a user turn behind the intent, decision journal, repo map and docs catalog, outranked by the pinned schemaInstruction it contradicts. It is now L0/SYSTEM and folds into the leading system message, and it sits directly after systemPrompt in assembly so it heads the system block rather than trailing schemaEntries. Extracted buildAgentPromptEntry so both the promptInline and prompt-path branches build the entry one way. Guard test mutation-verified. #416's finding 1 was wrong: the role prompt was never evictable. "agentPrompt" is already in REQUIRED_SOURCE_TYPES, and DefaultContextPackBuilder exempts REQUIRED entries from pruning at any layer. Layer was never the pinning mechanism here; message placement was the whole defect. Also pins groundingFeedback and recoveryTicket, the two feedback types that were neither REQUIRED nor in neverDropSourceTypes. The recovery stage exists only because of its ticket, so pruning the ticket left it nothing to repair. Not done: reconciling the pinned schemaInstruction ("respond with JSON only") against the role prompt's emit_artifact instruction, and live verification across analyst/architect/role_pipeline.toml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -285,6 +285,28 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage's role prompt — `prompts/<role>.md` or an inline `promptInline` — as its system prompt.
|
||||
*
|
||||
* #416: L0/SYSTEM, so PromptRenderer folds it into the leading system message rather than rendering it
|
||||
* as a user turn arriving behind the intent, decision journal, repo map and docs catalog, outranked by
|
||||
* the pinned `schemaInstruction` it contradicts. The renderer reserves the system block for content
|
||||
* that does not change during a run — "prompts, guidance, profiles" — which is exactly this.
|
||||
*
|
||||
* Layer choice is not what pins it: `agentPrompt` is in REQUIRED_SOURCE_TYPES, so it was already exempt
|
||||
* from pruning at L1 too.
|
||||
*/
|
||||
fun buildAgentPromptEntry(text: String, stageId: StageId, tokenEstimate: Int): ContextEntry =
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = text,
|
||||
sourceType = "agentPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = tokenEstimate,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
|
||||
// CLAUDE.md / AGENTS.md injected as L0 standing context (feat/backlog-burndown).
|
||||
fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry {
|
||||
val content = instructions.content
|
||||
|
||||
+7
@@ -151,6 +151,13 @@ internal val REQUIRED_SOURCE_TYPES = setOf(
|
||||
// #290: original intent stays unprunable via the REQUIRED bucket now that it renders as
|
||||
// L1/USER instead of relying on the old L0/SYSTEM never-drop placement.
|
||||
"initialIntent",
|
||||
// The recovery stage is entered by kernel routing and exists ONLY because of its ticket, so
|
||||
// pruning the ticket leaves it with nothing to repair. Grounding findings are the same shape:
|
||||
// the architect was handed its plan back, and without them it re-emits the identical plan.
|
||||
// Both are single latest-state entries (lastOrNull), so pinning adds two entries, not two per
|
||||
// retry, and both clear themselves — groundingFeedback on a PASS verdict, the ticket on close.
|
||||
"groundingFeedback",
|
||||
"recoveryTicket",
|
||||
)
|
||||
|
||||
// HTTP statuses that are transient despite being 4xx (F-002 retry classification).
|
||||
|
||||
+17
-26
@@ -105,6 +105,13 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
// Known-good workspace invariant (design 2026-07-15 seam 2): tell the stage whether the last
|
||||
// green build is still valid for the current workspace state, or stale and needing re-verification.
|
||||
val verifiedBaseline = verifiedBaselineEntries(sessionId)
|
||||
// #416: the stage role prompt IS the stage's system prompt, so it renders L0/SYSTEM and folds into
|
||||
// the leading system message. It used to be L1/USER, which made it a user turn arriving behind the
|
||||
// intent, decision journal, repo map and docs catalog — outranked by the schemaInstruction that
|
||||
// contradicts it, while PromptRenderer's own rule reserves the system block for exactly this kind of
|
||||
// content ("prompts, guidance, profiles" — what does not change during a run). Pinning is unchanged:
|
||||
// agentPrompt is in REQUIRED_SOURCE_TYPES, so it was never prunable at either layer.
|
||||
//
|
||||
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
|
||||
// SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
|
||||
// recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
|
||||
@@ -115,19 +122,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
} else {
|
||||
stageConfig.metadata["promptInline"]
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = text,
|
||||
sourceType = "agentPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(text),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
)
|
||||
}
|
||||
?.let { text -> listOf(buildAgentPromptEntry(text, stageId, estimateTokens(text))) }
|
||||
?: stageConfig.metadata["prompt"]
|
||||
?.let { path ->
|
||||
val resolvedText = runCatching { promptResolver.resolve(path) }
|
||||
@@ -143,17 +138,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
"[SessionOrchestrator] stage=${stageId.value}: " +
|
||||
"declared prompt '$path' could not be resolved",
|
||||
)
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = text,
|
||||
sourceType = "agentPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(text),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
)
|
||||
listOf(buildAgentPromptEntry(text, stageId, estimateTokens(text)))
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
@@ -280,11 +265,17 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
val remainingDeltaEntries = remainingDeltaResults
|
||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
// #416: promptEntries sits directly after systemPrompt. System entries render in (layer, ordinal)
|
||||
// order and the builder stamps ordinals by position, so this puts the role mandate at the head of
|
||||
// the system block — adjacent to the generic preamble it extends, and ahead of the schema
|
||||
// instruction. It used to trail schemaEntries, which is how a pinned "respond with JSON only"
|
||||
// ended up outranking the role's own emit_artifact instruction.
|
||||
var accumulatedEntries = stampBuckets(
|
||||
systemPrompt + operatingGuidance + promotedConcepts + successfulPlanShapes + verifiedBaseline +
|
||||
systemPrompt + promptEntries + operatingGuidance + promotedConcepts + successfulPlanShapes +
|
||||
verifiedBaseline +
|
||||
intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + steeringEntries +
|
||||
rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries +
|
||||
recoveryTicketEntries + unconfirmedFixHints + remainingDeltaEntries,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.events.events.RepoKnowledgeHit
|
||||
import com.correx.core.kernel.orchestration.buildAgentInstructionsEntry
|
||||
import com.correx.core.kernel.orchestration.buildAgentPromptEntry
|
||||
import com.correx.core.kernel.orchestration.buildArtifactKindVocabularyEntry
|
||||
import com.correx.core.kernel.orchestration.buildProjectProfileEntry
|
||||
import com.correx.core.kernel.orchestration.buildRelevantFilesEntry
|
||||
@@ -194,6 +195,19 @@ class ContextFeedbackTest {
|
||||
assertEquals("projectProfile", entry.sourceType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stage role prompt renders as the system prompt, not a user turn`() {
|
||||
// #416: as L1/USER this arrived as a user message behind the intent, journal, repo map and docs
|
||||
// catalog, outranked by the pinned schemaInstruction that contradicts it. Live session fced377e:
|
||||
// discovery never acknowledged its role and drifted into implementation.
|
||||
val entry = buildAgentPromptEntry("You discover. You do not implement.", StageId("discovery"), 7)
|
||||
assertEquals(ContextLayer.L0, entry.layer)
|
||||
assertEquals(EntryRole.SYSTEM, entry.role)
|
||||
assertEquals("agentPrompt", entry.sourceType)
|
||||
assertEquals("discovery", entry.sourceId)
|
||||
assertEquals("You discover. You do not implement.", entry.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agent instructions render as single L0 entry`() {
|
||||
val entry = buildAgentInstructionsEntry(
|
||||
|
||||
Reference in New Issue
Block a user