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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.UserSteering
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import kotlinx.datetime.Clock
|
||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
@@ -290,6 +298,60 @@ class ToolCallGateTest {
|
||||
assertNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must NOT be emitted when assessor is null")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approving with a steering note injects it into the next inference context`(): Unit = runBlocking {
|
||||
val executor = RecordingExecutor()
|
||||
// T2 so PROMPT mode does not auto-approve — the orchestrator pauses for a human decision.
|
||||
val (orchestrator, eventStore, provider) = buildOrchestrator(
|
||||
executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER),
|
||||
)
|
||||
val sessionId = SessionId("gate-steer")
|
||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||
|
||||
val job = launch { orchestrator.run(sessionId, singleStageGraph(), config) }
|
||||
|
||||
val request = withTimeout(5_000) {
|
||||
var req: ApprovalRequestedEvent? = null
|
||||
while (req == null) {
|
||||
req = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
|
||||
if (req == null) yield()
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
val note = "add the current distro to the output"
|
||||
orchestrator.submitApprovalDecision(
|
||||
request.requestId,
|
||||
ApprovalDecision(
|
||||
id = null,
|
||||
requestId = request.requestId,
|
||||
outcome = ApprovalOutcome.APPROVED,
|
||||
state = ApprovalStatus.COMPLETED,
|
||||
tier = Tier.T2,
|
||||
contextSnapshot = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(sessionId, StageId("A"), projectId = null),
|
||||
mode = ApprovalMode.PROMPT,
|
||||
),
|
||||
resolutionTimestamp = Clock.System.now(),
|
||||
reason = note,
|
||||
userSteering = UserSteering(text = note, sessionId = sessionId, timestamp = Clock.System.now()),
|
||||
),
|
||||
)
|
||||
|
||||
// The approved call executes, then the loop re-infers — that second request must carry the note.
|
||||
withTimeout(5_000) {
|
||||
while (provider.requests.size < 2) yield()
|
||||
}
|
||||
job.cancel()
|
||||
job.join()
|
||||
|
||||
assertTrue(executor.executeCalled.get(), "the approved tool must still execute")
|
||||
val steeringEntry = provider.requests[1].contextPack.layers.values.flatten().firstOrNull {
|
||||
it.sourceType == "steeringNote" && it.content.contains(note)
|
||||
}
|
||||
assertNotNull(steeringEntry, "approved steering note must be injected into the next inference context")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool output is compressed on the path into context`(): Unit = runBlocking {
|
||||
val rawOutput = "line1\n\nline2\n\n\nline3"
|
||||
|
||||
Reference in New Issue
Block a user