feat(stage-workingset): stage-scoped rejection feedback (slice 7)

A rejected tool call produced one generic, context-free warning per rejection
("a previous tool call was rejected — choose a different approach"), repeated
and session-scoped. With no tool, args, tier, or reason, it read to the model
as noise rather than a correction, and could not tell it which call to avoid.

Replace with a single stage-scoped entry (buildRejectionFeedbackEntry) joining
each rejected ApprovalDecisionResolvedEvent back to its ApprovalRequestedEvent
by requestId: tool name, args preview, tier, and the operator's reason. Scoped
to the stage whose calls were declined; steering notes on a decision are kept
separately. Pure (events, stageId) like buildRetryFeedbackEntry, so unit-tested
directly. Keeps sourceType "rejectionFeedback" (REQUIRED bucket) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:28:29 +04:00
parent 918f2f5652
commit a32784bfd8
3 changed files with 117 additions and 30 deletions
@@ -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,10 +87,11 @@ internal suspend fun SessionOrchestrator.buildSteeringNoteEntries(sessionId: Ses
tokenEstimate = estimateTokens(p.content),
role = EntryRole.SYSTEM,
)
is ApprovalDecisionResolvedEvent -> {
val steering = p.userSteering
when {
steering != null -> ContextEntry(
// 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,
@@ -97,28 +100,49 @@ internal suspend fun SessionOrchestrator.buildSteeringNoteEntries(sessionId: Ses
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(
}
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<StoredEvent>, 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 = feedback,
content = content,
sourceType = "rejectionFeedback",
sourceId = sessionId.value,
tokenEstimate = estimateTokens(feedback),
sourceId = stageId.value,
tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM,
)
}
else -> null
}
}
else -> null
}
}
}
/**
* Injects the initial user intent (the freeform request that started the run) as a pinned L0
@@ -229,6 +229,8 @@ internal suspend fun SessionOrchestrator.executeStage(
val agentInstructionsEntries = emptyList<ContextEntry>()
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 {
@@ -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(