feat(config): relocate orchestration loop/threshold/budget constants to [orchestration] config

The kernel's ReAct-loop tuning constants (max tool rounds, read/rejection-loop
nudge thresholds, feedback issue cap, repo-map top-k/files-per-dir, docs catalog
cap, clarification-round cap, review-block confidence/retry cap, default
refinement, recovery/intent route budgets) were hardcoded in SessionOrchestrator/
DefaultSessionOrchestrator. Relocated to a new OrchestrationTuning value threaded
through the orchestrator constructor, mapped from CorrexConfig.orchestration in
Main.kt, parsed in ConfigLoader, written by CorrexConfigWriter. Defaults equal the
former constants so an absent [orchestration] section reproduces prior behavior.

Startup-load (not hot-reload); pure output-truncation caps left as constants.

Vikunja #46 (task 76).
This commit is contained in:
2026-07-12 17:45:54 +04:00
parent 8d7c827ebb
commit b2c7bbe401
7 changed files with 139 additions and 33 deletions
@@ -34,6 +34,7 @@ import com.correx.core.inference.ModelCapability
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationTuning
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
@@ -389,6 +390,27 @@ fun main() {
toolRegistry.all().map { it.name }.toSet(),
injectRecovery = true,
)
// Startup-load orchestration tuning from the [orchestration] config section into the kernel's
// OrchestrationTuning. ponytail: read once at boot — a config edit needs a restart to take effect
// (unlike stage_timeout_ms, which ServerModule re-reads per session). Add a supplier if these ever
// need hot-reload.
val orchestrationTuning = with(correxConfig.orchestration) {
OrchestrationTuning(
maxToolRounds = maxToolRounds,
readLoopNudgeThreshold = readLoopNudgeThreshold,
rejectionLoopNudgeThreshold = rejectionLoopNudgeThreshold,
maxFeedbackIssues = maxFeedbackIssues,
repoMapInjectTopK = repoMapInjectTopK,
repoMapFilesPerDir = repoMapFilesPerDir,
docsCatalogMax = docsCatalogMax,
maxClarificationRounds = maxClarificationRounds,
reviewBlockMinConfidence = reviewBlockMinConfidence,
reviewBlockRetryCap = reviewBlockRetryCap,
defaultMaxRefinement = defaultMaxRefinement,
recoveryRouteBudget = recoveryRouteBudget,
intentRouteBudget = intentRouteBudget,
)
}
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines.copy(
@@ -424,6 +446,7 @@ fun main() {
taskSessionResolver,
),
),
tuning = orchestrationTuning,
)
val workflowRegistry = FileSystemWorkflowRegistry(
InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly),
@@ -630,6 +630,19 @@ object ConfigLoader {
compressionLevel = asInt(orchestrationSection["compression_level"], 4),
tokenPrunerUrl =
asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"),
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], 30),
readLoopNudgeThreshold = asInt(orchestrationSection["read_loop_nudge_threshold"], 3),
rejectionLoopNudgeThreshold = asInt(orchestrationSection["rejection_loop_nudge_threshold"], 3),
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], 3),
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], 30),
repoMapFilesPerDir = asInt(orchestrationSection["repo_map_files_per_dir"], 8),
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], 20),
maxClarificationRounds = asInt(orchestrationSection["max_clarification_rounds"], 3),
reviewBlockMinConfidence = asDouble(orchestrationSection["review_block_min_confidence"], 0.7),
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], 20),
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], 3),
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], 2),
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], 2),
)
val modelsSettings = ModelsSettings(
@@ -69,6 +69,25 @@ data class OrchestrationKnobs(
*/
val compressionLevel: Int = 4,
val tokenPrunerUrl: String = "http://127.0.0.1:8199",
/**
* Orchestration loop/threshold/budget tuning, read once at server startup. Defaults equal the
* kernel's former hardcoded constants — an absent section reproduces prior behavior. Reach for
* these when a local model misbehaves (thrashes a read loop, re-asks clarifications, gets trapped
* by a stubborn reviewer). Mirrors kernel OrchestrationTuning.
*/
val maxToolRounds: Int = 30,
val readLoopNudgeThreshold: Int = 3,
val rejectionLoopNudgeThreshold: Int = 3,
val maxFeedbackIssues: Int = 3,
val repoMapInjectTopK: Int = 30,
val repoMapFilesPerDir: Int = 8,
val docsCatalogMax: Int = 20,
val maxClarificationRounds: Int = 3,
val reviewBlockMinConfidence: Double = 0.7,
val reviewBlockRetryCap: Int = 20,
val defaultMaxRefinement: Int = 3,
val recoveryRouteBudget: Int = 2,
val intentRouteBudget: Int = 2,
)
@Serializable
@@ -86,6 +86,21 @@ object CorrexConfigWriter {
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.kv("compression_level", cfg.orchestration.compressionLevel)
b.kv("token_pruner_url", cfg.orchestration.tokenPrunerUrl)
b.kv("max_tool_rounds", cfg.orchestration.maxToolRounds)
b.kv("read_loop_nudge_threshold", cfg.orchestration.readLoopNudgeThreshold)
b.kv("rejection_loop_nudge_threshold", cfg.orchestration.rejectionLoopNudgeThreshold)
b.kv("max_feedback_issues", cfg.orchestration.maxFeedbackIssues)
b.kv("repo_map_inject_top_k", cfg.orchestration.repoMapInjectTopK)
b.kv("repo_map_files_per_dir", cfg.orchestration.repoMapFilesPerDir)
b.kv("docs_catalog_max", cfg.orchestration.docsCatalogMax)
b.kv("max_clarification_rounds", cfg.orchestration.maxClarificationRounds)
b.kv("review_block_min_confidence", cfg.orchestration.reviewBlockMinConfidence)
b.kv("review_block_retry_cap", cfg.orchestration.reviewBlockRetryCap)
b.kv("default_max_refinement", cfg.orchestration.defaultMaxRefinement)
b.kv("recovery_route_budget", cfg.orchestration.recoveryRouteBudget)
b.kv("intent_route_budget", cfg.orchestration.intentRouteBudget)
b.section("personalization")
b.kv("enabled", cfg.personalization.enabled)
@@ -57,18 +57,14 @@ import java.util.concurrent.atomic.*
)
private val log = LoggerFactory.getLogger(DefaultSessionOrchestrator::class.java)
// Fallback refinement-loop cap when a stage does not declare maxRetries.
private const val DEFAULT_MAX_REFINEMENT = 3
// Own, small budget for the retry-agency recovery route: how many times a single stage may be
// routed to a recovery stage before the run fails terminally. Deliberately smaller than the
// per-gate retry budget — recovery is a purpose-built repair-then-reverify loop, not open-ended.
private const val RECOVERY_ROUTE_BUDGET = 2
// RECOVERY_ROUTE_BUDGET / INTENT_ROUTE_BUDGET now in OrchestrationTuning.
// Tier-2 escalation budget: how many NO-PROGRESS routes the arbiter (recovery-stage-as-intent-holder)
// gets after the tier-1 owner loop exhausts, before the run fails terminally. Independent of the owner
// budget (keyed with INTENT_BUDGET_SUFFIX) so escalation is a genuine second chance, not a shared pool.
private const val INTENT_ROUTE_BUDGET = 2
// Transition ids for the failure-ticket loop. A ticket routes the failing gate's control to the
// repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that
@@ -109,7 +105,8 @@ class DefaultSessionOrchestrator(
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
readyTaskCounter: ReadyTaskCounter? = null,
taskClaimCoordinator: TaskClaimCoordinator? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator), ApprovalGateway {
tuning: OrchestrationTuning = OrchestrationTuning(),
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>()
@@ -340,10 +337,10 @@ class DefaultSessionOrchestrator(
// (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to
// the arbiter tier once the owner loop is spent; null = no tier available now.
val route = when {
owner != null && !budgetExhausted(state, ownerKey, fingerprint, RECOVERY_ROUTE_BUDGET) ->
RouteTier(owner, ownerKey, RECOVERY_ROUTE_BUDGET, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, INTENT_ROUTE_BUDGET) ->
RouteTier(arbiter, intentKey, INTENT_ROUTE_BUDGET, escalated = true)
owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
else -> null
}
if (route == null) {
@@ -621,7 +618,7 @@ class DefaultSessionOrchestrator(
// terminal failure instead of looping forever.
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: DEFAULT_MAX_REFINEMENT
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
if (iteration > maxIterations) {
@@ -0,0 +1,44 @@
package com.correx.core.kernel.orchestration
/**
* Operator-tunable orchestration loop/threshold/budget knobs, loaded once at server startup from
* the `[orchestration]` config section and threaded into the orchestrator. Defaults equal the
* former hardcoded constants, so an absent config section reproduces prior behavior exactly.
*
* These are the knobs an operator reaches for when a *local* model misbehaves (thrashes a read
* loop, re-asks the same clarification, gets trapped by a stubborn reviewer). Pure output-shaping
* truncation caps (summary/evidence length limits) are intentionally left as constants — they don't
* change orchestration behavior, only display width.
*
* ponytail: startup-load, not hot-reload — orchestrator holds this by value. If per-session
* live-tuning is ever wanted, pass a `() -> OrchestrationTuning` supplier like the journal-compaction
* threshold does.
*/
data class OrchestrationTuning(
/** Max inference+tool rounds in a single stage's ReAct loop before it's forced to conclude. */
val maxToolRounds: Int = 30,
/** Consecutive read-only rounds after which the model is nudged to write. */
val readLoopNudgeThreshold: Int = 3,
/** Consecutive rejected-tool rounds after which the model is nudged off the blocked path. */
val rejectionLoopNudgeThreshold: Int = 3,
/** Max validation issues surfaced back to the model as feedback. */
val maxFeedbackIssues: Int = 3,
/** Top-K repo-map hits injected into a stage's context. */
val repoMapInjectTopK: Int = 30,
/** Files listed per directory in the what-exists repo map. */
val repoMapFilesPerDir: Int = 8,
/** Max docs listed in the always-on "docs available" catalog. */
val docsCatalogMax: Int = 20,
/** Max times a single stage may re-ask clarification before it must proceed. */
val maxClarificationRounds: Int = 3,
/** Minimum reviewer confidence for a correctness finding to block a stage. */
val reviewBlockMinConfidence: Double = 0.7,
/** Pathological backstop: max review-driven retries before the stage is let through. */
val reviewBlockRetryCap: Int = 20,
/** Max review→refine cycles for a stage (freestyle default refinement budget). */
val defaultMaxRefinement: Int = 3,
/** Budget for routing a failed write-less stage to a recovery stage. */
val recoveryRouteBudget: Int = 2,
/** Budget for tier-2 intent-holder arbiter re-routing. */
val intentRouteBudget: Int = 2,
)
@@ -181,14 +181,13 @@ import kotlin.coroutines.cancellation.CancellationException
// re-investigating (list_dir/read/rebuild) before it reaches the write, and got bounced out before
// acting on a correct diagnosis. The real runaway guards are the read-loop and rejection-loop
// nudges (both fire after 3 unproductive rounds), so this ceiling only needs to be generous enough
// that productive stages aren't cut off mid-work.
private const val MAX_TOOL_ROUNDS = 30
// that productive stages aren't cut off mid-work. Value now lives in OrchestrationTuning.maxToolRounds.
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
// artifact before we force the write nudge. A model that keeps calling read tools every round
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
private const val READ_LOOP_NUDGE_THRESHOLD = 3
// Value now lives in OrchestrationTuning.readLoopNudgeThreshold.
// Consecutive rounds in which EVERY tool call was rejected/denied (plane-2 BLOCKED or a stage/policy
// ERROR) with nothing succeeding. Distinct from the read-loop breaker, which only fires for stages
@@ -196,22 +195,19 @@ private const val READ_LOOP_NUDGE_THRESHOLD = 3
// rejected path (e.g. list/read of a not-yet-created dir) would otherwise thrash to MAX_TOOL_ROUNDS
// with no progress. After this many all-rejected rounds we force the model to stop retrying and
// produce its output; a single successful tool call resets the counter.
private const val REJECTION_LOOP_NUDGE_THRESHOLD = 3
// Value now lives in OrchestrationTuning.rejectionLoopNudgeThreshold.
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
private const val MAX_FEEDBACK_ISSUES = 3
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30
private const val REPO_MAP_FILES_PER_DIR = 8
// Read-on-demand doc catalog: the top-N docs (by repo-map recency score) surfaced as
// `path — descriptor` so a stage learns which docs exist and can file_read one when relevant,
// instead of docs being force-fed (or excluded outright). One line each, hard-capped, so it can
// stay always-on without re-poisoning context (2026-07-07 doc-injection rework).
private const val DOCS_CATALOG_MAX = 20
// Value now lives in OrchestrationTuning.docsCatalogMax.
// A stage prompt must explicitly ask about documentation for .md/docs paths to enter the repo
// layout listing; otherwise docs only reach context through a real retrieval hit.
@@ -241,8 +237,7 @@ private val REQUIRED_SOURCE_TYPES = setOf(
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
// Cap on clarification rounds per stage — OrchestrationTuning.maxClarificationRounds.
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
@@ -255,8 +250,7 @@ private const val CONTRACT_EVIDENCE_CAP = 400
// every attempt (so the budget never charges, i.e. the run looks like perpetual "progress"): after
// this many blocks, findings surface without blocking so a stuck reviewer can never trap a stage
// forever. Set well above any legitimate budget+salvage sequence. Objective text is capped too.
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
private const val REVIEW_BLOCK_RETRY_CAP = 20
// REVIEW_BLOCK_MIN_CONFIDENCE / REVIEW_BLOCK_RETRY_CAP now in OrchestrationTuning.
private const val REVIEW_OBJECTIVE_CAP = 4_000
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@@ -287,6 +281,7 @@ abstract class SessionOrchestrator(
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
private val readyTaskCounter: ReadyTaskCounter? = null,
private val taskClaimCoordinator: TaskClaimCoordinator? = null,
protected val tuning: OrchestrationTuning = OrchestrationTuning(),
) {
private val log = LoggerFactory.getLogger(this::class.java)
private val eventStore: EventStore = repositories.eventStore
@@ -690,7 +685,7 @@ abstract class SessionOrchestrator(
while (
inferenceResult is InferenceResult.Success &&
toolRounds < MAX_TOOL_ROUNDS &&
toolRounds < tuning.maxToolRounds &&
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
) {
// Content turn (no tool call) but the stage still owes a file_written artifact: the
@@ -769,7 +764,7 @@ abstract class SessionOrchestrator(
consecutiveReadOnlyRounds = 0
} else {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
@@ -789,7 +784,7 @@ abstract class SessionOrchestrator(
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
if (allRejected) {
consecutiveRejectedRounds++
if (consecutiveRejectedRounds >= REJECTION_LOOP_NUDGE_THRESHOLD) {
if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
consecutiveRejectedRounds = 0
inferenceResult = pushBack(
"STOP. Your last tool calls were all rejected and retrying the same paths will " +
@@ -1774,7 +1769,7 @@ abstract class SessionOrchestrator(
val priorRounds = eventStore.read(sessionId)
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
if (priorRounds >= MAX_CLARIFICATION_ROUNDS) return false
if (priorRounds >= tuning.maxClarificationRounds) return false
val requestId = ClarificationRequestId(UUID.randomUUID().toString())
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
@@ -1814,7 +1809,7 @@ abstract class SessionOrchestrator(
appendLine("## Repo layout (what exists — use file_read for content)")
byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
val names = files.map { it.substringAfterLast('/') }
val shown = names.take(REPO_MAP_FILES_PER_DIR)
val shown = names.take(tuning.repoMapFilesPerDir)
val more = names.size - shown.size
val suffix = if (more > 0) ", …(+$more more)" else ""
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
@@ -1915,7 +1910,7 @@ abstract class SessionOrchestrator(
val retriever = repoKnowledgeRetriever
?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId)
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, REPO_MAP_INJECT_TOP_K) }
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, tuning.repoMapInjectTopK) }
.getOrElse { e ->
if (e is CancellationException) throw e
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
@@ -1942,7 +1937,7 @@ abstract class SessionOrchestrator(
val docs = map.entries
.filter { isDocPath(it.path) }
.sortedByDescending { it.score }
.take(DOCS_CATALOG_MAX)
.take(tuning.docsCatalogMax)
if (docs.isEmpty()) return emptyList()
val content = buildString {
appendLine("## Docs available (file_read to open — do not assume contents)")
@@ -2787,11 +2782,11 @@ abstract class SessionOrchestrator(
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
.count { it.stageId == stageId && it.blocked }
val blockingFinding = outcome.findings.firstOrNull {
it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE
it.correctness && it.confidence >= tuning.reviewBlockMinConfidence
}
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
blockingFinding != null &&
priorBlocks < REVIEW_BLOCK_RETRY_CAP
priorBlocks < tuning.reviewBlockRetryCap
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
@@ -2884,7 +2879,7 @@ abstract class SessionOrchestrator(
return if (errors.isEmpty()) {
"validation failed"
} else {
"validation failed: " + errors.take(MAX_FEEDBACK_ISSUES).joinToString("; ")
"validation failed: " + errors.take(tuning.maxFeedbackIssues).joinToString("; ")
}
}