diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt index eae644d0..2452038d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt @@ -6,6 +6,8 @@ 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.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.ClarificationAnswer import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationQuestions @@ -85,41 +87,63 @@ internal suspend fun SessionOrchestrator.buildSteeringNoteEntries(sessionId: Ses tokenEstimate = estimateTokens(p.content), role = EntryRole.SYSTEM, ) - is ApprovalDecisionResolvedEvent -> { - val steering = p.userSteering - when { - steering != null -> ContextEntry( - id = ContextEntryId(UUID.randomUUID().toString()), - layer = ContextLayer.L2, - content = steering.text, - sourceType = "steeringNote", - sourceId = steering.sessionId.value, - tokenEstimate = estimateTokens(steering.text), - role = EntryRole.USER, - ) - // A bare rejection (no note) still feeds back so the model does not - // re-propose the identical call on the retryable re-run (anti-loop). - p.outcome == ApprovalOutcome.REJECTED -> { - val feedback = "A previous tool call was rejected by the user. " + - "Do not repeat the same call; choose a different approach." - ContextEntry( - id = ContextEntryId(UUID.randomUUID().toString()), - layer = ContextLayer.L2, - content = feedback, - sourceType = "rejectionFeedback", - sourceId = sessionId.value, - tokenEstimate = estimateTokens(feedback), - role = EntryRole.SYSTEM, - ) - } - else -> null - } + // An operator steering note attached to a decision is a real instruction; keep it. + // Bare rejections are consolidated separately (buildRejectionFeedbackEntry) so they + // carry which call/why instead of a repeated context-free warning. + is ApprovalDecisionResolvedEvent -> p.userSteering?.let { steering -> + ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L2, + content = steering.text, + sourceType = "steeringNote", + sourceId = steering.sessionId.value, + tokenEstimate = estimateTokens(steering.text), + role = EntryRole.USER, + ) } else -> null } } } +/** + * One consolidated, stage-scoped entry naming the tool calls the operator declined in THIS stage — + * tool, args preview, tier, and the rejection reason — joined from each rejected + * [ApprovalDecisionResolvedEvent] back to its [ApprovalRequestedEvent] by requestId. Replaces the + * old per-rejection generic warnings ("a previous tool call was rejected"), which repeated without + * saying which call or why and read to the model as noise rather than a correction signal. Empty + * when the stage has no rejected calls. Replay-safe (recorded events only). + */ +fun buildRejectionFeedbackEntry(events: List, stageId: StageId): ContextEntry? { + val requests = events.mapNotNull { it.payload as? ApprovalRequestedEvent }.associateBy { it.requestId } + val rejections = events.mapNotNull { it.payload as? ApprovalDecisionResolvedEvent } + .filter { it.outcome == ApprovalOutcome.REJECTED && requests[it.requestId]?.stageId == stageId } + if (rejections.isEmpty()) return null + val content = buildString { + appendLine("## Rejected tool calls this stage") + appendLine( + "The operator declined the calls below. Do not re-propose them as-is — address the " + + "stated reason, or take a different approach:", + ) + rejections.forEach { d -> + val req = requests[d.requestId] + val preview = req?.preview?.takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty() + val reason = d.reason?.takeIf { it.isNotBlank() } + ?.let { " — reason: $it" } ?: " — no reason given" + appendLine("- ${req?.toolName ?: "tool call"} (tier ${d.tier})$preview$reason") + } + }.trimEnd() + return ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L2, + content = content, + sourceType = "rejectionFeedback", + sourceId = stageId.value, + tokenEstimate = content.length / 4, + role = EntryRole.SYSTEM, + ) +} + /** * Injects the initial user intent (the freeform request that started the run) as a pinned L0 * SYSTEM entry present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index 145100db..df43d62d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -229,6 +229,8 @@ internal suspend fun SessionOrchestrator.executeStage( val agentInstructionsEntries = emptyList() val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId) ?.let { listOf(it) } ?: emptyList() + val rejectionEntries = buildRejectionFeedbackEntry(sessionEvents, stageId) + ?.let { listOf(it) } ?: emptyList() val groundingFeedbackEntries = buildGroundingFeedbackEntry(sessionEvents, stageId) ?.let { listOf(it) } ?: emptyList() val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId) @@ -268,7 +270,7 @@ internal suspend fun SessionOrchestrator.executeStage( systemPrompt + operatingGuidance + promotedConcepts + successfulPlanShapes + verifiedBaseline + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries + journalEntries + repoMapEntries + claimedTaskEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + - clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries + recoveryTicketEntries + + rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries, ) val contextPack = runCatching { diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index dc56df4d..c7e5baad 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -10,8 +10,17 @@ import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.ToolInvocationId +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus import com.correx.core.approvals.Tier +import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.ValidationReportId +import com.correx.core.kernel.orchestration.buildRejectionFeedbackEntry +import kotlinx.datetime.Instant import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId @@ -100,6 +109,58 @@ class ContextFeedbackTest { assertNull(buildRetryFeedbackEntry(events, StageId("impl"))) } + @Test + fun `rejection feedback is one stage-scoped entry naming tool preview tier and reason`() { + val req = ApprovalRequestId("req-1") + val events = listOf( + stored( + payload = ApprovalRequestedEvent( + requestId = req, tier = Tier.T2, + validationReportId = ValidationReportId("vr-1"), riskSummaryId = null, + sessionId = SessionId("s"), stageId = StageId("impl"), projectId = null, + toolName = "shell", preview = "rm -rf build/", + ), + ), + stored( + payload = ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("d-1"), requestId = req, + outcome = ApprovalOutcome.REJECTED, status = ApprovalStatus.COMPLETED, + tier = Tier.T2, resolutionTimestamp = Instant.fromEpochMilliseconds(0), + reason = "destructive; do not delete build output", + ), + ), + ) + val entry = buildRejectionFeedbackEntry(events, StageId("impl"))!! + assertEquals("rejectionFeedback", entry.sourceType) + assertEquals(ContextLayer.L2, entry.layer) + assertTrue(entry.content.contains("## Rejected tool calls this stage"), entry.content) + assertTrue(entry.content.contains("shell (tier T2) rm -rf build/"), entry.content) + assertTrue(entry.content.contains("destructive; do not delete build output"), entry.content) + } + + @Test + fun `rejection feedback ignores other stages and non-rejected decisions`() { + val reqOther = ApprovalRequestId("req-o") + val events = listOf( + stored( + payload = ApprovalRequestedEvent( + requestId = reqOther, tier = Tier.T1, + validationReportId = ValidationReportId("vr-2"), riskSummaryId = null, + sessionId = SessionId("s"), stageId = StageId("other"), projectId = null, + toolName = "file_write", preview = "x", + ), + ), + stored( + payload = ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("d-2"), requestId = reqOther, + outcome = ApprovalOutcome.REJECTED, status = ApprovalStatus.COMPLETED, + tier = Tier.T1, resolutionTimestamp = Instant.fromEpochMilliseconds(0), reason = null, + ), + ), + ) + assertNull(buildRejectionFeedbackEntry(events, StageId("impl"))) + } + @Test fun `project profile renders as single L0 entry`() { val entry = buildProjectProfileEntry(