From d8e36b8282008d0ea210357fd50dc79091a5530f Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 01:44:08 +0400 Subject: [PATCH] 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. --- .../orchestration/SessionOrchestrator.kt | 56 +++++++++++++---- .../src/test/kotlin/ToolCallGateTest.kt | 62 +++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 4ea9089a..ed7cf871 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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 } diff --git a/testing/integration/src/test/kotlin/ToolCallGateTest.kt b/testing/integration/src/test/kotlin/ToolCallGateTest.kt index 5a7c0483..763d067a 100644 --- a/testing/integration/src/test/kotlin/ToolCallGateTest.kt +++ b/testing/integration/src/test/kotlin/ToolCallGateTest.kt @@ -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"