feat(kernel): producer-exit clarification loop
When a stage produces an LLM artifact carrying a non-empty questions array, the orchestrator parks the run, records ClarificationRequested, awaits the operator's answers (submitClarification seam), records ClarificationAnswered, injects the answered Q&A as an L2 USER entry, and re-runs the same stage so the questions are resolved by the role that asked them. Runs on a dedicated pendingClarifications map and the enterStage success-branch loop, so a clarification round never consumes the failure-retry budget; capped at three rounds. Integration test covers the happy path and the cap.
This commit is contained in:
+33
@@ -12,6 +12,8 @@ import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
@@ -21,6 +23,7 @@ import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ClarificationRequestId
|
||||
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
@@ -270,6 +273,31 @@ class DefaultSessionOrchestrator(
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers the operator's answers to a pending clarification raised by a stage. The waiting
|
||||
* stage (parked in [requestClarificationIfNeeded]) completes and re-runs with the answers in
|
||||
* context. If the server restarted while the clarification was pending there is no live
|
||||
* coroutine to complete, so the answers are recorded directly and the session resumed.
|
||||
*/
|
||||
suspend fun submitClarification(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
requestId: ClarificationRequestId,
|
||||
answers: List<ClarificationAnswer>,
|
||||
) {
|
||||
val deferred = pendingClarifications[requestId]
|
||||
if (deferred != null) {
|
||||
deferred.complete(answers)
|
||||
return
|
||||
}
|
||||
log.warn(
|
||||
"submitClarification: no live deferred for requestId={} (server restart?), recording directly",
|
||||
requestId.value,
|
||||
)
|
||||
emit(sessionId, ClarificationAnsweredEvent(requestId, sessionId, stageId, answers))
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
}
|
||||
|
||||
private suspend fun executeMove(
|
||||
ctx: EnrichedExecutionContext,
|
||||
decision: TransitionDecision.Move,
|
||||
@@ -356,6 +384,11 @@ class DefaultSessionOrchestrator(
|
||||
).outcome
|
||||
when (result) {
|
||||
is StageExecutionResult.Success -> {
|
||||
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
|
||||
// The stage raised open questions and the operator answered them; loop to
|
||||
// re-run the stage with the answers injected (no failure-retry budget spent).
|
||||
continue
|
||||
}
|
||||
compactionService?.let { svc ->
|
||||
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
||||
val journalText = DecisionJournalRenderer().render(journalState)
|
||||
|
||||
+85
-1
@@ -30,6 +30,10 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationQuestions
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.GroundingReference
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
@@ -71,6 +75,7 @@ import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ClarificationRequestId
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -142,6 +147,9 @@ private const val REPO_MAP_INJECT_TOP_K = 30
|
||||
private const val HTTP_TIMEOUT = 408
|
||||
private const val HTTP_TOO_MANY_REQUESTS = 429
|
||||
|
||||
// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds.
|
||||
private const val MAX_CLARIFICATION_ROUNDS = 3
|
||||
|
||||
@SuppressWarnings(
|
||||
"ForbiddenComment",
|
||||
"UnusedParameter",
|
||||
@@ -185,6 +193,13 @@ abstract class SessionOrchestrator(
|
||||
internal val pendingApprovals: ConcurrentHashMap<ApprovalRequestId, CompletableDeferred<ApprovalDecision>> =
|
||||
ConcurrentHashMap()
|
||||
|
||||
// A stage whose produced artifact carries open questions parks its run on one of these until the
|
||||
// operator answers (submitClarification completes it). Separate from pendingApprovals so a
|
||||
// clarification round never consumes the failure-retry budget.
|
||||
internal val pendingClarifications:
|
||||
ConcurrentHashMap<ClarificationRequestId, CompletableDeferred<List<ClarificationAnswer>>> =
|
||||
ConcurrentHashMap()
|
||||
|
||||
/** Cache mapping "sessionId:artifactId" to JSON-serialized artifact payload content.
|
||||
* Populated when ProcessResult artifacts are stored on tool failure.
|
||||
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
|
||||
@@ -328,6 +343,7 @@ abstract class SessionOrchestrator(
|
||||
|
||||
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
|
||||
val steeringEntries = buildSteeringNoteEntries(sessionId)
|
||||
val clarificationEntries = buildClarificationAnswerEntries(sessionId)
|
||||
|
||||
// Maintain the running transcript in true chronological order. Reading it back
|
||||
// from currentContext.layers (a Map grouped by layer) would scramble turn order
|
||||
@@ -385,7 +401,8 @@ abstract class SessionOrchestrator(
|
||||
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
|
||||
var accumulatedEntries =
|
||||
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + retryFeedbackEntries
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries
|
||||
val contextPack = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
@@ -1003,6 +1020,73 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the operator's answers to a stage's open questions as an L2 USER entry, so the stage
|
||||
* sees its own questions resolved on the clarification re-run. Correlates each answer's
|
||||
* questionId back to the prompt recorded on the [ClarificationRequestedEvent].
|
||||
*/
|
||||
private suspend fun buildClarificationAnswerEntries(sessionId: SessionId): List<ContextEntry> {
|
||||
val events = eventStore.read(sessionId)
|
||||
val answered = events.mapNotNull { it.payload as? ClarificationAnsweredEvent }
|
||||
if (answered.isEmpty()) return emptyList()
|
||||
val prompts = events
|
||||
.mapNotNull { it.payload as? ClarificationRequestedEvent }
|
||||
.flatMap { it.questions }
|
||||
.associate { it.id to it.prompt }
|
||||
val rendered = answered.flatMap { it.answers }.joinToString("\n") { a ->
|
||||
"Q: ${prompts[a.questionId] ?: a.questionId}\nA: ${a.value}"
|
||||
}
|
||||
val content = "The operator answered your open questions. Treat these answers as authoritative " +
|
||||
"and do not ask them again:\n$rendered"
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
content = content,
|
||||
sourceType = "clarificationAnswer",
|
||||
sourceId = sessionId.value,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
|
||||
* run: record the questions (invariant #9 — observed at parse time), await the operator's
|
||||
* answers, record them, and return true so the caller re-runs the stage with the answers in
|
||||
* context. Bounded by [MAX_CLARIFICATION_ROUNDS] so a stage that keeps re-asking eventually
|
||||
* proceeds. Returns false when there are no questions or the round budget is spent.
|
||||
*/
|
||||
internal suspend fun requestClarificationIfNeeded(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
graph: WorkflowGraph,
|
||||
): Boolean {
|
||||
val slot = graph.stages[stageId]?.produces?.firstOrNull { it.kind.llmEmitted } ?: return false
|
||||
val content = artifactContentCache["${sessionId.value}:${slot.name.value}"] ?: return false
|
||||
val questions = ClarificationQuestions.parse(content)
|
||||
if (questions.isEmpty()) return false
|
||||
|
||||
val priorRounds = eventStore.read(sessionId)
|
||||
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
|
||||
if (priorRounds >= MAX_CLARIFICATION_ROUNDS) return false
|
||||
|
||||
val requestId = ClarificationRequestId(UUID.randomUUID().toString())
|
||||
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
|
||||
pendingClarifications[requestId] = deferred
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "CLARIFICATION_PENDING"))
|
||||
emit(sessionId, ClarificationRequestedEvent(requestId, sessionId, stageId, questions))
|
||||
return try {
|
||||
val answers = deferred.await()
|
||||
emit(sessionId, ClarificationAnsweredEvent(requestId, sessionId, stageId, answers))
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
true
|
||||
} finally {
|
||||
pendingClarifications.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a top-K slice of the recorded repo map as a droppable L3 context entry. Reads
|
||||
* the latest [RepoMapComputedEvent] from the log (replay-safe — never re-scans the FS).
|
||||
|
||||
Reference in New Issue
Block a user