feat(context): required/optional bucket split, budget ceilings, grounded handoffs
Context rework from the 2026-07-05 context-poisoning diagnosis (session 6ca236c4 read-loop): - ContextBucket on ContextEntry: REQUIRED (task, constraints, needed artifacts, failure feedback) is never pruned/compressed/dropped; the build fails fast (RequiredContextOverflowException -> non-retryable stage failure) if required alone exceeds the stage budget. - OptionalCeilings: per-sourceType token caps for the optional block (repoMap 1200, relevantFiles 1600, journal 800, profiles 400/600, vocabulary 400), tuned for the 9-26B tier at 16k; enforced by truncation (journal keeps its tail, others their head). - Repo map is now a structural 'what exists' listing (alphabetical, per-directory, no recency ranking, no symbols); .md/docs paths are gated behind an explicit docs mention in the stage prompt — retrieval hits remain the only other path for docs to enter context. - file_written needs render as a manifest of ALL files the producing stage wrote (projected from ArtifactContentStored -> ToolInvocation -> FileWritten events), fixing the last-write-wins collapse; content stays lazy behind file_read. - Decision journal folds a stage's RETRY records into one summary line once a later transition leaves that stage (render-time only). - Dedupe retrieved-file lines in buildRelevantFilesEntry.
This commit is contained in:
+57
-5
@@ -15,10 +15,12 @@ import com.correx.core.context.compression.ToMeMerger
|
|||||||
import com.correx.core.context.compression.TokenPruner
|
import com.correx.core.context.compression.TokenPruner
|
||||||
import com.correx.core.context.compression.UniformRelevanceScorer
|
import com.correx.core.context.compression.UniformRelevanceScorer
|
||||||
import com.correx.core.context.model.CompressionMetadata
|
import com.correx.core.context.model.CompressionMetadata
|
||||||
|
import com.correx.core.context.model.ContextBucket
|
||||||
import com.correx.core.context.model.ContextEntry
|
import com.correx.core.context.model.ContextEntry
|
||||||
import com.correx.core.context.model.ContextLayer
|
import com.correx.core.context.model.ContextLayer
|
||||||
import com.correx.core.context.model.ContextPack
|
import com.correx.core.context.model.ContextPack
|
||||||
import com.correx.core.context.model.EntryRole
|
import com.correx.core.context.model.EntryRole
|
||||||
|
import com.correx.core.context.model.OptionalCeilings
|
||||||
import com.correx.core.context.model.TokenBudget
|
import com.correx.core.context.model.TokenBudget
|
||||||
import com.correx.core.events.types.ContextEntryId
|
import com.correx.core.events.types.ContextEntryId
|
||||||
import com.correx.core.events.types.ContextPackId
|
import com.correx.core.events.types.ContextPackId
|
||||||
@@ -43,9 +45,13 @@ class DefaultContextPackBuilder(
|
|||||||
private val tokenPruner: TokenPruner = NoOpTokenPruner,
|
private val tokenPruner: TokenPruner = NoOpTokenPruner,
|
||||||
private val toMeMerger: ToMeMerger = ToMeMerger(),
|
private val toMeMerger: ToMeMerger = ToMeMerger(),
|
||||||
private val relevanceScorer: RelevanceScorer = UniformRelevanceScorer,
|
private val relevanceScorer: RelevanceScorer = UniformRelevanceScorer,
|
||||||
|
private val ceilings: OptionalCeilings = OptionalCeilings(),
|
||||||
) : ContextPackBuilder {
|
) : ContextPackBuilder {
|
||||||
|
|
||||||
private val neverDropSourceTypes = setOf("steeringNote", "eventHistory", "decisionJournal", "factSheet")
|
// decisionJournal is deliberately NOT here anymore: the journal is OPTIONAL-bucket, capped
|
||||||
|
// by OptionalCeilings. Pinning is otherwise bucket-driven (ContextBucket.REQUIRED); this set
|
||||||
|
// only covers entries built outside the orchestrator that never stamp buckets.
|
||||||
|
private val neverDropSourceTypes = setOf("steeringNote", "eventHistory", "factSheet")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CHARS_PER_TOKEN = 4
|
const val CHARS_PER_TOKEN = 4
|
||||||
@@ -95,15 +101,34 @@ class DefaultContextPackBuilder(
|
|||||||
if (facts.isEmpty()) merged else merged + factSheetEntry(factSheet.render(facts))
|
if (facts.isEmpty()) merged else merged + factSheetEntry(factSheet.render(facts))
|
||||||
} else merged
|
} else merged
|
||||||
|
|
||||||
val (pinned, compressible) = withFactSheet.partition { it.sourceType in neverDropSourceTypes }
|
val (pinned, compressible) = withFactSheet.partition {
|
||||||
|
it.bucket == ContextBucket.REQUIRED || it.sourceType in neverDropSourceTypes
|
||||||
|
}
|
||||||
|
// Required entries are never trimmed: if they alone blow the budget, fail fast rather
|
||||||
|
// than ship a silently truncated instruction set.
|
||||||
|
val required = withFactSheet.filter { it.bucket == ContextBucket.REQUIRED }
|
||||||
|
val requiredTokens = required.sumOf { it.tokenEstimate }
|
||||||
|
if (requiredTokens > budget.limit) {
|
||||||
|
throw RequiredContextOverflowException(
|
||||||
|
requiredTokens = requiredTokens,
|
||||||
|
budgetLimit = budget.limit,
|
||||||
|
breakdown = required.groupBy { it.sourceType }
|
||||||
|
.mapValues { (_, group) -> group.sumOf { it.tokenEstimate } },
|
||||||
|
)
|
||||||
|
}
|
||||||
val pinnedTokens = pinned.sumOf { it.tokenEstimate }
|
val pinnedTokens = pinned.sumOf { it.tokenEstimate }
|
||||||
var remainingTokens = (budget.limit - pinnedTokens).coerceAtLeast(0)
|
var remainingTokens = (budget.limit - pinnedTokens).coerceAtLeast(0)
|
||||||
|
|
||||||
|
// Optional per-type ceilings are enforced by truncation here, not left to the group
|
||||||
|
// compressor: several strategies (EventHistory) pass entries through untouched, and these
|
||||||
|
// feeds are single large entries — dropping whole entries would lose them entirely.
|
||||||
|
val ceilinged = compressible.map(::applyCeiling)
|
||||||
|
|
||||||
// Query-conditioned selection: when a real relevance scorer is wired, reorder each freeform
|
// Query-conditioned selection: when a real relevance scorer is wired, reorder each freeform
|
||||||
// group so the least-relevant turns sit at the front (dropped first) and the most-relevant
|
// group so the least-relevant turns sit at the front (dropped first) and the most-relevant
|
||||||
// land at the tail the Conversation strategy keeps. Final layering re-sorts by ordinal, so
|
// land at the tail the Conversation strategy keeps. Final layering re-sorts by ordinal, so
|
||||||
// chronological render order is unaffected — only *what survives* the budget changes.
|
// chronological render order is unaffected — only *what survives* the budget changes.
|
||||||
val relevanceRanked = rankByRelevance(compressible, currentQuery(withFactSheet))
|
val relevanceRanked = rankByRelevance(ceilinged, currentQuery(withFactSheet))
|
||||||
|
|
||||||
// Stage 9 (BUDGET_ALLOCATOR_STRICT): compress structured groups before freeform so a long
|
// Stage 9 (BUDGET_ALLOCATOR_STRICT): compress structured groups before freeform so a long
|
||||||
// conversation can't starve tool logs / artifacts out of the window.
|
// conversation can't starve tool logs / artifacts out of the window.
|
||||||
@@ -116,7 +141,12 @@ class DefaultContextPackBuilder(
|
|||||||
val compressed = orderedGroups.flatMap { (sourceType, group) ->
|
val compressed = orderedGroups.flatMap { (sourceType, group) ->
|
||||||
val strategy = strategyFor(sourceType)
|
val strategy = strategyFor(sourceType)
|
||||||
strategiesUsed += strategy::class.simpleName ?: "Unknown"
|
strategiesUsed += strategy::class.simpleName ?: "Unknown"
|
||||||
val result = compressor.compress(group, TokenBudget(limit = remainingTokens.coerceAtLeast(0)), strategy)
|
// Optional groups run under their per-type ceiling so no single feed (repo map,
|
||||||
|
// journal, profile) can crowd out the rest of the optional block.
|
||||||
|
val groupLimit = ceilings.capFor(sourceType)
|
||||||
|
?.coerceAtMost(remainingTokens.coerceAtLeast(0))
|
||||||
|
?: remainingTokens.coerceAtLeast(0)
|
||||||
|
val result = compressor.compress(group, TokenBudget(limit = groupLimit), strategy)
|
||||||
remainingTokens -= result.sumOf { it.tokenEstimate }
|
remainingTokens -= result.sumOf { it.tokenEstimate }
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -151,6 +181,8 @@ class DefaultContextPackBuilder(
|
|||||||
.map { it.ordinal }.sortedDescending()
|
.map { it.ordinal }.sortedDescending()
|
||||||
val tier0 = if (tierSplit) freeformOrdinals.take(TIER0_TURNS).toSet() else emptySet()
|
val tier0 = if (tierSplit) freeformOrdinals.take(TIER0_TURNS).toSet() else emptySet()
|
||||||
return entries.map { entry ->
|
return entries.map { entry ->
|
||||||
|
// Required entries are exempt from lossy pruning by definition of the bucket.
|
||||||
|
if (entry.bucket == ContextBucket.REQUIRED) return@map entry
|
||||||
val isFreeform = classifier.classify(entry) == ContextClass.FREEFORM && entry.ordinal !in tier0
|
val isFreeform = classifier.classify(entry) == ContextClass.FREEFORM && entry.ordinal !in tier0
|
||||||
val isLargeDoc = entry.sourceType in DOC_SOURCE_TYPES && entry.content.length >= DOC_MIN_CHARS
|
val isLargeDoc = entry.sourceType in DOC_SOURCE_TYPES && entry.content.length >= DOC_MIN_CHARS
|
||||||
when {
|
when {
|
||||||
@@ -162,10 +194,30 @@ class DefaultContextPackBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates an OPTIONAL entry to its per-sourceType token ceiling. The decision journal is
|
||||||
|
* chronological (newest decisions at the tail), so it keeps its tail; every other feed keeps
|
||||||
|
* its head (headers/most-structural lines first).
|
||||||
|
*/
|
||||||
|
private fun applyCeiling(entry: ContextEntry): ContextEntry {
|
||||||
|
val cap = ceilings.capFor(entry.sourceType) ?: return entry
|
||||||
|
if (entry.tokenEstimate <= cap) return entry
|
||||||
|
val trimmedTailMarker = "…(older entries trimmed)\n"
|
||||||
|
val trimmedHeadMarker = "\n…(trimmed to budget)"
|
||||||
|
val content = if (entry.sourceType == "decisionJournal") {
|
||||||
|
trimmedTailMarker + entry.content.takeLast(cap * CHARS_PER_TOKEN - trimmedTailMarker.length)
|
||||||
|
} else {
|
||||||
|
entry.content.take(cap * CHARS_PER_TOKEN - trimmedHeadMarker.length) + trimmedHeadMarker
|
||||||
|
}
|
||||||
|
return entry.copy(content = content, tokenEstimate = estimateTokens(content))
|
||||||
|
}
|
||||||
|
|
||||||
private fun spansOf(entry: ContextEntry) = tagger.protectedSpans(entry.content)
|
private fun spansOf(entry: ContextEntry) = tagger.protectedSpans(entry.content)
|
||||||
|
|
||||||
private fun applyToMe(entries: List<ContextEntry>): List<ContextEntry> {
|
private fun applyToMe(entries: List<ContextEntry>): List<ContextEntry> {
|
||||||
val (freeform, rest) = entries.partition { classifier.classify(it) == ContextClass.FREEFORM }
|
val (freeform, rest) = entries.partition {
|
||||||
|
it.bucket != ContextBucket.REQUIRED && classifier.classify(it) == ContextClass.FREEFORM
|
||||||
|
}
|
||||||
return (rest + toMeMerger.merge(freeform)).sortedBy { it.ordinal }
|
return (rest + toMeMerger.merge(freeform)).sortedBy { it.ordinal }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.correx.core.context.builder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The REQUIRED bucket alone (task, constraints, loaded inputs, failures) does not fit the
|
||||||
|
* stage's token budget. Required context is never trimmed, so the only correct outcome is
|
||||||
|
* failing the build loudly — a silently truncated instruction is worse than no run.
|
||||||
|
*/
|
||||||
|
class RequiredContextOverflowException(
|
||||||
|
val requiredTokens: Int,
|
||||||
|
val budgetLimit: Int,
|
||||||
|
val breakdown: Map<String, Int>,
|
||||||
|
) : IllegalStateException(
|
||||||
|
"Required context ($requiredTokens tokens) exceeds the stage budget ($budgetLimit); " +
|
||||||
|
"by sourceType: " + breakdown.entries.joinToString(", ") { "${it.key}=${it.value}" },
|
||||||
|
)
|
||||||
@@ -5,6 +5,15 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
enum class EntryRole { SYSTEM, USER, ASSISTANT, TOOL }
|
enum class EntryRole { SYSTEM, USER, ASSISTANT, TOOL }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget class of an entry. REQUIRED entries (task, constraints, loaded inputs, failures) are
|
||||||
|
* never lossily compressed or dropped — if they alone exceed the budget the build fails fast
|
||||||
|
* instead of silently trimming. OPTIONAL entries (repo map, retrieval, journal, profiles) share
|
||||||
|
* whatever budget remains, each source type under its own ceiling.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class ContextBucket { REQUIRED, OPTIONAL }
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ContextEntry(
|
data class ContextEntry(
|
||||||
val id: ContextEntryId,
|
val id: ContextEntryId,
|
||||||
@@ -20,4 +29,7 @@ data class ContextEntry(
|
|||||||
// Entries built outside that builder (e.g. router chat) leave this at 0 and fall
|
// Entries built outside that builder (e.g. router chat) leave this at 0 and fall
|
||||||
// back to layer-priority ordering in PromptRenderer.
|
// back to layer-priority ordering in PromptRenderer.
|
||||||
val ordinal: Int = 0,
|
val ordinal: Int = 0,
|
||||||
|
// Default OPTIONAL keeps prior behavior for callers that don't stamp buckets (router chat);
|
||||||
|
// the orchestrator stamps REQUIRED on task/constraint/input/failure entries.
|
||||||
|
val bucket: ContextBucket = ContextBucket.OPTIONAL,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.correx.core.context.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-sourceType token ceilings for OPTIONAL bucket entries. Defaults are tuned for the
|
||||||
|
* 9–26B local-model tier running a 16k prompt budget: the optional block as a whole stays
|
||||||
|
* under ~5k tokens so the required block (task, constraints, loaded inputs, failures) and
|
||||||
|
* the running transcript always dominate the window.
|
||||||
|
*
|
||||||
|
* A source type absent from [caps] has no per-type ceiling — it is still bounded by the
|
||||||
|
* remaining post-required budget like everything else.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class OptionalCeilings(
|
||||||
|
val caps: Map<String, Int> = DEFAULT_CAPS,
|
||||||
|
) {
|
||||||
|
fun capFor(sourceType: String): Int? = caps[sourceType]
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT_CAPS: Map<String, Int> = mapOf(
|
||||||
|
"repoMap" to 1200,
|
||||||
|
"relevantFiles" to 1600,
|
||||||
|
"decisionJournal" to 800,
|
||||||
|
"operatorProfile" to 400,
|
||||||
|
"projectProfile" to 600,
|
||||||
|
"artifactKindVocabulary" to 400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.correx.core.journal
|
package com.correx.core.journal
|
||||||
|
|
||||||
import com.correx.core.journal.model.DecisionJournalState
|
import com.correx.core.journal.model.DecisionJournalState
|
||||||
|
import com.correx.core.journal.model.DecisionKind
|
||||||
|
import com.correx.core.journal.model.DecisionRecord
|
||||||
|
|
||||||
class DecisionJournalRenderer(private val keepLast: Int = 40) {
|
class DecisionJournalRenderer(private val keepLast: Int = 40) {
|
||||||
fun render(state: DecisionJournalState, summaryText: String? = null): String {
|
fun render(state: DecisionJournalState, summaryText: String? = null): String {
|
||||||
if (state.records.isEmpty() && summaryText == null) return ""
|
if (state.records.isEmpty() && summaryText == null) return ""
|
||||||
|
val visible = foldResolvedRetries(state.records)
|
||||||
return buildString {
|
return buildString {
|
||||||
appendLine("## Decision history (chronological — ground truth)")
|
appendLine("## Decision history (chronological — ground truth)")
|
||||||
if (summaryText != null) {
|
if (summaryText != null) {
|
||||||
@@ -13,7 +16,34 @@ class DecisionJournalRenderer(private val keepLast: Int = 40) {
|
|||||||
appendLine("+${state.lowSalienceOmittedCount} routine transitions/retries omitted")
|
appendLine("+${state.lowSalienceOmittedCount} routine transitions/retries omitted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.records.takeLast(keepLast).forEach { appendLine("- [${it.kind}] ${it.summary}") }
|
visible.takeLast(keepLast).forEach { appendLine("- [${it.kind}] ${it.summary}") }
|
||||||
}.trimEnd()
|
}.trimEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RETRY record is resolved once a later TRANSITION leaves its stage — the failure was
|
||||||
|
* overcome and its details are noise for every subsequent stage. Fold each resolved stage's
|
||||||
|
* retries into one summary line at the position of the first retry; unresolved (still-live)
|
||||||
|
* retries render verbatim. Render-time only: the journal state and event log are untouched.
|
||||||
|
*/
|
||||||
|
private fun foldResolvedRetries(records: List<DecisionRecord>): List<DecisionRecord> {
|
||||||
|
val resolvedAt = records
|
||||||
|
.filter { it.kind == DecisionKind.TRANSITION && it.fromStageId != null }
|
||||||
|
.groupBy { it.fromStageId!! }
|
||||||
|
.mapValues { (_, transitions) -> transitions.maxOf { it.sequence } }
|
||||||
|
val foldedStages = mutableSetOf<String>()
|
||||||
|
return records.mapNotNull { record ->
|
||||||
|
val stage = record.stageId
|
||||||
|
val resolution = stage?.let { resolvedAt[it] }
|
||||||
|
if (record.kind != DecisionKind.RETRY || resolution == null || record.sequence > resolution) {
|
||||||
|
return@mapNotNull record
|
||||||
|
}
|
||||||
|
if (stage in foldedStages) return@mapNotNull null
|
||||||
|
foldedStages += stage
|
||||||
|
val count = records.count {
|
||||||
|
it.kind == DecisionKind.RETRY && it.stageId == stage && it.sequence <= resolution
|
||||||
|
}
|
||||||
|
record.copy(summary = "$stage: $count retr${if (count == 1) "y" else "ies"}, resolved — stage completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class DefaultDecisionJournalReducer : DecisionJournalReducer {
|
|||||||
kind = DecisionKind.TRANSITION,
|
kind = DecisionKind.TRANSITION,
|
||||||
summary = "Advanced ${p.from.value} → ${p.to.value} (${p.transitionId.value})",
|
summary = "Advanced ${p.from.value} → ${p.to.value} (${p.transitionId.value})",
|
||||||
stageId = p.to.value,
|
stageId = p.to.value,
|
||||||
|
fromStageId = p.from.value,
|
||||||
)
|
)
|
||||||
is RetryAttemptedEvent -> DecisionRecord(
|
is RetryAttemptedEvent -> DecisionRecord(
|
||||||
sequence = event.sessionSequence,
|
sequence = event.sessionSequence,
|
||||||
|
|||||||
@@ -25,4 +25,8 @@ data class DecisionRecord(
|
|||||||
val kind: DecisionKind,
|
val kind: DecisionKind,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val stageId: String? = null,
|
val stageId: String? = null,
|
||||||
|
// TRANSITION only: the stage the workflow advanced FROM. Lets the renderer tell that a
|
||||||
|
// stage's earlier RETRY records are resolved (the stage was eventually left successfully)
|
||||||
|
// and fold them to a single summary line.
|
||||||
|
val fromStageId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,4 +77,44 @@ class DecisionJournalRendererTest {
|
|||||||
assertTrue(result.contains("+2 routine transitions/retries omitted"))
|
assertTrue(result.contains("+2 routine transitions/retries omitted"))
|
||||||
assertFalse(result.contains("- ["))
|
assertFalse(result.contains("- ["))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolved retries fold to a single summary line`() {
|
||||||
|
val state = DecisionJournalState(
|
||||||
|
records = listOf(
|
||||||
|
record(1, DecisionKind.RETRY, "Retry 1/3 of scaffold: did not produce").copy(stageId = "scaffold"),
|
||||||
|
record(2, DecisionKind.RETRY, "Retry 2/3 of scaffold: did not produce").copy(stageId = "scaffold"),
|
||||||
|
record(3, DecisionKind.TRANSITION, "Advanced scaffold → hook (t1)")
|
||||||
|
.copy(stageId = "hook", fromStageId = "scaffold"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = renderer.render(state)
|
||||||
|
assertFalse(result.contains("did not produce"), "resolved retry detail must not render")
|
||||||
|
assertTrue(result.contains("- [RETRY] scaffold: 2 retries, resolved — stage completed"))
|
||||||
|
assertTrue(result.contains("- [TRANSITION] Advanced scaffold → hook"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unresolved retries render verbatim`() {
|
||||||
|
val state = DecisionJournalState(
|
||||||
|
records = listOf(
|
||||||
|
record(1, DecisionKind.RETRY, "Retry 1/3 of hook: did not produce").copy(stageId = "hook"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = renderer.render(state)
|
||||||
|
assertTrue(result.contains("- [RETRY] Retry 1/3 of hook: did not produce"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retries after the stage was last left are not folded`() {
|
||||||
|
val state = DecisionJournalState(
|
||||||
|
records = listOf(
|
||||||
|
record(1, DecisionKind.TRANSITION, "Advanced scaffold → hook (t1)")
|
||||||
|
.copy(stageId = "hook", fromStageId = "scaffold"),
|
||||||
|
record(2, DecisionKind.RETRY, "Retry 1/3 of scaffold: regression").copy(stageId = "scaffold"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = renderer.render(state)
|
||||||
|
assertTrue(result.contains("- [RETRY] Retry 1/3 of scaffold: regression"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ fun buildArtifactKindVocabularyEntry(kinds: Collection<ArtifactKind>): ContextEn
|
|||||||
fun buildRelevantFilesEntry(hits: List<RepoKnowledgeHit>): ContextEntry {
|
fun buildRelevantFilesEntry(hits: List<RepoKnowledgeHit>): ContextEntry {
|
||||||
val content = buildString {
|
val content = buildString {
|
||||||
append("## Relevant files (retrieved by semantic similarity)\n")
|
append("## Relevant files (retrieved by semantic similarity)\n")
|
||||||
hits.forEach { append("- ").append(it.text).append("\n") }
|
hits.map { it.text }.distinct().forEach { append("- ").append(it).append("\n") }
|
||||||
}.trimEnd()
|
}.trimEnd()
|
||||||
return ContextEntry(
|
return ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
|||||||
+279
-28
@@ -20,6 +20,8 @@ import com.correx.core.artifacts.kind.ProcessResultArtifact
|
|||||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
import com.correx.core.context.builder.ContextPackBuilder
|
import com.correx.core.context.builder.ContextPackBuilder
|
||||||
|
import com.correx.core.context.builder.RequiredContextOverflowException
|
||||||
|
import com.correx.core.context.model.ContextBucket
|
||||||
import com.correx.core.context.model.ContextEntry
|
import com.correx.core.context.model.ContextEntry
|
||||||
import com.correx.core.context.model.ContextLayer
|
import com.correx.core.context.model.ContextLayer
|
||||||
import com.correx.core.context.model.ContextPack
|
import com.correx.core.context.model.ContextPack
|
||||||
@@ -29,7 +31,11 @@ import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
|||||||
import com.correx.core.events.events.RefinementIterationEvent
|
import com.correx.core.events.events.RefinementIterationEvent
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||||
|
import com.correx.core.events.events.FileWrittenEvent
|
||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactRepairFailedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactRepairResolvedEvent
|
||||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
@@ -128,6 +134,8 @@ import com.correx.core.transitions.graph.StageConfig
|
|||||||
import com.correx.core.transitions.graph.WorkflowGraph
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
import com.correx.core.transitions.resolution.TransitionDecision
|
import com.correx.core.transitions.resolution.TransitionDecision
|
||||||
import com.correx.core.transitions.resolution.TransitionResolver
|
import com.correx.core.transitions.resolution.TransitionResolver
|
||||||
|
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
|
||||||
|
import com.correx.core.validation.artifact.ArtifactFailure
|
||||||
import com.correx.core.validation.model.ValidationContext
|
import com.correx.core.validation.model.ValidationContext
|
||||||
import com.correx.core.validation.model.ValidationReport
|
import com.correx.core.validation.model.ValidationReport
|
||||||
import com.correx.core.validation.model.ValidationSeverity
|
import com.correx.core.validation.model.ValidationSeverity
|
||||||
@@ -165,6 +173,31 @@ 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_INJECT_TOP_K = 30
|
||||||
|
private const val REPO_MAP_FILES_PER_DIR = 8
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
private val DOCS_REQUEST_REGEX =
|
||||||
|
Regex("""\b(docs?|readme|adr|changelog|documentation|markdown)\b|\.md\b""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private fun isDocPath(path: String): Boolean =
|
||||||
|
path.endsWith(".md", ignoreCase = true) ||
|
||||||
|
path.split('/').any { it.equals("docs", ignoreCase = true) || it.equals("doc", ignoreCase = true) }
|
||||||
|
|
||||||
|
// Source types whose entries land in the REQUIRED context bucket (see stampBuckets).
|
||||||
|
private val REQUIRED_SOURCE_TYPES = setOf(
|
||||||
|
"systemPrompt",
|
||||||
|
"agentPrompt",
|
||||||
|
"agentInstructions",
|
||||||
|
"claimedTask",
|
||||||
|
"schemaInstruction",
|
||||||
|
"steeringNote",
|
||||||
|
"clarificationAnswer",
|
||||||
|
"rejectionFeedback",
|
||||||
|
"retryFeedback",
|
||||||
|
"neededArtifact",
|
||||||
|
"criticFeedback",
|
||||||
|
)
|
||||||
|
|
||||||
// HTTP statuses that are transient despite being 4xx (F-002 retry classification).
|
// HTTP statuses that are transient despite being 4xx (F-002 retry classification).
|
||||||
private const val HTTP_TIMEOUT = 408
|
private const val HTTP_TIMEOUT = 408
|
||||||
@@ -236,6 +269,10 @@ abstract class SessionOrchestrator(
|
|||||||
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
|
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
|
||||||
protected val artifactContentCache: ConcurrentHashMap<String, String> = ConcurrentHashMap()
|
protected val artifactContentCache: ConcurrentHashMap<String, String> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
/** Deterministic extraction/repair ladder for near-miss LLM artifact text (prose-wrapped JSON,
|
||||||
|
* code fences, trailing commas). Pure — recomputes on replay, records no events. */
|
||||||
|
private val artifactExtractionPipeline = ArtifactExtractionPipeline()
|
||||||
|
|
||||||
abstract suspend fun run(
|
abstract suspend fun run(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
@@ -452,18 +489,27 @@ abstract class SessionOrchestrator(
|
|||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
var accumulatedEntries =
|
var accumulatedEntries = stampBuckets(
|
||||||
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||||
clarificationEntries + retryFeedbackEntries
|
clarificationEntries + retryFeedbackEntries,
|
||||||
val contextPack = contextPackBuilder.build(
|
)
|
||||||
|
val contextPack = runCatching {
|
||||||
|
contextPackBuilder.build(
|
||||||
id = ContextPackId(UUID.randomUUID().toString()),
|
id = ContextPackId(UUID.randomUUID().toString()),
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
stageId = stageId,
|
stageId = stageId,
|
||||||
entries = accumulatedEntries,
|
entries = accumulatedEntries,
|
||||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||||
)
|
)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
if (e !is RequiredContextOverflowException) throw e
|
||||||
|
// Required context doesn't fit — retrying with the same inputs can't succeed.
|
||||||
|
log.error("[Orchestrator] stage={}: {}", stageId.value, e.message)
|
||||||
|
return StageExecutionResult.Failure(e.message ?: "required context overflow", retryable = false)
|
||||||
|
}
|
||||||
emitContextTruncationIfNeeded(sessionId, stageId, contextPack)
|
emitContextTruncationIfNeeded(sessionId, stageId, contextPack)
|
||||||
|
|
||||||
var currentContext = contextPack
|
var currentContext = contextPack
|
||||||
@@ -651,11 +697,36 @@ abstract class SessionOrchestrator(
|
|||||||
// (e.g. ArtifactFieldEquals on a reviewer's verdict) can read it. An emit_artifact
|
// (e.g. ArtifactFieldEquals on a reviewer's verdict) can read it. An emit_artifact
|
||||||
// tool call (llmArtifactOverride) supplies the content directly; otherwise the
|
// tool call (llmArtifactOverride) supplies the content directly; otherwise the
|
||||||
// artifact is the final assistant message text.
|
// artifact is the final assistant message text.
|
||||||
val artifactText = llmArtifactOverride ?: inferenceResult.response.text
|
val rawArtifactText = llmArtifactOverride ?: inferenceResult.response.text
|
||||||
val artifactHash = llmArtifactOverride
|
val slot = llmEmittedSlots.firstOrNull()
|
||||||
?.let { artifactStore.put(it.toByteArray()) }
|
// Artifact-emission ladder: deterministic repair (prose/fences/trailing commas), then
|
||||||
?: inferenceResult.responseArtifactId
|
// — rarely, gated by the failure classifier — one grammar-constrained LLM-repair rung,
|
||||||
llmEmittedSlots.firstOrNull()?.let { slot ->
|
// before the text is cached/validated. On a hard failure the policy decision
|
||||||
|
// short-circuits the stage. Only the nondeterministic rung records events (spec 2026-07-04 §6).
|
||||||
|
val artifactText = if (slot != null) {
|
||||||
|
when (val res = artifactExtractionPipeline.run(rawArtifactText, slot.kind.deriveJsonSchema())) {
|
||||||
|
is ArtifactExtractionPipeline.ExtractionResult.Resolved -> {
|
||||||
|
if (res.repaired) {
|
||||||
|
emitArtifactRepairAttempted(sessionId, stageId, slot, ArtifactFailure.FORMATTING, "DETERMINISTIC")
|
||||||
|
emitArtifactRepairResolved(sessionId, stageId, slot, res.canonicalJson.toString())
|
||||||
|
}
|
||||||
|
res.canonicalJson.toString()
|
||||||
|
}
|
||||||
|
is ArtifactExtractionPipeline.ExtractionResult.Unresolved ->
|
||||||
|
when (val ladder = repairArtifact(sessionId, stageId, slot, res, stageConfig, effectives, config.stageTimeoutMs)) {
|
||||||
|
is ArtifactLadderOutcome.Text -> ladder.text
|
||||||
|
is ArtifactLadderOutcome.Reject -> return ladder.failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawArtifactText
|
||||||
|
}
|
||||||
|
val artifactHash = if (artifactText != rawArtifactText || llmArtifactOverride != null) {
|
||||||
|
artifactStore.put(artifactText.toByteArray())
|
||||||
|
} else {
|
||||||
|
inferenceResult.responseArtifactId
|
||||||
|
}
|
||||||
|
slot?.let { slot ->
|
||||||
artifactContentCache["${sessionId.value}:${slot.name.value}"] = artifactText
|
artifactContentCache["${sessionId.value}:${slot.name.value}"] = artifactText
|
||||||
artifactHash?.let { hash ->
|
artifactHash?.let { hash ->
|
||||||
emit(
|
emit(
|
||||||
@@ -690,6 +761,122 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed interface ArtifactLadderOutcome {
|
||||||
|
data class Text(val text: String) : ArtifactLadderOutcome
|
||||||
|
data class Reject(val failure: StageExecutionResult.Failure) : ArtifactLadderOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an [ArtifactExtractionPipeline.ExtractionResult.Unresolved]: attempts one grammar-constrained
|
||||||
|
* LLM-repair rung (only for SCHEMA / MISSING_INFO — the classifier gates the expensive rung), then
|
||||||
|
* consults the policy [decide] to map a hard failure onto an orchestrator action. Records the §6 events.
|
||||||
|
*/
|
||||||
|
private suspend fun repairArtifact(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
slot: TypedArtifactSlot,
|
||||||
|
unresolved: ArtifactExtractionPipeline.ExtractionResult.Unresolved,
|
||||||
|
stageConfig: StageConfig,
|
||||||
|
effectives: RunEffectives,
|
||||||
|
timeoutMs: Long,
|
||||||
|
): ArtifactLadderOutcome {
|
||||||
|
val schema = slot.kind.deriveJsonSchema()
|
||||||
|
val eligible = unresolved.classification in setOf(ArtifactFailure.SCHEMA, ArtifactFailure.MISSING_INFO)
|
||||||
|
val bestCandidate = unresolved.bestCandidate
|
||||||
|
if (eligible && bestCandidate != null && !isCancelled(sessionId)) {
|
||||||
|
emitArtifactRepairAttempted(sessionId, stageId, slot, unresolved.classification, "LLM")
|
||||||
|
val repaired = runArtifactRepairInference(
|
||||||
|
sessionId, stageId, slot, bestCandidate, stageConfig, effectives, timeoutMs,
|
||||||
|
)
|
||||||
|
val reRun = repaired?.let { artifactExtractionPipeline.run(it, schema) }
|
||||||
|
if (reRun is ArtifactExtractionPipeline.ExtractionResult.Resolved) {
|
||||||
|
val json = reRun.canonicalJson.toString()
|
||||||
|
emitArtifactRepairResolved(sessionId, stageId, slot, json)
|
||||||
|
return ArtifactLadderOutcome.Text(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val decision = decide(unresolved.classification, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
|
||||||
|
emit(
|
||||||
|
sessionId,
|
||||||
|
ArtifactRepairFailedEvent(
|
||||||
|
artifactId = slot.name,
|
||||||
|
classification = unresolved.classification.name,
|
||||||
|
decision = decision::class.simpleName ?: "Unknown",
|
||||||
|
reason = unresolved.detail,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// ponytail: RetryProducer / RegenerateArtifactOnly / EscalateHuman all fold into a retryable
|
||||||
|
// failure — the existing retryStageOrFail loop re-runs the full producer (incl. the tools-less
|
||||||
|
// emission nudge) and surfaces exhaustion to the operator. A dedicated regenerate-only /
|
||||||
|
// approval-pause mid-artifact path is deferred until one is shown to help.
|
||||||
|
return ArtifactLadderOutcome.Reject(
|
||||||
|
StageExecutionResult.Failure(
|
||||||
|
"artifact repair failed (${unresolved.classification}): ${unresolved.detail}",
|
||||||
|
retryable = decision != ArtifactPolicyDecision.Abort,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One tools-less, grammar-constrained inference asking the model to repair its own malformed artifact. */
|
||||||
|
private suspend fun runArtifactRepairInference(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
slot: TypedArtifactSlot,
|
||||||
|
bestCandidate: String,
|
||||||
|
stageConfig: StageConfig,
|
||||||
|
effectives: RunEffectives,
|
||||||
|
timeoutMs: Long,
|
||||||
|
): String? {
|
||||||
|
val schema = slot.kind.deriveJsonSchema()
|
||||||
|
val schemaJson = Json.encodeToString(JsonSchema.serializer(), schema)
|
||||||
|
val prompt = "The previous output for the '${slot.name.value}' artifact was malformed and did not " +
|
||||||
|
"match the required schema.\n\nMalformed output:\n$bestCandidate\n\nReturn ONLY a single JSON " +
|
||||||
|
"object matching this schema. Do not add fields, prose, or code fences.\nSchema: $schemaJson"
|
||||||
|
val entry = ContextEntry(
|
||||||
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
layer = ContextLayer.L2,
|
||||||
|
sourceType = "artifactRepair",
|
||||||
|
sourceId = UUID.randomUUID().toString(),
|
||||||
|
content = prompt,
|
||||||
|
tokenEstimate = estimateTokens(prompt),
|
||||||
|
role = EntryRole.USER,
|
||||||
|
)
|
||||||
|
val ctx = contextPackBuilder.build(
|
||||||
|
id = ContextPackId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
entries = listOf(entry),
|
||||||
|
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||||
|
)
|
||||||
|
val result = runInference(
|
||||||
|
sessionId, stageId, ctx, stageConfig, timeoutMs, ResponseFormat.Json(schema), effectives, withTools = false,
|
||||||
|
)
|
||||||
|
return (result as? InferenceResult.Success)?.response?.text
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun emitArtifactRepairAttempted(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
slot: TypedArtifactSlot,
|
||||||
|
classification: ArtifactFailure,
|
||||||
|
rung: String,
|
||||||
|
) = emit(
|
||||||
|
sessionId,
|
||||||
|
ArtifactRepairAttemptedEvent(slot.name, classification.name, rung, sessionId, stageId),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun emitArtifactRepairResolved(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
slot: TypedArtifactSlot,
|
||||||
|
json: String,
|
||||||
|
) {
|
||||||
|
val hash = artifactStore.put(json.toByteArray())
|
||||||
|
emit(sessionId, ArtifactRepairResolvedEvent(slot.name, hash, sessionId, stageId))
|
||||||
|
}
|
||||||
|
|
||||||
private fun error(
|
private fun error(
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -1347,22 +1534,32 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a top-K slice of the recorded repo map as a droppable L3 context entry. Reads
|
* Renders the recorded repo map as a structural "what exists" listing — directories with
|
||||||
* the latest [RepoMapComputedEvent] from the log (replay-safe — never re-scans the FS).
|
* their files, alphabetical, no recency ranking and no symbol excerpts. Relevance is the
|
||||||
* Not pinned: under budget pressure this yields before the working set (paths + symbols
|
* retrieval layer's job (buildRelevantFilesEntry); the map only tells the model what it can
|
||||||
* only, so the model can `file_read` for detail).
|
* `file_read`. Markdown/docs paths are gated: they only appear when the stage prompt
|
||||||
|
* explicitly asks about documentation, so churny kernel docs can't bury real source
|
||||||
|
* (2026-07-05 context-poisoning root cause). Reads the latest [RepoMapComputedEvent] from
|
||||||
|
* the log (replay-safe — never re-scans the FS).
|
||||||
*/
|
*/
|
||||||
private suspend fun buildRepoMapEntries(sessionId: SessionId): List<ContextEntry> {
|
private suspend fun buildRepoMapEntries(sessionId: SessionId, stagePrompt: String = ""): List<ContextEntry> {
|
||||||
val map = eventStore.read(sessionId)
|
val map = eventStore.read(sessionId)
|
||||||
.mapNotNull { it.payload as? RepoMapComputedEvent }
|
.mapNotNull { it.payload as? RepoMapComputedEvent }
|
||||||
.lastOrNull() ?: return emptyList()
|
.lastOrNull() ?: return emptyList()
|
||||||
val top = map.entries.sortedByDescending { it.score }.take(REPO_MAP_INJECT_TOP_K)
|
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
|
||||||
if (top.isEmpty()) return emptyList()
|
val paths = map.entries.map { it.path }.distinct()
|
||||||
|
.filter { docsWanted || !isDocPath(it) }
|
||||||
|
.sorted()
|
||||||
|
if (paths.isEmpty()) return emptyList()
|
||||||
|
val byDir = paths.groupBy { it.substringBeforeLast('/', missingDelimiterValue = ".") }
|
||||||
val content = buildString {
|
val content = buildString {
|
||||||
appendLine("## Repo map (top files by recency — read files for detail)")
|
appendLine("## Repo layout (what exists — use file_read for content)")
|
||||||
top.forEach { entry ->
|
byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
|
||||||
val symbols = if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
|
val names = files.map { it.substringAfterLast('/') }
|
||||||
appendLine("- ${entry.path}$symbols")
|
val shown = names.take(REPO_MAP_FILES_PER_DIR)
|
||||||
|
val more = names.size - shown.size
|
||||||
|
val suffix = if (more > 0) ", …(+$more more)" else ""
|
||||||
|
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
|
||||||
}
|
}
|
||||||
}.trimEnd()
|
}.trimEnd()
|
||||||
return listOf(
|
return listOf(
|
||||||
@@ -1396,15 +1593,15 @@ abstract class SessionOrchestrator(
|
|||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
stageConfig: StageConfig,
|
stageConfig: StageConfig,
|
||||||
): List<ContextEntry> {
|
): List<ContextEntry> {
|
||||||
|
val stagePrompt = (stageConfig.metadata["promptInline"] ?: stageConfig.metadata["prompt"] ?: stageId.value)
|
||||||
|
.trim().ifBlank { stageId.value }
|
||||||
val recorded = eventStore.read(sessionId)
|
val recorded = eventStore.read(sessionId)
|
||||||
.mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
|
.mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
|
||||||
.lastOrNull { it.stageId == stageId }
|
.lastOrNull { it.stageId == stageId }
|
||||||
if (recorded != null) return repoEntriesOrMapFloor(sessionId, recorded.hits)
|
if (recorded != null) return repoEntriesOrMapFloor(sessionId, recorded.hits, stagePrompt)
|
||||||
|
|
||||||
val retriever = repoKnowledgeRetriever ?: return buildRepoMapEntries(sessionId)
|
val retriever = repoKnowledgeRetriever ?: return buildRepoMapEntries(sessionId, stagePrompt)
|
||||||
val query = (stageConfig.metadata["promptInline"] ?: stageConfig.metadata["prompt"] ?: stageId.value)
|
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, REPO_MAP_INJECT_TOP_K) }
|
||||||
.trim().ifBlank { stageId.value }
|
|
||||||
val hits = runCatching { retriever.retrieve(sessionId, query, REPO_MAP_INJECT_TOP_K) }
|
|
||||||
.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)
|
||||||
@@ -1412,8 +1609,8 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
// Record the raw retrieval for audit (a degenerate all-zero result is itself the signal that
|
// Record the raw retrieval for audit (a degenerate all-zero result is itself the signal that
|
||||||
// the embedder is noop / the index is empty), but only useful hits ever reach the agent.
|
// the embedder is noop / the index is empty), but only useful hits ever reach the agent.
|
||||||
if (hits.isNotEmpty()) emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, query, hits))
|
if (hits.isNotEmpty()) emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, stagePrompt, hits))
|
||||||
return repoEntriesOrMapFloor(sessionId, hits)
|
return repoEntriesOrMapFloor(sessionId, hits, stagePrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1424,9 +1621,25 @@ abstract class SessionOrchestrator(
|
|||||||
private suspend fun repoEntriesOrMapFloor(
|
private suspend fun repoEntriesOrMapFloor(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
hits: List<RepoKnowledgeHit>,
|
hits: List<RepoKnowledgeHit>,
|
||||||
|
stagePrompt: String,
|
||||||
): List<ContextEntry> {
|
): List<ContextEntry> {
|
||||||
val useful = hits.filter { it.score > 0f }
|
val useful = hits.filter { it.score > 0f }
|
||||||
return if (useful.isEmpty()) buildRepoMapEntries(sessionId) else listOf(buildRelevantFilesEntry(useful))
|
return if (useful.isEmpty()) {
|
||||||
|
buildRepoMapEntries(sessionId, stagePrompt)
|
||||||
|
} else {
|
||||||
|
listOf(buildRelevantFilesEntry(useful))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps the required/optional budget bucket by sourceType. REQUIRED = the stage cannot run
|
||||||
|
* correctly without it (task, constraints, loaded inputs, failure feedback) — never trimmed,
|
||||||
|
* build fails fast if it alone exceeds the budget. Everything else (repo map, retrieval,
|
||||||
|
* journal, profiles, vocabulary, transcript) is OPTIONAL and shares the remainder under
|
||||||
|
* per-type ceilings.
|
||||||
|
*/
|
||||||
|
private fun stampBuckets(entries: List<ContextEntry>): List<ContextEntry> = entries.map {
|
||||||
|
if (it.sourceType in REQUIRED_SOURCE_TYPES) it.copy(bucket = ContextBucket.REQUIRED) else it
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildNeedsArtifactEntries(
|
private suspend fun buildNeedsArtifactEntries(
|
||||||
@@ -1443,7 +1656,13 @@ abstract class SessionOrchestrator(
|
|||||||
"This is reviewer feedback on your previous attempt. " +
|
"This is reviewer feedback on your previous attempt. " +
|
||||||
"Address every finding before completing the stage.\n$cached"
|
"Address every finding before completing the stage.\n$cached"
|
||||||
} else {
|
} else {
|
||||||
"## Input artifact: ${needed.value}\n$cached"
|
// file_written handoff: hand the next stage the whole producing stage's change set
|
||||||
|
// as a manifest (known files), not one file's content. The cache value only holds
|
||||||
|
// the LAST write of the stage, so rendering it inline both misleads (5 of 6 files
|
||||||
|
// invisible) and wastes budget — content stays lazy behind file_read.
|
||||||
|
val manifest = parseFileWrittenArtifact(cached)
|
||||||
|
?.let { fileWrittenManifest(sessionId, needed) }
|
||||||
|
"## Input artifact: ${needed.value}\n${manifest ?: cached}"
|
||||||
}
|
}
|
||||||
ContextEntry(
|
ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
@@ -1457,6 +1676,38 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseFileWrittenArtifact(json: String): FileWrittenArtifact? =
|
||||||
|
runCatching { Json.decodeFromString(FileWrittenArtifact.serializer(), json) }.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects the full change set of the stage(s) that produced a file_written artifact from
|
||||||
|
* recorded events: ArtifactContentStoredEvent → producing stageIds, ToolInvocationRequested →
|
||||||
|
* that stage's invocations, FileWrittenEvent → the paths actually written. Pure projection
|
||||||
|
* over existing events — no new artifact kind, no producer change, replay-safe. Null when no
|
||||||
|
* writes are on record (caller falls back to the cached single-file JSON).
|
||||||
|
*/
|
||||||
|
private fun fileWrittenManifest(sessionId: SessionId, needed: ArtifactId): String? {
|
||||||
|
val events = eventStore.read(sessionId)
|
||||||
|
val stageIds = events.mapNotNull { it.payload as? ArtifactContentStoredEvent }
|
||||||
|
.filter { it.artifactId == needed }
|
||||||
|
.map { it.stageId }
|
||||||
|
.toSet()
|
||||||
|
if (stageIds.isEmpty()) return null
|
||||||
|
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||||
|
.filter { it.stageId in stageIds }
|
||||||
|
.map { it.invocationId }
|
||||||
|
.toSet()
|
||||||
|
val paths = events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||||
|
.filter { it.invocationId in invocationIds && it.postImageHash != null }
|
||||||
|
.map { it.path }
|
||||||
|
.distinct()
|
||||||
|
if (paths.isEmpty()) return null
|
||||||
|
return buildString {
|
||||||
|
appendLine("Files written by the producing stage (use file_read to load any content you need):")
|
||||||
|
paths.forEach { appendLine("- $it") }
|
||||||
|
}.trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitToolArtifacts(
|
private suspend fun emitToolArtifacts(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import com.correx.core.context.builder.DefaultContextPackBuilder
|
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||||
import com.correx.core.context.builder.DefaultDecisionPointBuilder
|
import com.correx.core.context.builder.DefaultDecisionPointBuilder
|
||||||
import com.correx.core.context.compression.DefaultContextCompressor
|
import com.correx.core.context.compression.DefaultContextCompressor
|
||||||
|
import com.correx.core.context.builder.RequiredContextOverflowException
|
||||||
|
import com.correx.core.context.model.ContextBucket
|
||||||
import com.correx.core.context.model.ContextEntry
|
import com.correx.core.context.model.ContextEntry
|
||||||
import com.correx.core.context.model.ContextLayer
|
import com.correx.core.context.model.ContextLayer
|
||||||
import com.correx.core.context.model.TokenBudget
|
import com.correx.core.context.model.TokenBudget
|
||||||
@@ -143,4 +145,50 @@ class DefaultContextPackBuilderTest {
|
|||||||
// ordinals are stamped from input order
|
// ordinals are stamped from input order
|
||||||
assertEquals(listOf(1, 2, 3, 4), pack.layers[ContextLayer.L2]!!.map { it.ordinal })
|
assertEquals(listOf(1, 2, 3, 4), pack.layers[ContextLayer.L2]!!.map { it.ordinal })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `required entries survive intact under budget pressure`() = kotlinx.coroutines.runBlocking {
|
||||||
|
val entries = listOf(
|
||||||
|
entry("task", ContextLayer.L1, 400).copy(sourceType = "agentPrompt", bucket = ContextBucket.REQUIRED),
|
||||||
|
entry("input", ContextLayer.L1, 400).copy(sourceType = "neededArtifact", bucket = ContextBucket.REQUIRED),
|
||||||
|
entry("map", ContextLayer.L3, 5000).copy(sourceType = "repoMap"),
|
||||||
|
)
|
||||||
|
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 1000))
|
||||||
|
val retained = pack.layers.values.flatten()
|
||||||
|
assertTrue(retained.any { it.id == ContextEntryId("task") }, "required task must be retained")
|
||||||
|
assertTrue(retained.any { it.id == ContextEntryId("input") }, "required input must be retained")
|
||||||
|
// and retained uncompressed — same token estimate as input
|
||||||
|
assertEquals(400, retained.first { it.id == ContextEntryId("task") }.tokenEstimate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build fails fast when required bucket alone exceeds the budget`() {
|
||||||
|
val entries = listOf(
|
||||||
|
entry("task", ContextLayer.L1, 900).copy(sourceType = "agentPrompt", bucket = ContextBucket.REQUIRED),
|
||||||
|
entry("input", ContextLayer.L1, 900).copy(sourceType = "neededArtifact", bucket = ContextBucket.REQUIRED),
|
||||||
|
)
|
||||||
|
val thrown = org.junit.jupiter.api.assertThrows<RequiredContextOverflowException> {
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(1800, thrown.requiredTokens)
|
||||||
|
assertEquals(1000, thrown.budgetLimit)
|
||||||
|
assertEquals(900, thrown.breakdown["agentPrompt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `optional groups are capped by their per-type ceiling`() = kotlinx.coroutines.runBlocking {
|
||||||
|
// Journal ceiling is 800 by default: a 5000-token journal must not survive whole even
|
||||||
|
// though the overall budget would fit it.
|
||||||
|
val entries = listOf(
|
||||||
|
entry("task", ContextLayer.L1, 100).copy(sourceType = "agentPrompt", bucket = ContextBucket.REQUIRED),
|
||||||
|
entry("journal", ContextLayer.L0, 5000).copy(sourceType = "decisionJournal"),
|
||||||
|
)
|
||||||
|
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 16384))
|
||||||
|
val journalTokens = pack.layers.values.flatten()
|
||||||
|
.filter { it.sourceType == "decisionJournal" }
|
||||||
|
.sumOf { it.tokenEstimate }
|
||||||
|
assertTrue(journalTokens <= 800, "journal must be capped to 800 tokens, was $journalTokens")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,28 @@ class SessionOrchestratorIntegrationTest {
|
|||||||
val sessionId = SessionId("s-trunc")
|
val sessionId = SessionId("s-trunc")
|
||||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||||
|
|
||||||
|
// Seed a recorded repo map so the stage context carries an OPTIONAL entry the dropping
|
||||||
|
// compressor can discard: required entries (like the system prompt) are pinned by the
|
||||||
|
// bucket split and never reach the compressor.
|
||||||
|
eventStore.append(
|
||||||
|
com.correx.core.events.events.NewEvent(
|
||||||
|
com.correx.core.events.events.EventMetadata(
|
||||||
|
com.correx.core.events.types.EventId("rm-trunc"),
|
||||||
|
sessionId,
|
||||||
|
kotlinx.datetime.Instant.parse("2026-06-04T00:00:00Z"),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
com.correx.core.events.events.RepoMapComputedEvent(
|
||||||
|
sessionId,
|
||||||
|
"/repo",
|
||||||
|
listOf(com.correx.core.events.events.RepoMapEntry("src/Main.kt", 0.9)),
|
||||||
|
kotlinx.datetime.Instant.parse("2026-06-04T00:00:00Z"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
truncOrchestrator.run(sessionId, truncGraph, config)
|
truncOrchestrator.run(sessionId, truncGraph, config)
|
||||||
|
|
||||||
val truncated = eventStore.read(sessionId).mapNotNull { it.payload as? ContextTruncatedEvent }
|
val truncated = eventStore.read(sessionId).mapNotNull { it.payload as? ContextTruncatedEvent }
|
||||||
|
|||||||
Reference in New Issue
Block a user