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
@@ -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"