diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index bf21cd87..871552a5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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), diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 41a8a674..28f12577 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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( diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 1b529324..565e6b02 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -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 diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt index 0ffe77af..5edf05f3 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt @@ -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) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index 997600a7..fb80df81 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -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 = ConcurrentHashMap() @@ -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) { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt new file mode 100644 index 00000000..891dab48 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt @@ -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, +) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index e03d6909..40e796d7 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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>() @@ -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("; ") } }