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:
+53
-29
@@ -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<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 = 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
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user