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:
+45
-11
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user