feat(kernel): make approval steering actually affect the run

A steering note attached to a tool approval was recorded but only
consumed by the NEXT stage's prompt build (buildSteeringNoteEntries runs
once, before the tool loop), so on a single-stage task it had no visible
effect. Two changes:

- Approve+note: capture userDecision.userSteering at the gate and inject
  it as a context entry right after the tool result, so the same stage's
  loop re-infers with the note and the model acts on it.
- Reject: buildSteeringNoteEntries now also emits anti-repeat feedback for
  REJECTED decisions with no note, so the model does not re-propose the
  identical call on the retryable re-run (a bare reject previously fed
  back only "approval denied").

Integration test drives an approval with a steering note and asserts the
next inference's context carries it.
This commit is contained in:
2026-06-03 01:44:08 +04:00
parent f7fc10ddf5
commit d8e36b8282
2 changed files with 107 additions and 11 deletions
@@ -445,6 +445,9 @@ abstract class SessionOrchestrator(
assessment
}
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
// A steering note attached to a human approval is captured here and injected after the
// tool result, so the same-stage loop re-infers with it and the model acts on the note.
var approvalNote: String? = null
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
// no approval needed
} else {
@@ -559,6 +562,7 @@ abstract class SessionOrchestrator(
),
)
}
approvalNote = userDecision.userSteering?.text
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
}
}
@@ -617,7 +621,18 @@ abstract class SessionOrchestrator(
tokenEstimate = estimateTokens(resultContent),
role = EntryRole.TOOL,
)
listOf(assistantEntry, resultEntry)
val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "steeringNote",
sourceId = sourceId,
content = "User steering on the approved '${toolCall.function.name}' call: $it",
tokenEstimate = estimateTokens(it),
role = EntryRole.USER,
)
}
listOfNotNull(assistantEntry, resultEntry, steeringEntry)
}
}
@@ -692,16 +707,35 @@ abstract class SessionOrchestrator(
tokenEstimate = estimateTokens(p.content),
role = EntryRole.SYSTEM,
)
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,
)
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
}
}
else -> null
}