#2: approval pauses narrate from ApprovalRequestedEvent (carries tool/tier/preview/stage directly) instead of OrchestrationPaused + a pending-approvals map the next event had not yet populated — the narrator always lost that race and produced generic text. #3: NarrationTrigger now carries the triggering event's stageId; the narration status line and RouterNarrationEvent.stageId use it instead of the router projection's currentStageId, which terminal events have already cleared ('Stage: none'). #9: ExecutionPlanLocked -> plan.locked and ArtifactValidated -> artifact.validated mapped to the TUI (Go: feed lines, plan shows the compiled stage chain); ArtifactValidating explicitly dropped. #10: blank router turn guard — a length-finish with empty text now emits an explanatory turn naming the max_tokens budget instead of a silent blank; blank steering responses no longer emit steering notes. #12: resumeAbandoned only auto-resumes sessions whose last event is within orchestration.resume_abandoned_max_age_minutes (default 24h, 0 disables); staler sessions stay parked for POST /resume. Knob is TUI-editable and persisted.
This commit is contained in:
@@ -583,6 +583,8 @@ object ConfigLoader {
|
||||
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], 180_000),
|
||||
journalCompactionTokenThreshold =
|
||||
asInt(orchestrationSection["journal_compaction_token_threshold"], 2_000),
|
||||
resumeAbandonedMaxAgeMinutes =
|
||||
asLong(orchestrationSection["resume_abandoned_max_age_minutes"], 1_440),
|
||||
)
|
||||
|
||||
val modelsSettings = ModelsSettings(
|
||||
|
||||
@@ -28,6 +28,12 @@ data class CorrexConfig(
|
||||
data class OrchestrationKnobs(
|
||||
val stageTimeoutMs: Long = 180_000,
|
||||
val journalCompactionTokenThreshold: Int = 2_000,
|
||||
/**
|
||||
* Boot-time abandoned-session pickup only resumes sessions whose last event is at most
|
||||
* this old; anything staler stays parked (POST /sessions/{id}/resume revives it on demand).
|
||||
* 0 disables auto-resume entirely.
|
||||
*/
|
||||
val resumeAbandonedMaxAgeMinutes: Long = 1_440,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -84,6 +84,7 @@ object CorrexConfigWriter {
|
||||
b.section("orchestration")
|
||||
b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs)
|
||||
b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold)
|
||||
b.kv("resume_abandoned_max_age_minutes", cfg.orchestration.resumeAbandonedMaxAgeMinutes)
|
||||
|
||||
b.section("personalization")
|
||||
b.kv("enabled", cfg.personalization.enabled)
|
||||
|
||||
@@ -175,6 +175,13 @@ object EditableConfig {
|
||||
getString = { it.orchestration.journalCompactionTokenThreshold.toString() },
|
||||
withString = { c, v -> orc(c) { it.copy(journalCompactionTokenThreshold = int(JCT_KEY, v)) } },
|
||||
),
|
||||
EditableField(
|
||||
"orchestration.resume_abandoned_max_age_minutes", ConfigFieldType.LONG,
|
||||
getString = { it.orchestration.resumeAbandonedMaxAgeMinutes.toString() },
|
||||
withString = { c, v ->
|
||||
orc(c) { it.copy(resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)) }
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
private const val JCT_KEY = "orchestration.journal_compaction_token_threshold"
|
||||
|
||||
@@ -281,9 +281,9 @@ class DefaultRouterContextBuilder(
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildWorkflowStatusContent(state: RouterState): String {
|
||||
private fun buildWorkflowStatusContent(state: RouterState, stageOverride: String? = null): String {
|
||||
val status = capitalizeFirst(state.workflowStatus.name.lowercase())
|
||||
val stage = state.currentStageId?.value ?: "none"
|
||||
val stage = stageOverride ?: state.currentStageId?.value ?: "none"
|
||||
return "Status: $status, Stage: $stage"
|
||||
}
|
||||
|
||||
@@ -369,8 +369,8 @@ class DefaultRouterContextBuilder(
|
||||
)
|
||||
val workflowStatusEntry = buildContextEntry(
|
||||
sourceType = "workflowStatus",
|
||||
sourceId = state.currentStageId?.value ?: "none",
|
||||
content = buildWorkflowStatusContent(state),
|
||||
sourceId = trigger.stageId ?: state.currentStageId?.value ?: "none",
|
||||
content = buildWorkflowStatusContent(state, trigger.stageId),
|
||||
layer = ContextLayer.L0,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.Embedder
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ModelCapability
|
||||
@@ -153,7 +154,18 @@ class DefaultRouterFacade(
|
||||
responseFormat = ResponseFormat.Text,
|
||||
)
|
||||
val inferenceResponse = provider.infer(inferenceRequest)
|
||||
val content = inferenceResponse.text
|
||||
val rawContent = inferenceResponse.text
|
||||
// A length-finish with no visible text means the model burned the whole completion
|
||||
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
|
||||
val content = rawContent.ifBlank {
|
||||
if (inferenceResponse.finishReason is FinishReason.Length) {
|
||||
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
|
||||
"without producing an answer (likely spent on hidden reasoning). " +
|
||||
"Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again."
|
||||
} else {
|
||||
"The model returned an empty response (finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
|
||||
}
|
||||
}
|
||||
|
||||
// Emit ROUTER turn event
|
||||
emitChatTurn(
|
||||
@@ -162,19 +174,20 @@ class DefaultRouterFacade(
|
||||
tokensUsed = inferenceResponse.tokensUsed,
|
||||
)
|
||||
|
||||
if (mode == ChatMode.STEERING) {
|
||||
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
|
||||
if (steeringEmitted) {
|
||||
val validationError = validateSteering?.invoke(content)
|
||||
if (validationError == null) {
|
||||
emitSteeringNote(sessionId, content, effectiveStageId)
|
||||
}
|
||||
}
|
||||
|
||||
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
|
||||
return RouterResponse(content = content, steeringEmitted = steeringEmitted)
|
||||
}
|
||||
|
||||
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
|
||||
val state = routerRepository.getRouterState(sessionId)
|
||||
val effectiveStageId = state.currentStageId ?: StageId.NONE
|
||||
val effectiveStageId = trigger.stageId?.let { StageId(it) } ?: state.currentStageId ?: StageId.NONE
|
||||
|
||||
val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget)
|
||||
|
||||
|
||||
@@ -11,4 +11,10 @@ data class NarrationTrigger(
|
||||
val kind: String,
|
||||
/** Human-readable instruction injected as the final context entry for this narration. */
|
||||
val instruction: String,
|
||||
/**
|
||||
* Stage the triggering event belongs to. Carried on the trigger because the router
|
||||
* projection's currentStageId is already cleared (workflow terminal events) or not yet
|
||||
* written when the narrator fires — reading it back from state races the reducer.
|
||||
*/
|
||||
val stageId: String? = null,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user