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:
@@ -34,6 +34,7 @@ import com.correx.core.inference.ModelCapability
|
|||||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
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.OrchestrationProjector
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
@@ -389,6 +390,27 @@ fun main() {
|
|||||||
toolRegistry.all().map { it.name }.toSet(),
|
toolRegistry.all().map { it.name }.toSet(),
|
||||||
injectRecovery = true,
|
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(
|
val orchestrator = DefaultSessionOrchestrator(
|
||||||
repositories = repositories,
|
repositories = repositories,
|
||||||
engines = engines.copy(
|
engines = engines.copy(
|
||||||
@@ -424,6 +446,7 @@ fun main() {
|
|||||||
taskSessionResolver,
|
taskSessionResolver,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
tuning = orchestrationTuning,
|
||||||
)
|
)
|
||||||
val workflowRegistry = FileSystemWorkflowRegistry(
|
val workflowRegistry = FileSystemWorkflowRegistry(
|
||||||
InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly),
|
InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly),
|
||||||
|
|||||||
@@ -630,6 +630,19 @@ object ConfigLoader {
|
|||||||
compressionLevel = asInt(orchestrationSection["compression_level"], 4),
|
compressionLevel = asInt(orchestrationSection["compression_level"], 4),
|
||||||
tokenPrunerUrl =
|
tokenPrunerUrl =
|
||||||
asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"),
|
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(
|
val modelsSettings = ModelsSettings(
|
||||||
|
|||||||
@@ -69,6 +69,25 @@ data class OrchestrationKnobs(
|
|||||||
*/
|
*/
|
||||||
val compressionLevel: Int = 4,
|
val compressionLevel: Int = 4,
|
||||||
val tokenPrunerUrl: String = "http://127.0.0.1:8199",
|
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
|
@Serializable
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ object CorrexConfigWriter {
|
|||||||
b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs)
|
b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs)
|
||||||
b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold)
|
b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold)
|
||||||
b.kv("resume_abandoned_max_age_minutes", cfg.orchestration.resumeAbandonedMaxAgeMinutes)
|
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.section("personalization")
|
||||||
b.kv("enabled", cfg.personalization.enabled)
|
b.kv("enabled", cfg.personalization.enabled)
|
||||||
|
|||||||
+8
-11
@@ -57,18 +57,14 @@ import java.util.concurrent.atomic.*
|
|||||||
)
|
)
|
||||||
private val log = LoggerFactory.getLogger(DefaultSessionOrchestrator::class.java)
|
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
|
// 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
|
// 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.
|
// 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)
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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,
|
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
||||||
readyTaskCounter: ReadyTaskCounter? = null,
|
readyTaskCounter: ReadyTaskCounter? = null,
|
||||||
taskClaimCoordinator: TaskClaimCoordinator? = 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 tokenizer: Tokenizer? = tokenizer
|
||||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||||
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
|
// (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.
|
// the arbiter tier once the owner loop is spent; null = no tier available now.
|
||||||
val route = when {
|
val route = when {
|
||||||
owner != null && !budgetExhausted(state, ownerKey, fingerprint, RECOVERY_ROUTE_BUDGET) ->
|
owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
|
||||||
RouteTier(owner, ownerKey, RECOVERY_ROUTE_BUDGET, escalated = false)
|
RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
|
||||||
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, INTENT_ROUTE_BUDGET) ->
|
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
|
||||||
RouteTier(arbiter, intentKey, INTENT_ROUTE_BUDGET, escalated = true)
|
RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (route == null) {
|
if (route == null) {
|
||||||
@@ -621,7 +618,7 @@ class DefaultSessionOrchestrator(
|
|||||||
// terminal failure instead of looping forever.
|
// terminal failure instead of looping forever.
|
||||||
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
|
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
|
||||||
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
|
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
|
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
|
||||||
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
||||||
if (iteration > maxIterations) {
|
if (iteration > maxIterations) {
|
||||||
|
|||||||
+44
@@ -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,
|
||||||
|
)
|
||||||
+17
-22
@@ -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
|
// 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
|
// 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
|
// 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.
|
// that productive stages aren't cut off mid-work. Value now lives in OrchestrationTuning.maxToolRounds.
|
||||||
private const val MAX_TOOL_ROUNDS = 30
|
|
||||||
|
|
||||||
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
|
// 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
|
// 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
|
// 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).
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 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 STAGE_COMPLETE_TOOL = "stage_complete"
|
||||||
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
|
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
|
||||||
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
|
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
|
||||||
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
|
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
|
||||||
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
|
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
|
||||||
private const val OUTPUT_SUMMARY_LIMIT = 500
|
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
|
// 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,
|
// `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
|
// 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).
|
// 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
|
// 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.
|
// 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_TIMEOUT = 408
|
||||||
private const val HTTP_TOO_MANY_REQUESTS = 429
|
private const val HTTP_TOO_MANY_REQUESTS = 429
|
||||||
|
|
||||||
// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds.
|
// Cap on clarification rounds per stage — OrchestrationTuning.maxClarificationRounds.
|
||||||
private const val MAX_CLARIFICATION_ROUNDS = 3
|
|
||||||
|
|
||||||
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// forever. Set well above any legitimate budget+salvage sequence. Objective text is capped too.
|
||||||
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
|
// REVIEW_BLOCK_MIN_CONFIDENCE / REVIEW_BLOCK_RETRY_CAP now in OrchestrationTuning.
|
||||||
private const val REVIEW_BLOCK_RETRY_CAP = 20
|
|
||||||
private const val REVIEW_OBJECTIVE_CAP = 4_000
|
private const val REVIEW_OBJECTIVE_CAP = 4_000
|
||||||
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
|
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
|
||||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
|
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
|
||||||
@@ -287,6 +281,7 @@ abstract class SessionOrchestrator(
|
|||||||
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
||||||
private val readyTaskCounter: ReadyTaskCounter? = null,
|
private val readyTaskCounter: ReadyTaskCounter? = null,
|
||||||
private val taskClaimCoordinator: TaskClaimCoordinator? = null,
|
private val taskClaimCoordinator: TaskClaimCoordinator? = null,
|
||||||
|
protected val tuning: OrchestrationTuning = OrchestrationTuning(),
|
||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(this::class.java)
|
private val log = LoggerFactory.getLogger(this::class.java)
|
||||||
private val eventStore: EventStore = repositories.eventStore
|
private val eventStore: EventStore = repositories.eventStore
|
||||||
@@ -690,7 +685,7 @@ abstract class SessionOrchestrator(
|
|||||||
|
|
||||||
while (
|
while (
|
||||||
inferenceResult is InferenceResult.Success &&
|
inferenceResult is InferenceResult.Success &&
|
||||||
toolRounds < MAX_TOOL_ROUNDS &&
|
toolRounds < tuning.maxToolRounds &&
|
||||||
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
|
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
|
||||||
) {
|
) {
|
||||||
// Content turn (no tool call) but the stage still owes a file_written artifact: the
|
// Content turn (no tool call) but the stage still owes a file_written artifact: the
|
||||||
@@ -769,7 +764,7 @@ abstract class SessionOrchestrator(
|
|||||||
consecutiveReadOnlyRounds = 0
|
consecutiveReadOnlyRounds = 0
|
||||||
} else {
|
} else {
|
||||||
consecutiveReadOnlyRounds++
|
consecutiveReadOnlyRounds++
|
||||||
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
|
if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
|
||||||
consecutiveReadOnlyRounds = 0
|
consecutiveReadOnlyRounds = 0
|
||||||
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
|
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
|
||||||
continue
|
continue
|
||||||
@@ -789,7 +784,7 @@ abstract class SessionOrchestrator(
|
|||||||
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
|
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
|
||||||
if (allRejected) {
|
if (allRejected) {
|
||||||
consecutiveRejectedRounds++
|
consecutiveRejectedRounds++
|
||||||
if (consecutiveRejectedRounds >= REJECTION_LOOP_NUDGE_THRESHOLD) {
|
if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
|
||||||
consecutiveRejectedRounds = 0
|
consecutiveRejectedRounds = 0
|
||||||
inferenceResult = pushBack(
|
inferenceResult = pushBack(
|
||||||
"STOP. Your last tool calls were all rejected and retrying the same paths will " +
|
"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)
|
val priorRounds = eventStore.read(sessionId)
|
||||||
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
|
.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 requestId = ClarificationRequestId(UUID.randomUUID().toString())
|
||||||
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
|
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
|
||||||
@@ -1814,7 +1809,7 @@ abstract class SessionOrchestrator(
|
|||||||
appendLine("## Repo layout (what exists — use file_read for content)")
|
appendLine("## Repo layout (what exists — use file_read for content)")
|
||||||
byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
|
byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
|
||||||
val names = files.map { it.substringAfterLast('/') }
|
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 more = names.size - shown.size
|
||||||
val suffix = if (more > 0) ", …(+$more more)" else ""
|
val suffix = if (more > 0) ", …(+$more more)" else ""
|
||||||
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
|
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
|
||||||
@@ -1915,7 +1910,7 @@ abstract class SessionOrchestrator(
|
|||||||
|
|
||||||
val retriever = repoKnowledgeRetriever
|
val retriever = repoKnowledgeRetriever
|
||||||
?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId)
|
?: 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 ->
|
.getOrElse { e ->
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
|
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
|
||||||
@@ -1942,7 +1937,7 @@ abstract class SessionOrchestrator(
|
|||||||
val docs = map.entries
|
val docs = map.entries
|
||||||
.filter { isDocPath(it.path) }
|
.filter { isDocPath(it.path) }
|
||||||
.sortedByDescending { it.score }
|
.sortedByDescending { it.score }
|
||||||
.take(DOCS_CATALOG_MAX)
|
.take(tuning.docsCatalogMax)
|
||||||
if (docs.isEmpty()) return emptyList()
|
if (docs.isEmpty()) return emptyList()
|
||||||
val content = buildString {
|
val content = buildString {
|
||||||
appendLine("## Docs available (file_read to open — do not assume contents)")
|
appendLine("## Docs available (file_read to open — do not assume contents)")
|
||||||
@@ -2787,11 +2782,11 @@ abstract class SessionOrchestrator(
|
|||||||
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
|
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
|
||||||
.count { it.stageId == stageId && it.blocked }
|
.count { it.stageId == stageId && it.blocked }
|
||||||
val blockingFinding = outcome.findings.firstOrNull {
|
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 &&
|
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
|
||||||
blockingFinding != null &&
|
blockingFinding != null &&
|
||||||
priorBlocks < REVIEW_BLOCK_RETRY_CAP
|
priorBlocks < tuning.reviewBlockRetryCap
|
||||||
|
|
||||||
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
|
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
|
||||||
|
|
||||||
@@ -2884,7 +2879,7 @@ abstract class SessionOrchestrator(
|
|||||||
return if (errors.isEmpty()) {
|
return if (errors.isEmpty()) {
|
||||||
"validation failed"
|
"validation failed"
|
||||||
} else {
|
} else {
|
||||||
"validation failed: " + errors.take(MAX_FEEDBACK_ISSUES).joinToString("; ")
|
"validation failed: " + errors.take(tuning.maxFeedbackIssues).joinToString("; ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user