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
|
assessment
|
||||||
}
|
}
|
||||||
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
|
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) {
|
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
|
||||||
// no approval needed
|
// no approval needed
|
||||||
} else {
|
} else {
|
||||||
@@ -559,6 +562,7 @@ abstract class SessionOrchestrator(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
approvalNote = userDecision.userSteering?.text
|
||||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,7 +621,18 @@ abstract class SessionOrchestrator(
|
|||||||
tokenEstimate = estimateTokens(resultContent),
|
tokenEstimate = estimateTokens(resultContent),
|
||||||
role = EntryRole.TOOL,
|
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),
|
tokenEstimate = estimateTokens(p.content),
|
||||||
role = EntryRole.SYSTEM,
|
role = EntryRole.SYSTEM,
|
||||||
)
|
)
|
||||||
is ApprovalDecisionResolvedEvent -> p.userSteering?.let { steering ->
|
is ApprovalDecisionResolvedEvent -> {
|
||||||
ContextEntry(
|
val steering = p.userSteering
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
when {
|
||||||
layer = ContextLayer.L2,
|
steering != null -> ContextEntry(
|
||||||
content = steering.text,
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
sourceType = "steeringNote",
|
layer = ContextLayer.L2,
|
||||||
sourceId = steering.sessionId.value,
|
content = steering.text,
|
||||||
tokenEstimate = estimateTokens(steering.text),
|
sourceType = "steeringNote",
|
||||||
role = EntryRole.USER,
|
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
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
import com.correx.core.approvals.ApprovalProjector
|
import com.correx.core.approvals.ApprovalProjector
|
||||||
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
import com.correx.core.approvals.DefaultApprovalReducer
|
import com.correx.core.approvals.DefaultApprovalReducer
|
||||||
import com.correx.core.approvals.DefaultApprovalRepository
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.approvals.UserSteering
|
||||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
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.artifacts.DefaultArtifactReducer
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
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")
|
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
|
@Test
|
||||||
fun `tool output is compressed on the path into context`(): Unit = runBlocking {
|
fun `tool output is compressed on the path into context`(): Unit = runBlocking {
|
||||||
val rawOutput = "line1\n\nline2\n\n\nline3"
|
val rawOutput = "line1\n\nline2\n\n\nline3"
|
||||||
|
|||||||
Reference in New Issue
Block a user