Compare commits
10 Commits
bfd925b518
...
d69cb12ce9
| Author | SHA1 | Date | |
|---|---|---|---|
| d69cb12ce9 | |||
| 9b925e141d | |||
| 5d1f2ab360 | |||
| 1f794dad63 | |||
| 670e0c4828 | |||
| 7f820bafe6 | |||
| aee2e67c66 | |||
| 135a34eb2f | |||
| 2912799fe0 | |||
| 3a4e577b5b |
@@ -96,6 +96,7 @@ data class StatsReportDto(
|
||||
private const val MS_PER_SECOND = 1000L
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MINUTES_PER_HOUR = 60L
|
||||
private const val PERCENT_MULTIPLIER = 100.0
|
||||
|
||||
private fun humanDuration(ms: Long): String {
|
||||
if (ms <= 0L) return "0s"
|
||||
@@ -111,7 +112,7 @@ private fun humanDuration(ms: Long): String {
|
||||
}
|
||||
|
||||
private fun otherPct(report: StatsReportDto): Double =
|
||||
(100.0 - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0)
|
||||
(PERCENT_MULTIPLIER - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0)
|
||||
|
||||
fun renderStats(report: StatsReportDto): String {
|
||||
val lines = mutableListOf<String>()
|
||||
@@ -162,10 +163,10 @@ fun renderStats(report: StatsReportDto): String {
|
||||
val q = report.quality
|
||||
lines += "Signal quality"
|
||||
lines += " extraction: %d fetched, %d low-quality (%.0f%% clean)".format(
|
||||
Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * 100.0,
|
||||
Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * PERCENT_MULTIPLIER,
|
||||
)
|
||||
lines += " retrieval: %d kept, %d filtered (%.0f%% precision)".format(
|
||||
Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * 100.0,
|
||||
Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * PERCENT_MULTIPLIER,
|
||||
)
|
||||
lines += " brief drift: ${q.briefDriftCount} capability gaps: ${q.capabilityGapCount}"
|
||||
lines += ""
|
||||
|
||||
@@ -34,6 +34,9 @@ import kotlinx.serialization.json.put
|
||||
|
||||
private val taskJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private const val TASK_ID_COLUMN_WIDTH = 14
|
||||
private const val TASK_STATUS_COLUMN_WIDTH = 12
|
||||
|
||||
/** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */
|
||||
@Serializable
|
||||
data class TaskRow(
|
||||
@@ -60,7 +63,9 @@ fun renderTaskList(tasks: List<TaskRow>): String {
|
||||
if (tasks.isEmpty()) return "no tasks"
|
||||
return tasks.joinToString("\n") { t ->
|
||||
val claim = t.claimant?.let { " @$it" }.orEmpty()
|
||||
"${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd()
|
||||
val id = t.id.padEnd(TASK_ID_COLUMN_WIDTH)
|
||||
val status = t.status.padEnd(TASK_STATUS_COLUMN_WIDTH)
|
||||
"$id $status ${t.title.orEmpty()}$claim".trimEnd()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,8 +229,10 @@ class TaskCreateCommand : TaskHttpCommand("create") {
|
||||
}
|
||||
val raw = resp.bodyAsText()
|
||||
when {
|
||||
resp.status == HttpStatusCode.Conflict ->
|
||||
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}")
|
||||
resp.status == HttpStatusCode.Conflict -> {
|
||||
val dupes = renderTaskList(taskJson.decodeFromString(raw))
|
||||
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n$dupes")
|
||||
}
|
||||
jsonOut() -> println(raw)
|
||||
else -> println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
|
||||
}
|
||||
@@ -250,7 +257,11 @@ class TaskCompleteCommand : TaskHttpCommand("complete") {
|
||||
|
||||
override fun run() = http { client ->
|
||||
val resp = client.post("${baseUrl()}/tasks/$id/complete")
|
||||
if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id")
|
||||
if (resp.status == HttpStatusCode.NotFound) {
|
||||
System.err.println("No such task: $id")
|
||||
} else {
|
||||
println("completed $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ class TaskRenderTest {
|
||||
val lines = out.lines()
|
||||
assertEquals(2, lines.size)
|
||||
assertTrue(lines[0].startsWith("auth-1"))
|
||||
assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus"))
|
||||
assertTrue(
|
||||
lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus"),
|
||||
)
|
||||
assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE"))
|
||||
}
|
||||
|
||||
@@ -38,7 +40,14 @@ class TaskRenderTest {
|
||||
fun `renderTaskDetail includes goal, criteria, links and notes`() {
|
||||
val out = renderTaskDetail(task)
|
||||
assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh"))
|
||||
for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) {
|
||||
val wants = listOf(
|
||||
"goal: users stay authenticated",
|
||||
"- rotates",
|
||||
"backend/auth/**",
|
||||
"adr-7 (IMPLEMENTS -> DOC)",
|
||||
"[AGENT] kickoff",
|
||||
)
|
||||
for (want in wants) {
|
||||
assertTrue(out.contains(want), "detail missing: $want")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ fun main() {
|
||||
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
|
||||
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
|
||||
)
|
||||
// Retrieves full tool output the kernel spilled to CAS on truncation (ref shown in-context).
|
||||
val toolOutputTool = com.correx.infrastructure.tools.ToolOutputTool(artifactStore)
|
||||
val extraTools = taskTools + toolOutputTool
|
||||
val toolRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfig(
|
||||
workspaceRoot,
|
||||
@@ -268,7 +271,7 @@ fun main() {
|
||||
toolsConfig,
|
||||
researchToolConfig,
|
||||
),
|
||||
extraTools = taskTools,
|
||||
extraTools = extraTools,
|
||||
)
|
||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||
registry = toolRegistry,
|
||||
@@ -301,7 +304,7 @@ fun main() {
|
||||
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
||||
val wsRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
|
||||
extraTools = taskTools,
|
||||
extraTools = extraTools,
|
||||
)
|
||||
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
||||
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
|
||||
|
||||
@@ -212,7 +212,12 @@ class ServerModule(
|
||||
|
||||
// Live-only: subscribeAll() replays nothing and ServerModule is never built under
|
||||
// ReplayOrchestrator, so narration never re-fires on restart/replay (invariant #8).
|
||||
NarrationSubscriber(eventStore = eventStore, routerFacade = routerFacade, scope = moduleScope, maxPerRun = narrationMaxPerRun).start()
|
||||
NarrationSubscriber(
|
||||
eventStore = eventStore,
|
||||
routerFacade = routerFacade,
|
||||
scope = moduleScope,
|
||||
maxPerRun = narrationMaxPerRun,
|
||||
).start()
|
||||
|
||||
// Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration:
|
||||
// probes read the environment and record degraded/restored events; replay reads those facts.
|
||||
@@ -449,7 +454,10 @@ class ServerModule(
|
||||
* TOML registry, so the resume route cannot find them there. Rehydrates the artifact
|
||||
* cache first because the plan content lives in it after a restart.
|
||||
*/
|
||||
suspend fun freestyleResumeGraph(sessionId: SessionId, workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? {
|
||||
suspend fun freestyleResumeGraph(
|
||||
sessionId: SessionId,
|
||||
workflowId: String,
|
||||
): com.correx.core.transitions.graph.WorkflowGraph? {
|
||||
if (!workflowId.startsWith("freestyle-")) return null
|
||||
orchestrator.rehydrate(sessionId)
|
||||
return freestyleDriver?.compiledGraph(sessionId)
|
||||
@@ -534,6 +542,10 @@ class ServerModule(
|
||||
val planAlreadyLocked = eventStore.read(sessionId)
|
||||
.any { it.payload is ExecutionPlanLockedEvent }
|
||||
if (planAlreadyLocked) return
|
||||
// completeWorkflow evicts artifactContentCache on the planning graph's completion (#54),
|
||||
// so the execution_plan slot lockAndRun reads is gone by now. Rebuild it from the durable
|
||||
// ArtifactContentStoredEvent before handing off. Rehydrate-safe and idempotent.
|
||||
orchestrator.rehydrate(sessionId)
|
||||
freestyleDriver?.lockAndRun(sessionId)
|
||||
}
|
||||
|
||||
@@ -614,7 +626,11 @@ class ServerModule(
|
||||
return@forEach
|
||||
}
|
||||
val graph = workflowRegistry.find(orchState.workflowId) ?: run {
|
||||
log.warn("resumeAbandoned: no graph for session={} workflowId={}", sessionId.value, orchState.workflowId)
|
||||
log.warn(
|
||||
"resumeAbandoned: no graph for session={} workflowId={}",
|
||||
sessionId.value,
|
||||
orchState.workflowId,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
|
||||
|
||||
@@ -1,55 +1,12 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.AssessedIssueDto
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.ReviewFindingDto
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.toDto
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.SessionNamedEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.ModelLoadedEvent
|
||||
import com.correx.core.events.events.ModelUnloadedEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectBlockedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.TalkieNarrationEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private val log = LoggerFactory.getLogger("DomainEventMapper")
|
||||
internal val log = LoggerFactory.getLogger("DomainEventMapper")
|
||||
|
||||
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
|
||||
suspend fun map(event: StoredEvent, sessionSequence: Long = 0L): ServerMessage? =
|
||||
@@ -62,379 +19,44 @@ private object NoopArtifactStore : ArtifactStore {
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
/**
|
||||
* Outcome of one per-domain mapper. [Emit] means "this event is mine" — carrying either a
|
||||
* [ServerMessage] or `null` (handled but deliberately not surfaced, e.g. transient bookkeeping
|
||||
* events). [Skip] means "not my domain", so the dispatcher tries the next mapper. The distinction
|
||||
* matters: chaining on a bare `null` would conflate suppression with non-ownership.
|
||||
*/
|
||||
internal sealed interface MapOutcome {
|
||||
@JvmInline
|
||||
value class Emit(val message: ServerMessage?) : MapOutcome
|
||||
data object Skip : MapOutcome
|
||||
}
|
||||
|
||||
// Per-domain mappers, tried in order. Each owns a disjoint slice of the payload hierarchy and
|
||||
// returns [MapOutcome.Skip] for anything outside it. Split across files by domain area to keep
|
||||
// each mapper and its import list small — see StageInferenceEventMappers, ToolEventMappers, etc.
|
||||
private val domainMappers: List<suspend (StoredEvent, ArtifactStore, Long) -> MapOutcome> = listOf(
|
||||
::mapSessionEvent,
|
||||
::mapStageInferenceEvent,
|
||||
::mapToolEvent,
|
||||
::mapLifecycleEvent,
|
||||
)
|
||||
|
||||
suspend fun domainEventToServerMessage(
|
||||
event: StoredEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long = 0L,
|
||||
): ServerMessage? {
|
||||
val seq = event.sequence
|
||||
return when (val p = event.payload) {
|
||||
is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced(
|
||||
sessionId = p.sessionId,
|
||||
workflowId = "chat",
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowStartedEvent -> ServerMessage.SessionAnnounced(
|
||||
sessionId = p.sessionId,
|
||||
workflowId = p.workflowId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound(
|
||||
sessionId = p.sessionId,
|
||||
workspaceRoot = p.workspaceRoot,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is SessionNamedEvent -> ServerMessage.SessionRenamed(
|
||||
sessionId = p.sessionId,
|
||||
name = p.name,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ChatTurnEvent -> ServerMessage.ChatTurn(
|
||||
sessionId = p.sessionId,
|
||||
turnId = p.turnId,
|
||||
role = p.role.name,
|
||||
content = p.content,
|
||||
latencyMs = p.latencyMs,
|
||||
totalTokens = p.tokensUsed?.totalTokens,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(
|
||||
sessionId = p.sessionId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowFailedEvent -> ServerMessage.SessionFailed(
|
||||
sessionId = p.sessionId,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is TransitionExecutedEvent -> ServerMessage.StageStarted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.to,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is StageCompletedEvent -> ServerMessage.StageCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is StageFailedEvent -> ServerMessage.StageFailed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
reason = p.reason,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is OrchestrationPausedEvent -> mapOrchestrationPaused(p, seq, sessionSequence)
|
||||
is OrchestrationResumedEvent -> ServerMessage.SessionResumed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is InferenceStartedEvent -> ServerMessage.InferenceStarted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is InferenceCompletedEvent -> mapInferenceCompleted(event, p, artifactStore, sessionSequence)
|
||||
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
elapsedMs = p.timeoutMs,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is InferenceFailedEvent -> ServerMessage.InferenceFailed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is RetryAttemptedEvent -> ServerMessage.RetryAttempted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
attemptNumber = p.attemptNumber,
|
||||
maxAttempts = p.maxAttempts,
|
||||
failureReason = p.failureReason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
tier = p.tier,
|
||||
params = prettyToolParams(p.request.parameters),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
outputSummary = p.receipt.outputSummary,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
diff = p.receipt.diff,
|
||||
affectedEntities = p.receipt.affectedEntities,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
reason = p.reason,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolCallAssessedEvent -> ServerMessage.ToolAssessed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
toolName = p.toolName,
|
||||
disposition = p.disposition.name,
|
||||
issues = p.issues.map { AssessedIssueDto(it.code, it.message, it.severity) },
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ReviewFindingsRaisedEvent -> ServerMessage.ReviewFindings(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
verdict = p.verdict.name,
|
||||
findings = p.findings.map {
|
||||
ReviewFindingDto(
|
||||
severity = it.severity.name,
|
||||
confidence = it.confidence,
|
||||
category = it.category,
|
||||
target = it.target,
|
||||
message = it.message,
|
||||
suggestedFix = it.suggestedFix,
|
||||
correctness = it.correctness,
|
||||
)
|
||||
},
|
||||
blocked = p.blocked,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
|
||||
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
|
||||
sessionId = p.sessionId,
|
||||
requestId = p.requestId,
|
||||
stageId = p.stageId,
|
||||
questions = p.questions,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
|
||||
sessionId = p.sessionId,
|
||||
proposalId = p.proposalId,
|
||||
prompt = p.prompt,
|
||||
candidates = p.candidates,
|
||||
originalRequest = p.originalRequest,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
|
||||
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
|
||||
sessionId = event.metadata.sessionId,
|
||||
requestId = p.requestId,
|
||||
outcome = p.outcome.name,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is TalkieNarrationEvent -> ServerMessage.Narration(
|
||||
sessionId = p.sessionId,
|
||||
content = p.content,
|
||||
stageId = p.stageId,
|
||||
latencyMs = p.latencyMs,
|
||||
totalTokens = p.tokensUsed?.totalTokens,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
artifactId = p.artifactId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is ArtifactValidatedEvent -> ServerMessage.ArtifactValidated(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
artifactId = p.artifactId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
// Transient pre-validation marker, emitted microseconds before Validated or a
|
||||
// stage failure — either of those carries the outcome the operator cares about.
|
||||
is ArtifactValidatingEvent -> null
|
||||
is ExecutionPlanLockedEvent -> ServerMessage.PlanLocked(
|
||||
sessionId = p.sessionId,
|
||||
workflowId = p.workflowId,
|
||||
stageIds = p.stageIds,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
is ModelLoadedEvent -> ServerMessage.ModelChanged(
|
||||
modelId = p.modelId,
|
||||
providerId = p.providerId.value,
|
||||
loaded = true,
|
||||
)
|
||||
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
|
||||
modelId = p.modelId,
|
||||
providerId = p.providerId.value,
|
||||
loaded = false,
|
||||
)
|
||||
// Internal slot→CAS-hash bookkeeping (F-007 durable bridge); no operator-facing surface.
|
||||
is ArtifactContentStoredEvent -> null
|
||||
|
||||
// Freestyle graph-rerouting bookkeeping. The deterministic record is in place; a dedicated
|
||||
// operator surface ships with the LLM-proposal + approval-confirm front-half.
|
||||
is PreemptRedirectEvent -> null
|
||||
is PreemptRedirectBlockedEvent -> null
|
||||
|
||||
else -> {
|
||||
for (mapper in domainMappers) {
|
||||
when (val outcome = mapper(event, artifactStore, sessionSequence)) {
|
||||
is MapOutcome.Emit -> return outcome.message
|
||||
MapOutcome.Skip -> Unit
|
||||
}
|
||||
}
|
||||
log.debug(
|
||||
"DomainEventMapper: unmapped payload type={} sessionId={} sequence={}",
|
||||
p::class.simpleName,
|
||||
event.payload::class.simpleName,
|
||||
event.metadata.sessionId,
|
||||
event.sequence,
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun mapOrchestrationPaused(
|
||||
p: OrchestrationPausedEvent,
|
||||
seq: Long,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage {
|
||||
val reason = when (p.reason) {
|
||||
"APPROVAL_PENDING" -> PauseReason.APPROVAL_PENDING
|
||||
"CLARIFICATION_PENDING" -> PauseReason.CLARIFICATION_PENDING
|
||||
"ABANDONED_STALE" -> PauseReason.ABANDONED_STALE
|
||||
else -> PauseReason.USER_REQUESTED
|
||||
}
|
||||
return ServerMessage.SessionPaused(
|
||||
sessionId = p.sessionId,
|
||||
reason = reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun mapInferenceCompleted(
|
||||
event: StoredEvent,
|
||||
p: InferenceCompletedEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage {
|
||||
val response = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }
|
||||
val reasoning = p.reasoningArtifactId?.let { id ->
|
||||
runCatching { artifactStore.get(id)?.toString(Charsets.UTF_8) ?: "" }.getOrElse { "" }
|
||||
} ?: ""
|
||||
return ServerMessage.InferenceCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
outputSummary = response,
|
||||
responseText = response,
|
||||
reasoning = reasoning,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
totalTokens = p.tokensUsed.totalTokens,
|
||||
sequence = event.sequence,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapApprovalRequested(
|
||||
p: ApprovalRequestedEvent,
|
||||
seq: Long,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage =
|
||||
ServerMessage.ApprovalRequired(
|
||||
sessionId = p.sessionId,
|
||||
requestId = p.requestId,
|
||||
tier = p.tier,
|
||||
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
|
||||
level = p.tier.name,
|
||||
factors = emptyList(),
|
||||
recommendedAction = RiskAction.PROMPT_USER.name,
|
||||
rationale = emptyList(),
|
||||
),
|
||||
toolName = p.toolName,
|
||||
preview = p.preview,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
/**
|
||||
* Render a tool call's raw parameter map into compact, human-readable "key=value" cells for the
|
||||
* client's tool-call row. Primary keys (path/command/query/…) lead so the most salient argument is
|
||||
* first; long values are truncated and multi-line values collapsed so the WS frame stays small.
|
||||
*/
|
||||
internal fun prettyToolParams(parameters: Map<String, Any>): List<String> {
|
||||
if (parameters.isEmpty()) return emptyList()
|
||||
val primary = listOf("command", "path", "query", "url", "pattern", "content", "operation")
|
||||
val ordered = parameters.entries.sortedWith(
|
||||
compareBy({ primary.indexOf(it.key).let { i -> if (i < 0) primary.size else i } }, { it.key }),
|
||||
)
|
||||
return ordered.take(MAX_PARAM_CELLS).map { (k, v) -> "$k=${formatParamValue(v)}" }
|
||||
}
|
||||
|
||||
private fun formatParamValue(value: Any?): String {
|
||||
val raw = when (value) {
|
||||
null -> "null"
|
||||
is String -> value
|
||||
is Collection<*> -> value.joinToString(", ", prefix = "[", postfix = "]") { formatParamValue(it) }
|
||||
else -> value.toString()
|
||||
}
|
||||
val flattened = raw.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
val clipped = if (flattened.length > MAX_PARAM_VALUE_LEN) flattened.take(MAX_PARAM_VALUE_LEN) + "…" else flattened
|
||||
// Quote strings that carry whitespace so the boundary of the value is unambiguous in the row.
|
||||
return if (value is String && clipped.any { it.isWhitespace() }) "\"$clipped\"" else clipped
|
||||
}
|
||||
|
||||
private const val MAX_PARAM_CELLS = 5
|
||||
private const val MAX_PARAM_VALUE_LEN = 80
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.toDto
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ModelLoadedEvent
|
||||
import com.correx.core.events.events.ModelUnloadedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectBlockedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TalkieNarrationEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
|
||||
/**
|
||||
* Approval / clarification / workflow-proposal / narration / artifact / model / preempt events —
|
||||
* the remaining operator-facing (or deliberately suppressed) lifecycle surface. Branches that
|
||||
* return `null` are handled-but-not-surfaced (transient bookkeeping); wrapping them in
|
||||
* [MapOutcome.Emit] keeps them from falling through to the dispatcher's "unmapped" log.
|
||||
*/
|
||||
@Suppress("UnusedParameter", "LongMethod", "CyclomaticComplexMethod")
|
||||
internal suspend fun mapLifecycleEvent(
|
||||
event: StoredEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): MapOutcome {
|
||||
val seq = event.sequence
|
||||
val msg: ServerMessage? = when (val p = event.payload) {
|
||||
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
|
||||
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
|
||||
sessionId = p.sessionId,
|
||||
requestId = p.requestId,
|
||||
stageId = p.stageId,
|
||||
questions = p.questions,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
|
||||
sessionId = p.sessionId,
|
||||
proposalId = p.proposalId,
|
||||
prompt = p.prompt,
|
||||
candidates = p.candidates,
|
||||
originalRequest = p.originalRequest,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
|
||||
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
|
||||
sessionId = event.metadata.sessionId,
|
||||
requestId = p.requestId,
|
||||
outcome = p.outcome.name,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is TalkieNarrationEvent -> ServerMessage.Narration(
|
||||
sessionId = p.sessionId,
|
||||
content = p.content,
|
||||
stageId = p.stageId,
|
||||
latencyMs = p.latencyMs,
|
||||
totalTokens = p.tokensUsed?.totalTokens,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
artifactId = p.artifactId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ArtifactValidatedEvent -> ServerMessage.ArtifactValidated(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
artifactId = p.artifactId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
// Transient pre-validation marker, emitted microseconds before Validated or a
|
||||
// stage failure — either of those carries the outcome the operator cares about.
|
||||
is ArtifactValidatingEvent -> null
|
||||
|
||||
is ExecutionPlanLockedEvent -> ServerMessage.PlanLocked(
|
||||
sessionId = p.sessionId,
|
||||
workflowId = p.workflowId,
|
||||
stageIds = p.stageIds,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ModelLoadedEvent -> ServerMessage.ModelChanged(
|
||||
modelId = p.modelId,
|
||||
providerId = p.providerId.value,
|
||||
loaded = true,
|
||||
)
|
||||
|
||||
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
|
||||
modelId = p.modelId,
|
||||
providerId = p.providerId.value,
|
||||
loaded = false,
|
||||
)
|
||||
|
||||
// Internal slot→CAS-hash bookkeeping (F-007 durable bridge); no operator-facing surface.
|
||||
is ArtifactContentStoredEvent -> null
|
||||
|
||||
// Freestyle graph-rerouting bookkeeping. The deterministic record is in place; a dedicated
|
||||
// operator surface ships with the LLM-proposal + approval-confirm front-half.
|
||||
is PreemptRedirectEvent -> null
|
||||
is PreemptRedirectBlockedEvent -> null
|
||||
|
||||
else -> return MapOutcome.Skip
|
||||
}
|
||||
return MapOutcome.Emit(msg)
|
||||
}
|
||||
|
||||
private fun mapApprovalRequested(
|
||||
p: ApprovalRequestedEvent,
|
||||
seq: Long,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage =
|
||||
ServerMessage.ApprovalRequired(
|
||||
sessionId = p.sessionId,
|
||||
requestId = p.requestId,
|
||||
tier = p.tier,
|
||||
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
|
||||
level = p.tier.name,
|
||||
factors = emptyList(),
|
||||
recommendedAction = RiskAction.PROMPT_USER.name,
|
||||
rationale = emptyList(),
|
||||
),
|
||||
toolName = p.toolName,
|
||||
preview = p.preview,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.SessionNamedEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
|
||||
/** Session lifecycle + chat-turn events. [artifactStore] is unused here but kept for a uniform mapper signature. */
|
||||
@Suppress("UnusedParameter", "LongMethod")
|
||||
internal suspend fun mapSessionEvent(
|
||||
event: StoredEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): MapOutcome {
|
||||
val seq = event.sequence
|
||||
val msg = when (val p = event.payload) {
|
||||
is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced(
|
||||
sessionId = p.sessionId,
|
||||
workflowId = "chat",
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowStartedEvent -> ServerMessage.SessionAnnounced(
|
||||
sessionId = p.sessionId,
|
||||
workflowId = p.workflowId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound(
|
||||
sessionId = p.sessionId,
|
||||
workspaceRoot = p.workspaceRoot,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is SessionNamedEvent -> ServerMessage.SessionRenamed(
|
||||
sessionId = p.sessionId,
|
||||
name = p.name,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ChatTurnEvent -> ServerMessage.ChatTurn(
|
||||
sessionId = p.sessionId,
|
||||
turnId = p.turnId,
|
||||
role = p.role.name,
|
||||
content = p.content,
|
||||
latencyMs = p.latencyMs,
|
||||
totalTokens = p.tokensUsed?.totalTokens,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(
|
||||
sessionId = p.sessionId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is WorkflowFailedEvent -> ServerMessage.SessionFailed(
|
||||
sessionId = p.sessionId,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
else -> return MapOutcome.Skip
|
||||
}
|
||||
return MapOutcome.Emit(msg)
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
|
||||
/** Stage transition + inference lifecycle events (some carry timestamps / need the artifact store). */
|
||||
@Suppress("LongMethod")
|
||||
internal suspend fun mapStageInferenceEvent(
|
||||
event: StoredEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): MapOutcome {
|
||||
val seq = event.sequence
|
||||
val msg = when (val p = event.payload) {
|
||||
is TransitionExecutedEvent -> ServerMessage.StageStarted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.to,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is StageCompletedEvent -> ServerMessage.StageCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is StageFailedEvent -> ServerMessage.StageFailed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
reason = p.reason,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is OrchestrationPausedEvent -> mapOrchestrationPaused(p, seq, sessionSequence)
|
||||
is OrchestrationResumedEvent -> ServerMessage.SessionResumed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is InferenceStartedEvent -> ServerMessage.InferenceStarted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is InferenceCompletedEvent -> mapInferenceCompleted(event, p, artifactStore, sessionSequence)
|
||||
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
elapsedMs = p.timeoutMs,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is InferenceFailedEvent -> ServerMessage.InferenceFailed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is RetryAttemptedEvent -> ServerMessage.RetryAttempted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
attemptNumber = p.attemptNumber,
|
||||
maxAttempts = p.maxAttempts,
|
||||
failureReason = p.failureReason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
else -> return MapOutcome.Skip
|
||||
}
|
||||
return MapOutcome.Emit(msg)
|
||||
}
|
||||
|
||||
private fun mapOrchestrationPaused(
|
||||
p: OrchestrationPausedEvent,
|
||||
seq: Long,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage {
|
||||
val reason = when (p.reason) {
|
||||
"APPROVAL_PENDING" -> PauseReason.APPROVAL_PENDING
|
||||
"CLARIFICATION_PENDING" -> PauseReason.CLARIFICATION_PENDING
|
||||
"ABANDONED_STALE" -> PauseReason.ABANDONED_STALE
|
||||
else -> PauseReason.USER_REQUESTED
|
||||
}
|
||||
return ServerMessage.SessionPaused(
|
||||
sessionId = p.sessionId,
|
||||
reason = reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun mapInferenceCompleted(
|
||||
event: StoredEvent,
|
||||
p: InferenceCompletedEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage {
|
||||
val response = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }
|
||||
val reasoning = p.reasoningArtifactId?.let { id ->
|
||||
runCatching { artifactStore.get(id)?.toString(Charsets.UTF_8) ?: "" }.getOrElse { "" }
|
||||
} ?: ""
|
||||
return ServerMessage.InferenceCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
outputSummary = response,
|
||||
responseText = response,
|
||||
reasoning = reasoning,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
totalTokens = p.tokensUsed.totalTokens,
|
||||
sequence = event.sequence,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.AssessedIssueDto
|
||||
import com.correx.apps.server.protocol.ReviewFindingDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
|
||||
/** Tool invocation / execution / assessment + review-findings events. */
|
||||
@Suppress("UnusedParameter", "LongMethod")
|
||||
internal suspend fun mapToolEvent(
|
||||
event: StoredEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): MapOutcome {
|
||||
val seq = event.sequence
|
||||
val msg = when (val p = event.payload) {
|
||||
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
tier = p.tier,
|
||||
params = prettyToolParams(p.request.parameters),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
outputSummary = p.receipt.outputSummary,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
diff = p.receipt.diff,
|
||||
affectedEntities = p.receipt.affectedEntities,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
reason = p.reason,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
reason = p.reason,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ToolCallAssessedEvent -> ServerMessage.ToolAssessed(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
toolName = p.toolName,
|
||||
disposition = p.disposition.name,
|
||||
issues = p.issues.map { AssessedIssueDto(it.code, it.message, it.severity) },
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ReviewFindingsRaisedEvent -> ServerMessage.ReviewFindings(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
verdict = p.verdict.name,
|
||||
findings = p.findings.map {
|
||||
ReviewFindingDto(
|
||||
severity = it.severity.name,
|
||||
confidence = it.confidence,
|
||||
category = it.category,
|
||||
target = it.target,
|
||||
message = it.message,
|
||||
suggestedFix = it.suggestedFix,
|
||||
correctness = it.correctness,
|
||||
)
|
||||
},
|
||||
blocked = p.blocked,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
else -> return MapOutcome.Skip
|
||||
}
|
||||
return MapOutcome.Emit(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool call's raw parameter map into compact, human-readable "key=value" cells for the
|
||||
* client's tool-call row. Primary keys (path/command/query/…) lead so the most salient argument is
|
||||
* first; long values are truncated and multi-line values collapsed so the WS frame stays small.
|
||||
*/
|
||||
internal fun prettyToolParams(parameters: Map<String, Any>): List<String> {
|
||||
if (parameters.isEmpty()) return emptyList()
|
||||
val primary = listOf("command", "path", "query", "url", "pattern", "content", "operation")
|
||||
val ordered = parameters.entries.sortedWith(
|
||||
compareBy({ primary.indexOf(it.key).let { i -> if (i < 0) primary.size else i } }, { it.key }),
|
||||
)
|
||||
return ordered.take(MAX_PARAM_CELLS).map { (k, v) -> "$k=${formatParamValue(v)}" }
|
||||
}
|
||||
|
||||
private fun formatParamValue(value: Any?): String {
|
||||
val raw = when (value) {
|
||||
null -> "null"
|
||||
is String -> value
|
||||
is Collection<*> -> value.joinToString(", ", prefix = "[", postfix = "]") { formatParamValue(it) }
|
||||
else -> value.toString()
|
||||
}
|
||||
val flattened = raw.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
val clipped = if (flattened.length > MAX_PARAM_VALUE_LEN) flattened.take(MAX_PARAM_VALUE_LEN) + "…" else flattened
|
||||
// Quote strings that carry whitespace so the boundary of the value is unambiguous in the row.
|
||||
return if (value is String && clipped.any { it.isWhitespace() }) "\"$clipped\"" else clipped
|
||||
}
|
||||
|
||||
private const val MAX_PARAM_CELLS = 5
|
||||
private const val MAX_PARAM_VALUE_LEN = 80
|
||||
@@ -60,7 +60,8 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore {
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId)
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents()
|
||||
override fun allSessionIds(): Set<SessionId> = delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) }
|
||||
override fun allSessionIds(): Set<SessionId> =
|
||||
delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) }
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = delegate.subscribeAll()
|
||||
|
||||
|
||||
@@ -143,14 +143,28 @@ class RepoMapIndexer(
|
||||
|
||||
// Top-level declarations only — best-effort per language. Bodies/locals are never matched.
|
||||
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
|
||||
"kt" to Regex("""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
|
||||
"java" to Regex("""(?m)^\s*(?:public |private |protected |final |abstract |static )*(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
|
||||
"kt" to Regex(
|
||||
"""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*""" +
|
||||
"""(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
|
||||
),
|
||||
"java" to Regex(
|
||||
"""(?m)^\s*(?:public |private |protected |final |abstract |static )*""" +
|
||||
"""(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
|
||||
),
|
||||
"py" to Regex("""(?m)^(?:def|class)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
|
||||
"go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
|
||||
"js" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
|
||||
"ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
|
||||
"js" to Regex(
|
||||
"""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+""" +
|
||||
"""(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
|
||||
),
|
||||
"ts" to Regex(
|
||||
"""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+""" +
|
||||
"""(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
|
||||
),
|
||||
// GDScript: column-0 declarations only, so indented inner-class members never match.
|
||||
"gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
|
||||
"gd" to Regex(
|
||||
"""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
|
||||
),
|
||||
// C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative
|
||||
// (no method capture — return-type heuristics there are noisy and risk false positives).
|
||||
"cs" to Regex(
|
||||
|
||||
+12
-4
@@ -116,11 +116,13 @@ class NarrationSubscriber(
|
||||
closeLane(sid)
|
||||
}
|
||||
is WorkflowFailedEvent -> {
|
||||
val instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. " +
|
||||
"Explain the failure to the user."
|
||||
enqueue(
|
||||
sid,
|
||||
NarrationTrigger(
|
||||
kind = "workflow_failed",
|
||||
instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. Explain the failure to the user.",
|
||||
instruction = instruction,
|
||||
stageId = p.stageId.value,
|
||||
),
|
||||
)
|
||||
@@ -139,21 +141,27 @@ class NarrationSubscriber(
|
||||
),
|
||||
)
|
||||
}
|
||||
is ExecutionPlanRejectedEvent -> enqueue(
|
||||
is ExecutionPlanRejectedEvent -> {
|
||||
val instruction = "The execution plan was rejected (${p.source}): ${p.reason}. " +
|
||||
"Explain to the user what happened and what they can do next."
|
||||
enqueue(
|
||||
sid,
|
||||
NarrationTrigger(
|
||||
kind = "plan_rejected",
|
||||
instruction = "The execution plan was rejected (${p.source}): ${p.reason}. Explain to the user what happened and what they can do next.",
|
||||
instruction = instruction,
|
||||
),
|
||||
)
|
||||
}
|
||||
is OrchestrationPausedEvent -> {
|
||||
// Approval pauses are narrated from ApprovalRequestedEvent above.
|
||||
if (!p.reason.contains("APPROVAL", ignoreCase = true)) {
|
||||
val instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. " +
|
||||
"Inform the user."
|
||||
enqueue(
|
||||
sid,
|
||||
NarrationTrigger(
|
||||
kind = "paused",
|
||||
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.",
|
||||
instruction = instruction,
|
||||
stageId = p.stageId.value,
|
||||
),
|
||||
pauseKey = SESSION_PAUSE_KEY,
|
||||
|
||||
+42
-13
@@ -63,20 +63,49 @@ class ReplayInspectionService(private val eventStore: EventStore) {
|
||||
|
||||
private fun toTimelineEntry(sequence: Long, payload: EventPayload, type: String): TimelineEntry =
|
||||
when (payload) {
|
||||
is WorkflowStartedEvent -> TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}")
|
||||
is StageCompletedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}")
|
||||
is StageFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value} — ${payload.reason}")
|
||||
is WorkflowStartedEvent ->
|
||||
TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}")
|
||||
is StageCompletedEvent ->
|
||||
TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}")
|
||||
is StageFailedEvent -> TimelineEntry(
|
||||
sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value} — ${payload.reason}",
|
||||
)
|
||||
is ToolExecutionCompletedEvent -> TimelineEntry(sequence, type, null, "Tool executed: ${payload.toolName}")
|
||||
is ToolExecutionFailedEvent -> TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName} — ${payload.reason}")
|
||||
is ToolExecutionRejectedEvent -> TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName} — ${payload.reason}")
|
||||
is ApprovalRequestedEvent -> TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}")
|
||||
is ApprovalDecisionResolvedEvent -> TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}")
|
||||
is OrchestrationPausedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration paused at ${payload.stageId.value}: ${payload.reason}")
|
||||
is OrchestrationResumedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}")
|
||||
is RetryAttemptedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at ${payload.stageId.value}: ${payload.failureReason}")
|
||||
is TransitionExecutedEvent -> TimelineEntry(sequence, type, payload.from.value, "Transition ${payload.from.value} → ${payload.to.value}")
|
||||
is WorkflowCompletedEvent -> TimelineEntry(sequence, type, payload.terminalStageId.value, "Workflow completed after ${payload.totalStages} stages")
|
||||
is WorkflowFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}")
|
||||
is ToolExecutionFailedEvent ->
|
||||
TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName} — ${payload.reason}")
|
||||
is ToolExecutionRejectedEvent ->
|
||||
TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName} — ${payload.reason}")
|
||||
is ApprovalRequestedEvent ->
|
||||
TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}")
|
||||
is ApprovalDecisionResolvedEvent ->
|
||||
TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}")
|
||||
is OrchestrationPausedEvent -> TimelineEntry(
|
||||
sequence,
|
||||
type,
|
||||
payload.stageId.value,
|
||||
"Orchestration paused at ${payload.stageId.value}: ${payload.reason}",
|
||||
)
|
||||
is OrchestrationResumedEvent -> TimelineEntry(
|
||||
sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}",
|
||||
)
|
||||
is RetryAttemptedEvent -> TimelineEntry(
|
||||
sequence,
|
||||
type,
|
||||
payload.stageId.value,
|
||||
"Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at " +
|
||||
"${payload.stageId.value}: ${payload.failureReason}",
|
||||
)
|
||||
is TransitionExecutedEvent -> TimelineEntry(
|
||||
sequence, type, payload.from.value, "Transition ${payload.from.value} → ${payload.to.value}",
|
||||
)
|
||||
is WorkflowCompletedEvent -> TimelineEntry(
|
||||
sequence,
|
||||
type,
|
||||
payload.terminalStageId.value,
|
||||
"Workflow completed after ${payload.totalStages} stages",
|
||||
)
|
||||
is WorkflowFailedEvent ->
|
||||
TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}")
|
||||
else -> TimelineEntry(sequence, type, null, type)
|
||||
}
|
||||
|
||||
|
||||
@@ -566,7 +566,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
suspend fun requireToolName(scopeLabel: String): String? =
|
||||
msg.toolName?.takeIf { it.isNotBlank() }
|
||||
?: run {
|
||||
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
|
||||
val errMsg = "CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"
|
||||
sendFrame(errorResponse(errMsg))
|
||||
null
|
||||
}
|
||||
|
||||
@@ -683,7 +684,9 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
// Bind the per-repo project profile so router triage can cite conventions/commands.
|
||||
// Parity with launchSessionRun — chat sessions previously never bound a profile.
|
||||
runCatching { module.bindProjectProfile(sessionId) }
|
||||
.onFailure { log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) }
|
||||
.onFailure {
|
||||
log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message)
|
||||
}
|
||||
|
||||
withSessionContext(sessionId) {
|
||||
runCatching {
|
||||
|
||||
@@ -102,7 +102,12 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
),
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it)
|
||||
log.error(
|
||||
"routerFacade.onUserInput failed for session={}: {}",
|
||||
msg.sessionId.value,
|
||||
it.message,
|
||||
it,
|
||||
)
|
||||
val error = ServerMessage.ProtocolError("Router error: ${it.message}")
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||
}
|
||||
|
||||
@@ -325,7 +325,9 @@ class DomainEventMapperTest {
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L),
|
||||
ServerMessage.ToolStarted(
|
||||
sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L,
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
@@ -436,7 +438,8 @@ class DomainEventMapperTest {
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
|
||||
val result =
|
||||
domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
|
||||
assertEquals(requestId, result.requestId)
|
||||
assertEquals(Tier.T3, result.tier)
|
||||
assertNull(result.toolName)
|
||||
@@ -470,11 +473,15 @@ class DomainEventMapperTest {
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
|
||||
val result =
|
||||
domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
|
||||
assertEquals(requestId, result.requestId)
|
||||
assertEquals("HIGH", result.riskSummary.level)
|
||||
assertEquals("PROMPT_USER", result.riskSummary.recommendedAction)
|
||||
assertEquals(listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), result.riskSummary.rationale)
|
||||
assertEquals(
|
||||
listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"),
|
||||
result.riskSummary.rationale,
|
||||
)
|
||||
assertEquals(2, result.riskSummary.factors.size)
|
||||
assertTrue(result.riskSummary.factors[0].contains("Validation errors"))
|
||||
assertTrue(result.riskSummary.factors[1].contains("Repeated failure"))
|
||||
@@ -495,7 +502,8 @@ class DomainEventMapperTest {
|
||||
userSteering = null,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved
|
||||
val result =
|
||||
domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved
|
||||
assertEquals(sessionId, result.sessionId)
|
||||
assertEquals(requestId, result.requestId)
|
||||
assertEquals("APPROVED", result.outcome)
|
||||
@@ -519,7 +527,8 @@ class DomainEventMapperTest {
|
||||
userSteering = null,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved
|
||||
val result =
|
||||
domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved
|
||||
assertEquals("REJECTED", result.outcome)
|
||||
assertNull(result.reason)
|
||||
}
|
||||
|
||||
+36
-6
@@ -325,8 +325,14 @@ class SessionEventBridgeTest {
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
// WorkflowStartedEvent is filtered out by eventToEntry (maps to null)
|
||||
assertEquals(3, snapshot.recentEvents.size)
|
||||
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"), snapshot.recentEvents[0])
|
||||
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value), snapshot.recentEvents[1])
|
||||
assertEquals(
|
||||
EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"),
|
||||
snapshot.recentEvents[0],
|
||||
)
|
||||
assertEquals(
|
||||
EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value),
|
||||
snapshot.recentEvents[1],
|
||||
)
|
||||
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "SessionCompleted", ""), snapshot.recentEvents[2])
|
||||
}
|
||||
|
||||
@@ -359,7 +365,13 @@ class SessionEventBridgeTest {
|
||||
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
|
||||
val store = fakeEventStore(allEventsList = emptyList())
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
assertEquals(1, sent.size)
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent[0])
|
||||
@@ -370,7 +382,13 @@ class SessionEventBridgeTest {
|
||||
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent.last())
|
||||
}
|
||||
@@ -385,7 +403,13 @@ class SessionEventBridgeTest {
|
||||
override suspend fun lastGlobalSequence(): Long = 5L
|
||||
}
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
assertEquals(5L, snapshot.lastSequence)
|
||||
@@ -399,7 +423,13 @@ class SessionEventBridgeTest {
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
assertEquals(3L, snapshot.lastSessionSequence)
|
||||
|
||||
@@ -257,7 +257,10 @@ class FreestyleDriverTest {
|
||||
assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint")
|
||||
|
||||
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||
assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}")
|
||||
assertTrue(
|
||||
lint.hardFailures.any { it.code == "unproduced_need" },
|
||||
"expected unproduced_need: ${lint.hardFailures}",
|
||||
)
|
||||
|
||||
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
|
||||
assertEquals("lint", rejected.source)
|
||||
|
||||
+2
-1
@@ -175,7 +175,8 @@ class ModelLifecycleLiveTest {
|
||||
val raw = frame.readText()
|
||||
when (val msg = decodeServerMessage(raw)) {
|
||||
is ServerMessage.ModelChanged -> modelChanged = msg
|
||||
is ServerMessage.ProtocolError -> error("Unexpected protocol error during swap: ${msg.message}")
|
||||
is ServerMessage.ProtocolError ->
|
||||
error("Unexpected protocol error during swap: ${msg.message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -29,7 +29,11 @@ private class OnesEmbedder(override val dimension: Int = 8) : Embedder {
|
||||
|
||||
class ProjectMemoryServiceTest {
|
||||
|
||||
private fun store(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload, es: InMemoryEventStore) =
|
||||
private fun store(
|
||||
sessionId: SessionId,
|
||||
payload: com.correx.core.events.events.EventPayload,
|
||||
es: InMemoryEventStore,
|
||||
) =
|
||||
runBlocking {
|
||||
es.append(
|
||||
NewEvent(
|
||||
@@ -58,7 +62,11 @@ class ProjectMemoryServiceTest {
|
||||
|
||||
val sessionA = SessionId("A")
|
||||
store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore)
|
||||
store(sessionA, TransitionExecutedEvent(sessionA, StageId("plan"), StageId("impl"), TransitionId("t1")), eventStore)
|
||||
store(
|
||||
sessionA,
|
||||
TransitionExecutedEvent(sessionA, StageId("plan"), StageId("impl"), TransitionId("t1")),
|
||||
eventStore,
|
||||
)
|
||||
|
||||
service(config, eventStore, l3).persist(sessionA, "/repo")
|
||||
|
||||
@@ -67,7 +75,10 @@ class ProjectMemoryServiceTest {
|
||||
|
||||
assertTrue(seeded.any { it.contains("use jwt for auth") }, "expected prior decision retrieved")
|
||||
val note = eventStore.read(sessionB).mapNotNull { it.payload as? SteeringNoteAddedEvent }.firstOrNull()
|
||||
assertTrue(note != null && note.content.contains("Project memory"), "expected seeded steering note in session B")
|
||||
assertTrue(
|
||||
note != null && note.content.contains("Project memory"),
|
||||
"expected seeded steering note in session B",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -109,7 +109,9 @@ class NarrationSubscriberTest {
|
||||
|
||||
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
|
||||
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
|
||||
liveFlow.emit(storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L))
|
||||
liveFlow.emit(
|
||||
storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L),
|
||||
)
|
||||
liveFlow.emit(storedEvent(WorkflowCompletedEvent(sessionId, StageId("c"), totalStages = 2), seq = 4L))
|
||||
|
||||
withTimeout(2_000L) {
|
||||
|
||||
+3
-1
@@ -282,7 +282,9 @@ class ServerMessageSerializationTest {
|
||||
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
|
||||
assert(jsonStr.contains("\"type\":\"workflow.proposed\"")) { "expected type=workflow.proposed" }
|
||||
assert(jsonStr.contains("\"workflowId\":\"research\"")) { "expected candidate id" }
|
||||
assert(jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\"")) { "expected original request" }
|
||||
assert(
|
||||
jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\""),
|
||||
) { "expected original request" }
|
||||
|
||||
val decoded = json.decodeFromString<ServerMessage.WorkflowProposed>(jsonStr)
|
||||
assertEquals(2, decoded.candidates.size)
|
||||
|
||||
+4
-2
@@ -56,8 +56,10 @@ class FileSystemWorkflowRegistryTest {
|
||||
|
||||
@Test
|
||||
fun `listAll falls back to workflowId when description absent`() {
|
||||
dir.resolve("healthcheck.toml").writeText(validToml.replace("description = \"Runs the health check pipeline\"\n", ""))
|
||||
assertEquals("healthcheck", FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll().single().description)
|
||||
val noDescToml = validToml.replace("description = \"Runs the health check pipeline\"\n", "")
|
||||
dir.resolve("healthcheck.toml").writeText(noDescToml)
|
||||
val registry = FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir)
|
||||
assertEquals("healthcheck", registry.listAll().single().description)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+4
-2
@@ -87,10 +87,12 @@ class ReplayInspectionServiceTest {
|
||||
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
|
||||
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId }
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId }
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
|
||||
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
|
||||
override fun lastSequence(sessionId: SessionId): Long? =
|
||||
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
override suspend fun lastGlobalSequence(): Long = 0L
|
||||
|
||||
+12
-4
@@ -86,18 +86,26 @@ class EventInspectRoutesTest {
|
||||
)
|
||||
|
||||
private val seedEvents = listOf(
|
||||
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"), 1L),
|
||||
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"), 2L),
|
||||
storedEvent(
|
||||
WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"),
|
||||
1L,
|
||||
),
|
||||
storedEvent(
|
||||
WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"),
|
||||
2L,
|
||||
),
|
||||
storedEvent(InitialIntentEvent(sessionId = s1, intent = "build the thing"), 3L),
|
||||
)
|
||||
|
||||
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
|
||||
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId }
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId }
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
|
||||
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
|
||||
override fun lastSequence(sessionId: SessionId): Long? =
|
||||
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
override suspend fun lastGlobalSequence(): Long = 0L
|
||||
|
||||
@@ -180,7 +180,11 @@ class SessionUndoServiceTest {
|
||||
val noWorkspaceEventStore = FakeEventStore(listOf(storedEvent(fileWritten, sessionId, 1L)))
|
||||
val serviceBootOnly = SessionUndoService(noWorkspaceEventStore, storeNoWorkspace, setOf(bootDir))
|
||||
val summaryBootOnly = serviceBootOnly.undo(sessionId)
|
||||
assertEquals(1, summaryBootOnly.rejected, "boot-only roots must jail the workspace-dir file when no bound event exists")
|
||||
assertEquals(
|
||||
1,
|
||||
summaryBootOnly.rejected,
|
||||
"boot-only roots must jail the workspace-dir file when no bound event exists",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,7 +121,13 @@ class GlobalStreamHandlerTest {
|
||||
orchRepo: OrchestrationRepository,
|
||||
): Pair<SessionEventBridge, Channel<ServerMessage>> {
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, noopWorkflowRegistry, noopToolRegistry) { received.send(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
orchRepo,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { received.send(it) }
|
||||
return bridge to received
|
||||
}
|
||||
|
||||
@@ -167,7 +173,13 @@ class GlobalStreamHandlerTest {
|
||||
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 64)
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) {
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
idleOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) {
|
||||
received.send(it)
|
||||
}
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
@@ -209,7 +221,13 @@ class GlobalStreamHandlerTest {
|
||||
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 16)
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) {
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
idleOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) {
|
||||
received.send(it)
|
||||
}
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
@@ -257,7 +275,13 @@ class GlobalStreamHandlerTest {
|
||||
return 0L
|
||||
}
|
||||
}
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
idleOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { }
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
|
||||
val job = launch {
|
||||
@@ -274,7 +298,13 @@ class GlobalStreamHandlerTest {
|
||||
fun `shutdown - cancel outer scope ends subscription with no leaks`() = runTest {
|
||||
val liveFlow = MutableSharedFlow<StoredEvent>()
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
idleOrchestrationRepository(),
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { }
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
|
||||
val job = launch {
|
||||
|
||||
@@ -250,7 +250,8 @@ class WorkspaceHandshakeTest {
|
||||
delay(50)
|
||||
|
||||
// Phase 2: start a session
|
||||
send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null))))
|
||||
val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)
|
||||
send(Frame.Text(encode(startMsg)))
|
||||
|
||||
// Give the server time to process the event emission (before orchestrator runs)
|
||||
delay(200)
|
||||
@@ -298,7 +299,8 @@ class WorkspaceHandshakeTest {
|
||||
delay(50)
|
||||
|
||||
// StartSession — this closes the Hello window
|
||||
send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null))))
|
||||
val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)
|
||||
send(Frame.Text(encode(startMsg)))
|
||||
delay(100)
|
||||
|
||||
// Late Hello with a different path — must be ignored
|
||||
@@ -334,7 +336,8 @@ class WorkspaceHandshakeTest {
|
||||
delay(100)
|
||||
|
||||
// No Hello — send StartSession directly
|
||||
send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null))))
|
||||
val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)
|
||||
send(Frame.Text(encode(startMsg)))
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ subprojects {
|
||||
apply plugin: "io.gitlab.arturbosch.detekt"
|
||||
apply plugin: "org.jetbrains.kotlinx.kover"
|
||||
|
||||
// Unique jar base names from the full project path (core:inference -> core-inference).
|
||||
// Sibling modules share leaf names (core:inference vs infrastructure:inference, core:tools
|
||||
// vs infrastructure:tools), which collide in the flat application-plugin lib dir and drop
|
||||
// classes at runtime. Derive per-path names so every jar lands distinctly.
|
||||
tasks.withType(Jar).configureEach {
|
||||
archiveBaseName = project.path.substring(1).replace(':', '-')
|
||||
}
|
||||
|
||||
detekt {
|
||||
toolVersion = "1.23.7"
|
||||
config.setFrom("$rootDir/detekt.yml")
|
||||
|
||||
@@ -19,7 +19,11 @@ package com.correx.core.artifacts.kind
|
||||
object KindContractTable {
|
||||
|
||||
/** A single assertion template — a kind's requirement before it is bound to a concrete path. */
|
||||
private data class Template(val id: String, val evaluator: AssertionEvaluator, val args: Map<String, String> = emptyMap())
|
||||
private data class Template(
|
||||
val id: String,
|
||||
val evaluator: AssertionEvaluator,
|
||||
val args: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
private val FLOOR = listOf(
|
||||
Template("file_exists", AssertionEvaluator.FS),
|
||||
|
||||
@@ -104,7 +104,10 @@ internal object SimpleToml {
|
||||
ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true }
|
||||
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) }
|
||||
ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) }
|
||||
ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() }
|
||||
ch == ',' && !inQuotes -> {
|
||||
result.add(stripQuotes(current.toString().trim()))
|
||||
current = StringBuilder()
|
||||
}
|
||||
else -> current.append(ch)
|
||||
}
|
||||
}
|
||||
@@ -181,6 +184,46 @@ object ProfileLoader {
|
||||
}
|
||||
|
||||
object ConfigLoader {
|
||||
private const val DEFAULT_SERVER_PORT = 8080
|
||||
private const val DEFAULT_SESSION_LIST_LIMIT = 5
|
||||
private const val DEFAULT_EMBEDDER_DIMENSION = 1536
|
||||
private const val DEFAULT_L3_DIM = 1536
|
||||
private const val DEFAULT_L3_BIT_WIDTH = 4
|
||||
private const val DEFAULT_GENERATION_TEMPERATURE = 0.7
|
||||
private const val DEFAULT_GENERATION_TOP_P = 0.9
|
||||
private const val DEFAULT_GENERATION_MAX_TOKENS = 512
|
||||
private const val DEFAULT_NARRATION_TEMPERATURE = 0.7
|
||||
private const val DEFAULT_NARRATION_TOP_P = 0.9
|
||||
private const val DEFAULT_NARRATION_MAX_TOKENS = 4096
|
||||
private const val DEFAULT_NARRATION_MAX_PER_RUN = 100
|
||||
private const val DEFAULT_CONVERSATION_KEEP_LAST = 6
|
||||
private const val DEFAULT_RETRIEVAL_K = 5
|
||||
private const val DEFAULT_TOKEN_BUDGET = 4096
|
||||
private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192
|
||||
private const val DEFAULT_PROJECT_MEMORY_K = 5
|
||||
private const val DEFAULT_PROJECT_MAX_DEPTH = 4
|
||||
private const val DEFAULT_PROJECT_INJECT_TOP_K = 30
|
||||
private const val DEFAULT_STAGE_TIMEOUT_MS = 180_000L
|
||||
private const val DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD = 2_000
|
||||
private const val DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES = 1_440L
|
||||
private const val DEFAULT_COMPRESSION_LEVEL = 4
|
||||
private const val DEFAULT_MAX_TOOL_ROUNDS = 30
|
||||
private const val DEFAULT_READ_LOOP_NUDGE_THRESHOLD = 3
|
||||
private const val DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD = 3
|
||||
private const val DEFAULT_MAX_FEEDBACK_ISSUES = 3
|
||||
private const val DEFAULT_REPO_MAP_INJECT_TOP_K = 30
|
||||
private const val DEFAULT_REPO_MAP_FILES_PER_DIR = 8
|
||||
private const val DEFAULT_DOCS_CATALOG_MAX = 20
|
||||
private const val DEFAULT_MAX_CLARIFICATION_ROUNDS = 3
|
||||
private const val DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
|
||||
private const val DEFAULT_REVIEW_BLOCK_RETRY_CAP = 20
|
||||
private const val DEFAULT_MAX_REFINEMENT = 3
|
||||
private const val DEFAULT_RECOVERY_ROUTE_BUDGET = 2
|
||||
private const val DEFAULT_INTENT_ROUTE_BUDGET = 2
|
||||
private const val DEFAULT_MODELS_PORT = 10000
|
||||
private const val DEFAULT_SAMPLING_TOP_P = 1.0
|
||||
private const val DEFAULT_SAMPLING_TEMPERATURE = 0.7
|
||||
|
||||
fun load(): CorrexConfig {
|
||||
val path = configPath()
|
||||
if (!Files.exists(path)) {
|
||||
@@ -302,7 +345,7 @@ object ConfigLoader {
|
||||
}
|
||||
// JSON-style array: ["item1", "item2"]
|
||||
valueStr.startsWith("[") && valueStr.endsWith("]") -> {
|
||||
parseArray(valueStr, lineNum)
|
||||
parseArray(valueStr)
|
||||
}
|
||||
// CSV fallback for backward compat (detect by "," and "=" pattern without brackets)
|
||||
valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> {
|
||||
@@ -336,7 +379,7 @@ object ConfigLoader {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseArray(arrayStr: String, lineNum: Int): List<String> {
|
||||
private fun parseArray(arrayStr: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val content = arrayStr.substring(1, arrayStr.length - 1).trim()
|
||||
if (content.isEmpty()) return result
|
||||
@@ -430,12 +473,12 @@ object ConfigLoader {
|
||||
|
||||
val server = ServerConfig(
|
||||
host = asString(serverSection["host"], "localhost"),
|
||||
port = asInt(serverSection["port"], 8080),
|
||||
port = asInt(serverSection["port"], DEFAULT_SERVER_PORT),
|
||||
)
|
||||
|
||||
val tui = TuiConfig(
|
||||
theme = asString(tuiSection["theme"], "dark"),
|
||||
sessionListLimit = asInt(tuiSection["session_list_limit"], 5),
|
||||
sessionListLimit = asInt(tuiSection["session_list_limit"], DEFAULT_SESSION_LIST_LIMIT),
|
||||
)
|
||||
|
||||
val cli = CliConfig(
|
||||
@@ -447,7 +490,9 @@ object ConfigLoader {
|
||||
val shellEnabled = when {
|
||||
toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true)
|
||||
toolsSection.containsKey("shell_enabled") -> {
|
||||
System.err.println("Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead")
|
||||
System.err.println(
|
||||
"Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead"
|
||||
)
|
||||
asBoolean(toolsSection["shell_enabled"], true)
|
||||
}
|
||||
else -> true
|
||||
@@ -456,7 +501,9 @@ object ConfigLoader {
|
||||
val fileReadEnabled = when {
|
||||
toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true)
|
||||
toolsSection.containsKey("file_read_enabled") -> {
|
||||
System.err.println("Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead")
|
||||
System.err.println(
|
||||
"Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead"
|
||||
)
|
||||
asBoolean(toolsSection["file_read_enabled"], true)
|
||||
}
|
||||
else -> true
|
||||
@@ -465,7 +512,9 @@ object ConfigLoader {
|
||||
val fileWriteEnabled = when {
|
||||
toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true)
|
||||
toolsSection.containsKey("file_write_enabled") -> {
|
||||
System.err.println("Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead")
|
||||
System.err.println(
|
||||
"Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead"
|
||||
)
|
||||
asBoolean(toolsSection["file_write_enabled"], true)
|
||||
}
|
||||
else -> true
|
||||
@@ -474,7 +523,9 @@ object ConfigLoader {
|
||||
val fileEditEnabled = when {
|
||||
toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true)
|
||||
toolsSection.containsKey("file_edit_enabled") -> {
|
||||
System.err.println("Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead")
|
||||
System.err.println(
|
||||
"Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead"
|
||||
)
|
||||
asBoolean(toolsSection["file_edit_enabled"], true)
|
||||
}
|
||||
else -> true
|
||||
@@ -491,7 +542,9 @@ object ConfigLoader {
|
||||
val1 is String -> {
|
||||
// Backward compat: check if it's CSV or already a single item
|
||||
if (val1.contains(",")) {
|
||||
System.err.println("Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead")
|
||||
System.err.println(
|
||||
"Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead"
|
||||
)
|
||||
val1.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
} else {
|
||||
listOf(val1)
|
||||
@@ -547,7 +600,7 @@ object ConfigLoader {
|
||||
|
||||
val embedder = EmbedderConfig(
|
||||
backend = asString(routerEmbedderSection["backend"], "noop"),
|
||||
dimension = asInt(routerEmbedderSection["dimension"], 1536),
|
||||
dimension = asInt(routerEmbedderSection["dimension"], DEFAULT_EMBEDDER_DIMENSION),
|
||||
url = asStringOrNull(routerEmbedderSection["url"]),
|
||||
modelId = asStringOrNull(routerEmbedderSection["model_id"]),
|
||||
)
|
||||
@@ -557,30 +610,30 @@ object ConfigLoader {
|
||||
persistPath = asStringOrNull(routerL3Section["persist_path"]),
|
||||
pythonExecutable = asString(routerL3Section["python_executable"], "python3"),
|
||||
scriptPath = asStringOrNull(routerL3Section["script_path"]),
|
||||
dim = asInt(routerL3Section["dim"], 1536),
|
||||
bitWidth = asInt(routerL3Section["bit_width"], 4),
|
||||
dim = asInt(routerL3Section["dim"], DEFAULT_L3_DIM),
|
||||
bitWidth = asInt(routerL3Section["bit_width"], DEFAULT_L3_BIT_WIDTH),
|
||||
)
|
||||
|
||||
val generation = GenerationSettings(
|
||||
temperature = asDouble(routerGenerationSection["temperature"], 0.7),
|
||||
topP = asDouble(routerGenerationSection["top_p"], 0.9),
|
||||
maxTokens = asInt(routerGenerationSection["max_tokens"], 512),
|
||||
temperature = asDouble(routerGenerationSection["temperature"], DEFAULT_GENERATION_TEMPERATURE),
|
||||
topP = asDouble(routerGenerationSection["top_p"], DEFAULT_GENERATION_TOP_P),
|
||||
maxTokens = asInt(routerGenerationSection["max_tokens"], DEFAULT_GENERATION_MAX_TOKENS),
|
||||
)
|
||||
|
||||
val narration = NarrationSettings(
|
||||
temperature = asDouble(routerNarrationSection["temperature"], 0.7),
|
||||
topP = asDouble(routerNarrationSection["top_p"], 0.9),
|
||||
maxTokens = asInt(routerNarrationSection["max_tokens"], 4096),
|
||||
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100),
|
||||
temperature = asDouble(routerNarrationSection["temperature"], DEFAULT_NARRATION_TEMPERATURE),
|
||||
topP = asDouble(routerNarrationSection["top_p"], DEFAULT_NARRATION_TOP_P),
|
||||
maxTokens = asInt(routerNarrationSection["max_tokens"], DEFAULT_NARRATION_MAX_TOKENS),
|
||||
maxPerRun = asInt(routerNarrationSection["max_per_run"], DEFAULT_NARRATION_MAX_PER_RUN),
|
||||
modelId = asStringOrNull(routerNarrationSection["model_id"]),
|
||||
)
|
||||
|
||||
val router = TalkieConfig(
|
||||
embedder = embedder,
|
||||
l3 = l3,
|
||||
conversationKeepLast = asInt(routerSection["conversation_keep_last"], 6),
|
||||
retrievalK = asInt(routerSection["retrieval_k"], 5),
|
||||
tokenBudget = asInt(routerSection["token_budget"], 4096),
|
||||
conversationKeepLast = asInt(routerSection["conversation_keep_last"], DEFAULT_CONVERSATION_KEEP_LAST),
|
||||
retrievalK = asInt(routerSection["retrieval_k"], DEFAULT_RETRIEVAL_K),
|
||||
tokenBudget = asInt(routerSection["token_budget"], DEFAULT_TOKEN_BUDGET),
|
||||
generation = generation,
|
||||
narration = narration,
|
||||
)
|
||||
@@ -588,7 +641,7 @@ object ConfigLoader {
|
||||
val models = modelsList.mapNotNull { modelMap ->
|
||||
val id = asString(modelMap["id"]) ?: return@mapNotNull null
|
||||
val modelPath = asString(modelMap["model_path"]) ?: return@mapNotNull null
|
||||
val contextSize = asInt(modelMap["context_size"], 8192)
|
||||
val contextSize = asInt(modelMap["context_size"], DEFAULT_MODEL_CONTEXT_SIZE)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = (modelMap["params"] as? Map<String, Any>)
|
||||
?.mapValues { it.value.toString() }
|
||||
@@ -614,42 +667,53 @@ object ConfigLoader {
|
||||
val project = ProjectConfig(
|
||||
enabled = asBoolean(projectSection["enabled"], false),
|
||||
root = asString(projectSection["root"], ""),
|
||||
memoryK = asInt(projectSection["memory_k"], 5),
|
||||
maxDepth = asInt(projectSection["max_depth"], 4),
|
||||
memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K),
|
||||
maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH),
|
||||
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES },
|
||||
injectTopK = asInt(projectSection["inject_top_k"], 30),
|
||||
injectTopK = asInt(projectSection["inject_top_k"], DEFAULT_PROJECT_INJECT_TOP_K),
|
||||
)
|
||||
|
||||
val orchestrationSection = sections["orchestration"] ?: emptyMap()
|
||||
val orchestration = OrchestrationKnobs(
|
||||
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], 180_000),
|
||||
journalCompactionTokenThreshold =
|
||||
asInt(orchestrationSection["journal_compaction_token_threshold"], 2_000),
|
||||
resumeAbandonedMaxAgeMinutes =
|
||||
asLong(orchestrationSection["resume_abandoned_max_age_minutes"], 1_440),
|
||||
compressionLevel = asInt(orchestrationSection["compression_level"], 4),
|
||||
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], DEFAULT_STAGE_TIMEOUT_MS),
|
||||
journalCompactionTokenThreshold = asInt(
|
||||
orchestrationSection["journal_compaction_token_threshold"],
|
||||
DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD,
|
||||
),
|
||||
resumeAbandonedMaxAgeMinutes = asLong(
|
||||
orchestrationSection["resume_abandoned_max_age_minutes"],
|
||||
DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES,
|
||||
),
|
||||
compressionLevel = asInt(orchestrationSection["compression_level"], DEFAULT_COMPRESSION_LEVEL),
|
||||
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),
|
||||
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], DEFAULT_MAX_TOOL_ROUNDS),
|
||||
readLoopNudgeThreshold =
|
||||
asInt(orchestrationSection["read_loop_nudge_threshold"], DEFAULT_READ_LOOP_NUDGE_THRESHOLD),
|
||||
rejectionLoopNudgeThreshold = asInt(
|
||||
orchestrationSection["rejection_loop_nudge_threshold"],
|
||||
DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD,
|
||||
),
|
||||
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], DEFAULT_MAX_FEEDBACK_ISSUES),
|
||||
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], DEFAULT_REPO_MAP_INJECT_TOP_K),
|
||||
repoMapFilesPerDir =
|
||||
asInt(orchestrationSection["repo_map_files_per_dir"], DEFAULT_REPO_MAP_FILES_PER_DIR),
|
||||
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], DEFAULT_DOCS_CATALOG_MAX),
|
||||
maxClarificationRounds =
|
||||
asInt(orchestrationSection["max_clarification_rounds"], DEFAULT_MAX_CLARIFICATION_ROUNDS),
|
||||
reviewBlockMinConfidence =
|
||||
asDouble(orchestrationSection["review_block_min_confidence"], DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE),
|
||||
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], DEFAULT_REVIEW_BLOCK_RETRY_CAP),
|
||||
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], DEFAULT_MAX_REFINEMENT),
|
||||
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], DEFAULT_RECOVERY_ROUTE_BUDGET),
|
||||
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], DEFAULT_INTENT_ROUTE_BUDGET),
|
||||
)
|
||||
|
||||
val modelsSettings = ModelsSettings(
|
||||
defaultModel = asStringOrNull(modelsSection["default_model"]),
|
||||
llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"),
|
||||
host = asString(modelsSection["host"], "127.0.0.1"),
|
||||
port = asInt(modelsSection["port"], 10000),
|
||||
port = asInt(modelsSection["port"], DEFAULT_MODELS_PORT),
|
||||
)
|
||||
|
||||
val artifacts = artifactsList.mapNotNull { artifactMap ->
|
||||
@@ -664,8 +728,8 @@ object ConfigLoader {
|
||||
|
||||
val samplingSection = sections["sampling"] ?: emptyMap()
|
||||
val sampling = SamplingConfig(
|
||||
temperature = asDouble(samplingSection["temperature"], 0.7),
|
||||
topP = asDouble(samplingSection["top_p"], 1.0),
|
||||
temperature = asDouble(samplingSection["temperature"], DEFAULT_SAMPLING_TEMPERATURE),
|
||||
topP = asDouble(samplingSection["top_p"], DEFAULT_SAMPLING_TOP_P),
|
||||
topK = samplingSection["top_k"]?.let { asInt(it) },
|
||||
minP = samplingSection["min_p"]?.let { asDouble(it) },
|
||||
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
|
||||
|
||||
@@ -184,7 +184,11 @@ object EditableConfig {
|
||||
"orchestration.resume_abandoned_max_age_minutes", ConfigFieldType.LONG,
|
||||
getString = { it.orchestration.resumeAbandonedMaxAgeMinutes.toString() },
|
||||
withString = { c, v ->
|
||||
orc(c) { it.copy(resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)) }
|
||||
orc(c) {
|
||||
it.copy(
|
||||
resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,9 @@ object OperatorProfileWriter {
|
||||
}
|
||||
|
||||
val prefs = profile.preferences
|
||||
val hasPrefs = prefs.approvalMode.isNotBlank() || prefs.preferredModels.isNotEmpty() || prefs.conventions.isNotEmpty()
|
||||
val hasPrefs = prefs.approvalMode.isNotBlank() ||
|
||||
prefs.preferredModels.isNotEmpty() ||
|
||||
prefs.conventions.isNotEmpty()
|
||||
if (hasPrefs) {
|
||||
if (b.isNotEmpty()) b.append('\n')
|
||||
b.append("[preferences]\n")
|
||||
|
||||
+5
-2
@@ -97,8 +97,11 @@ class DefaultContextPackBuilder(
|
||||
// Stage 1 (FORMAT_COMPRESS): lossless json→dotted-line compaction of structured entries.
|
||||
val formatted = if (policy.enabled(CompressionStage.FORMAT_COMPRESS)) {
|
||||
deduped.map { entry ->
|
||||
if (classifier.classify(entry) == ContextClass.STRUCTURED) reencode(entry, formatCompressor.compress(entry.content))
|
||||
else entry
|
||||
if (classifier.classify(entry) == ContextClass.STRUCTURED) {
|
||||
reencode(entry, formatCompressor.compress(entry.content))
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}
|
||||
} else deduped
|
||||
|
||||
|
||||
@@ -142,7 +142,11 @@ class CompressionPipelineStagesTest {
|
||||
// Instruction-doc pruning is retired: curated/procedural docs (project profile, AGENTS.md
|
||||
// how-to) must reach the model verbatim — token-pruning fused them into unparseable soup.
|
||||
val pruner = object : com.correx.core.context.compression.TokenPruner {
|
||||
override suspend fun prune(content: String, protectedSpans: List<String>, targetRatio: Double): String = "DOC-PRUNED"
|
||||
override suspend fun prune(
|
||||
content: String,
|
||||
protectedSpans: List<String>,
|
||||
targetRatio: Double,
|
||||
): String = "DOC-PRUNED"
|
||||
}
|
||||
val builder = com.correx.core.context.builder.DefaultContextPackBuilder(
|
||||
com.correx.core.context.compression.DefaultContextCompressor(), CompressionPolicy(3), tokenPruner = pruner,
|
||||
@@ -155,8 +159,11 @@ class CompressionPipelineStagesTest {
|
||||
entry("agi", ContextLayer.L0, EntryRole.SYSTEM, bigInstructions).copy(sourceType = "agentInstructions"),
|
||||
)
|
||||
val flat = builder.build(
|
||||
com.correx.core.events.types.ContextPackId("p"), com.correx.core.events.types.SessionId("s"),
|
||||
com.correx.core.events.types.StageId("st"), entries, com.correx.core.context.model.TokenBudget(limit = 4000),
|
||||
com.correx.core.events.types.ContextPackId("p"),
|
||||
com.correx.core.events.types.SessionId("s"),
|
||||
com.correx.core.events.types.StageId("st"),
|
||||
entries,
|
||||
com.correx.core.context.model.TokenBudget(limit = 4000),
|
||||
).layers.values.flatten()
|
||||
assertEquals(bigProfile, flat.first { it.sourceType == "projectProfile" }.content)
|
||||
assertEquals("you are an agent", flat.first { it.sourceType == "systemPrompt" }.content)
|
||||
@@ -171,7 +178,10 @@ class CompressionPipelineStagesTest {
|
||||
assertEquals(ContextClass.STRUCTURED, c.classify(entry("toolLog", ContextLayer.L2, EntryRole.TOOL)))
|
||||
assertEquals(ContextClass.STRUCTURED, c.classify(entry("steeringNote", ContextLayer.L2, EntryRole.SYSTEM)))
|
||||
// Assistant tool-call turns are structured history, never token-pruned (amnesia-loop fix).
|
||||
assertEquals(ContextClass.STRUCTURED, c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT)))
|
||||
assertEquals(
|
||||
ContextClass.STRUCTURED,
|
||||
c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT)),
|
||||
)
|
||||
assertEquals(ContextClass.FREEFORM, c.classify(entry("chat", ContextLayer.L1, EntryRole.USER)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ package com.correx.core.approvals
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Serializable
|
||||
enum class Tier(val level: Int) {
|
||||
@SerialName("T0") T0(0),
|
||||
@SerialName("T1") T1(1),
|
||||
@SerialName("T2") T2(2),
|
||||
@SerialName("T3") T3(3),
|
||||
@SerialName("T4") T4(4)
|
||||
enum class Tier {
|
||||
@SerialName("T0") T0,
|
||||
@SerialName("T1") T1,
|
||||
@SerialName("T2") T2,
|
||||
@SerialName("T3") T3,
|
||||
@SerialName("T4") T4;
|
||||
|
||||
/** Escalation level, ordered T0 < T1 < … < T4. Derived from declaration order — no magic numbers. */
|
||||
val level: Int get() = ordinal
|
||||
}
|
||||
|
||||
fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level
|
||||
|
||||
@@ -19,4 +19,8 @@ data class ToolReceipt(
|
||||
val tier: Tier,
|
||||
val timestamp: Instant,
|
||||
val diff: String? = null,
|
||||
// Artifact-store hash of the FULL tool output when it was truncated for model context (the
|
||||
// outputSummary above is a bounded preview). Null when nothing was truncated. Lets an agent
|
||||
// retrieve everything via the tool_output tool without bloating the event log with raw output.
|
||||
val fullOutputHash: String? = null,
|
||||
)
|
||||
|
||||
+5
-1
@@ -28,6 +28,10 @@ class ArtifactRepairEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `ArtifactRepairFailed round-trips`() {
|
||||
roundTrip(ArtifactRepairFailedEvent(ArtifactId("a"), "UNSAFE", "Abort", "path escapes", SessionId("s"), StageId("st")))
|
||||
roundTrip(
|
||||
ArtifactRepairFailedEvent(
|
||||
ArtifactId("a"), "UNSAFE", "Abort", "path escapes", SessionId("s"), StageId("st"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -14,7 +14,11 @@ class PlanLintCompletedEventSerializationTest {
|
||||
sessionId = SessionId("sess-1"),
|
||||
candidateId = "freestyle-sess-1",
|
||||
hardFailures = listOf(
|
||||
PlanLintFinding(code = "unproduced_need", stageId = "implement", detail = "needs 'design', produced by no stage"),
|
||||
PlanLintFinding(
|
||||
code = "unproduced_need",
|
||||
stageId = "implement",
|
||||
detail = "needs 'design', produced by no stage",
|
||||
),
|
||||
PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"),
|
||||
),
|
||||
softFindings = listOf(
|
||||
|
||||
+5
-1
@@ -44,7 +44,11 @@ class RepoKnowledgeRetrievedEventSerializationTest {
|
||||
query = "context builder",
|
||||
hits = listOf(
|
||||
RepoKnowledgeHit(path = "core/context/ContextBuilder.kt", text = "class ContextBuilder", score = 0.95f),
|
||||
RepoKnowledgeHit(path = "core/context/DefaultContextBuilder.kt", text = "class DefaultContextBuilder", score = 0.88f),
|
||||
RepoKnowledgeHit(
|
||||
path = "core/context/DefaultContextBuilder.kt",
|
||||
text = "class DefaultContextBuilder",
|
||||
score = 0.88f,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(),
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ class RepoMapComputedEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `RepoMapComputedEvent without stateKey field decodes with null`() {
|
||||
val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo","entries":[],"computedAt":"2026-06-11T00:00:00Z"}"""
|
||||
val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo",""" +
|
||||
""""entries":[],"computedAt":"2026-06-11T00:00:00Z"}"""
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) as RepoMapComputedEvent
|
||||
assertNull(decoded.stateKey)
|
||||
}
|
||||
|
||||
+6
-1
@@ -23,7 +23,12 @@ class ToolCallAssessedEventSerializationTest {
|
||||
observations = listOf(
|
||||
ToolCallObservation(
|
||||
ruleCode = "PATH_CONTAINMENT",
|
||||
facts = mapOf("path" to "/tmp/x", "exists" to "false", "inWorkspace" to "false", "privileged" to "false"),
|
||||
facts = mapOf(
|
||||
"path" to "/tmp/x",
|
||||
"exists" to "false",
|
||||
"inWorkspace" to "false",
|
||||
"privileged" to "false",
|
||||
),
|
||||
),
|
||||
),
|
||||
disposition = RiskAction.PROMPT_USER,
|
||||
|
||||
@@ -54,7 +54,12 @@ class DecisionJournalRenderer(private val keepLast: Int = 40) {
|
||||
* agents don't act on "Approval APPROVED (tier T2)" or "Advanced x → y", so they're
|
||||
* dropped from the render (still present in [DecisionJournalState.records] for the journal).
|
||||
*/
|
||||
private val AGENT_RELEVANT_KINDS =
|
||||
setOf(DecisionKind.INTENT, DecisionKind.STEERING, DecisionKind.PREEMPT, DecisionKind.FAILURE, DecisionKind.RETRY)
|
||||
private val AGENT_RELEVANT_KINDS = setOf(
|
||||
DecisionKind.INTENT,
|
||||
DecisionKind.STEERING,
|
||||
DecisionKind.PREEMPT,
|
||||
DecisionKind.FAILURE,
|
||||
DecisionKind.RETRY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-547
@@ -5,23 +5,13 @@ import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.journal.DecisionJournalRenderer
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifacts.kind.ArtifactKindRegistry
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.RetrySalvageDecidedEvent
|
||||
import com.correx.core.events.events.SalvageDecision
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
@@ -30,21 +20,15 @@ import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ClarificationRequestId
|
||||
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.orchestration.subagent.InSessionSubagentRunner
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunRequest
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
|
||||
import com.correx.core.kernel.retry.FailureFingerprint
|
||||
import com.correx.core.kernel.retry.RetryCoordinator
|
||||
import com.correx.core.kernel.retry.RetryDecision
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.UUID
|
||||
@@ -70,37 +54,37 @@ private val log = LoggerFactory.getLogger(DefaultSessionOrchestrator::class.java
|
||||
// repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that
|
||||
// opened the ticket (TICKET_RETURN). Both are kernel-driven, not plan edges — see ticketReturnMove.
|
||||
internal val TICKET_ROUTE = TransitionId("ticket-route")
|
||||
private val TICKET_RETURN = TransitionId("ticket-return")
|
||||
internal val TICKET_RETURN = TransitionId("ticket-return")
|
||||
|
||||
// Shortest evidence token treated as a file reference — guards against matching a written path's
|
||||
// suffix on a trivially short token (an extension fragment, a one-char name).
|
||||
private const val MIN_EVIDENCE_TOKEN = 5
|
||||
internal const val MIN_EVIDENCE_TOKEN = 5
|
||||
|
||||
// File-like tokens in gate evidence (tsc/build output): "src/App.tsx", "frontend/x.ts:12:5".
|
||||
private val EVIDENCE_PATH_RE = Regex("""[\w./@-]+\.[A-Za-z0-9]+""")
|
||||
internal val EVIDENCE_PATH_RE = Regex("""[\w./@-]+\.[A-Za-z0-9]+""")
|
||||
|
||||
// Deterministic gate id → capability the failing stage must hold to change the failure condition.
|
||||
// A gate not listed here is not eligible for recovery routing (retries in place as before).
|
||||
private val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
|
||||
internal val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
|
||||
"execution" to "file_write",
|
||||
"contract" to "file_write",
|
||||
"static_analysis" to "file_write",
|
||||
)
|
||||
|
||||
// Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path).
|
||||
private fun ticketCategory(gate: String): String = when (gate) {
|
||||
internal fun ticketCategory(gate: String): String = when (gate) {
|
||||
"plan_compile" -> "planning"
|
||||
else -> "implementation"
|
||||
}
|
||||
|
||||
class DefaultSessionOrchestrator(
|
||||
private val repositories: OrchestratorRepositories,
|
||||
internal val repositories: OrchestratorRepositories,
|
||||
engines: OrchestratorEngines,
|
||||
private val retryCoordinator: RetryCoordinator,
|
||||
internal val retryCoordinator: RetryCoordinator,
|
||||
artifactStore: ArtifactStore,
|
||||
tokenizer: Tokenizer? = null,
|
||||
private val decisionJournalRepository: DefaultDecisionJournalRepository,
|
||||
private val compactionService: JournalCompactionService? = null,
|
||||
decisionJournalRepository: DefaultDecisionJournalRepository,
|
||||
internal val compactionService: JournalCompactionService? = null,
|
||||
artifactKindRegistry: ArtifactKindRegistry? = null,
|
||||
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
||||
readyTaskCounter: ReadyTaskCounter? = null,
|
||||
@@ -114,7 +98,7 @@ class DefaultSessionOrchestrator(
|
||||
// Hybrid-exhaustion salvage judge (T6/T7): consulted only when the "review" gate exhausts its
|
||||
// per-gate retry budget. Null = deterministic allow-one-reset-then-fail fallback (see
|
||||
// decideGateExhaustion) so the feature degrades safely without inference.
|
||||
private val salvageJudge: SalvageJudge? = engines.salvageJudge
|
||||
internal val salvageJudge: SalvageJudge? = engines.salvageJudge
|
||||
|
||||
override val subagentRunner: SubagentRunner = InSessionSubagentRunner(
|
||||
executeStage = { sid, stg, graph, session, cfg ->
|
||||
@@ -140,336 +124,6 @@ class DefaultSessionOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private tailrec suspend fun step(ctx: EnrichedExecutionContext): WorkflowResult {
|
||||
log.debug(
|
||||
"[Orchestrator] step session={} stage={} stageCount={}",
|
||||
ctx.sessionId.value, ctx.currentStageId.value, ctx.stageCount,
|
||||
)
|
||||
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
|
||||
|
||||
val enriched = EnrichedExecutionContext(
|
||||
graph = ctx.graph,
|
||||
sessionId = ctx.sessionId,
|
||||
stageCount = ctx.stageCount,
|
||||
currentStageId = ctx.currentStageId,
|
||||
config = ctx.config,
|
||||
state = orchestrationRepository.getState(ctx.sessionId),
|
||||
session = repositories.sessionRepository.getSession(ctx.sessionId),
|
||||
)
|
||||
|
||||
val stageConfig = enriched.graph.stages[enriched.currentStageId]
|
||||
val targetIds: Set<ArtifactId> = stageConfig?.produces?.map { it.name }?.toSet() ?: emptySet()
|
||||
val stageArtifacts: Map<ArtifactId, ArtifactState> = if (targetIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
val validated = repositories.eventStore.read(enriched.sessionId)
|
||||
.mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId }
|
||||
.filter { it in targetIds }
|
||||
.toSet()
|
||||
validated.associateWith { ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) }
|
||||
}
|
||||
|
||||
val artifactContent: Map<ArtifactId, String> = targetIds.mapNotNull { id ->
|
||||
val cacheKey = "${enriched.sessionId.value}:${id.value}"
|
||||
artifactContentCache[cacheKey]?.let { content -> id to content }
|
||||
}.toMap()
|
||||
|
||||
val resolved = resolveTransition(
|
||||
enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
|
||||
)
|
||||
// Ticket return-to-sender: a repair stage (the file's author, or the fallback recovery stage)
|
||||
// is entered by kernel routing from whichever stage failed a gate, so its exit must go BACK to
|
||||
// that exact gate (re-run it) — not follow a plan edge that would walk a regenerating
|
||||
// implementer or skip intervening stages. Derived from the latest failure ticket routed here
|
||||
// (replay-deterministic). Overrides the resolved edge; an operator preempt below still wins.
|
||||
val baseDecision = ticketReturnMove(enriched) ?: resolved
|
||||
// Preemptive redirect override (freestyle graph re-routing): if the operator confirmed a
|
||||
// jump (a recorded, unconsumed PreemptRedirectEvent), it overrides the resolver's edge —
|
||||
// unless the target is unknown or its needs aren't satisfied, in which case it is blocked
|
||||
// and the normal edge is taken. The decision is pure over the event log (replay-deterministic);
|
||||
// only the block-event emission is a side effect here.
|
||||
val decision = when (
|
||||
val outcome = PreemptRedirect.decide(
|
||||
events = repositories.eventStore.read(enriched.sessionId),
|
||||
graph = enriched.graph,
|
||||
sessionId = enriched.sessionId,
|
||||
artifactAvailable = { id ->
|
||||
!artifactContentCache["${enriched.sessionId.value}:${id.value}"].isNullOrBlank()
|
||||
},
|
||||
nowMs = Clock.System.now().toEpochMilliseconds(),
|
||||
)
|
||||
) {
|
||||
is PreemptRedirect.Outcome.Override -> outcome.move
|
||||
is PreemptRedirect.Outcome.Block -> {
|
||||
emit(enriched.sessionId, outcome.event)
|
||||
log.info(
|
||||
"[Orchestrator] redirect blocked session={} to={} reason={}",
|
||||
enriched.sessionId.value, outcome.event.toStageId.value, outcome.event.reason,
|
||||
)
|
||||
baseDecision
|
||||
}
|
||||
PreemptRedirect.Outcome.None -> baseDecision
|
||||
}
|
||||
log.debug(
|
||||
"[Orchestrator] transition session={} stage={} decision={}",
|
||||
enriched.sessionId.value, enriched.currentStageId.value, decision::class.simpleName,
|
||||
)
|
||||
return when (decision) {
|
||||
is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
|
||||
is StepResult.Continue -> step(r.ctx)
|
||||
is StepResult.Terminal -> r.result
|
||||
}
|
||||
|
||||
is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
|
||||
completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount, enriched.graph.id)
|
||||
} else {
|
||||
// No outgoing edge matched: the stage finished but produced nothing that satisfies a
|
||||
// transition (e.g. the analyst never emitted a validated `analysis`). Treat it as a
|
||||
// retryable stage failure — a fresh attempt lets the model gather more before writing —
|
||||
// rather than killing the run. Only terminal once the retry budget is spent.
|
||||
retryStageOrFail(
|
||||
enriched,
|
||||
"no transition condition matched from stage ${enriched.currentStageId.value}",
|
||||
)
|
||||
}
|
||||
|
||||
is TransitionDecision.Blocked -> failWorkflow(
|
||||
sessionId = enriched.sessionId,
|
||||
stageId = enriched.currentStageId,
|
||||
reason = decision.reason,
|
||||
retryExhausted = false,
|
||||
)
|
||||
|
||||
is TransitionDecision.NoMatch -> retryStageOrFail(
|
||||
enriched,
|
||||
"no matching transition from stage ${enriched.currentStageId.value}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-terminal stage whose outcome matched no outgoing transition (Stay/NoMatch) produced no
|
||||
* usable artifact. Route it back through the stage's retry budget instead of failing the run:
|
||||
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
|
||||
*/
|
||||
private suspend fun retryStageOrFail(
|
||||
ctx: EnrichedExecutionContext,
|
||||
reason: String,
|
||||
): WorkflowResult {
|
||||
val attempt = orchestrationRepository.getState(ctx.sessionId).retryCount
|
||||
val shouldRetry = retryCoordinator.shouldRetry(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = ctx.currentStageId,
|
||||
currentAttempt = attempt,
|
||||
policy = ctx.config.retryPolicy,
|
||||
failureReason = reason,
|
||||
)
|
||||
if (!shouldRetry) {
|
||||
return failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true)
|
||||
}
|
||||
return when (val r = enterStage(ctx, ctx.currentStageId)) {
|
||||
is StepResult.Continue -> step(r.ctx)
|
||||
is StepResult.Terminal -> r.result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retry-agency guard (ticket + recovery model). When [failure]'s gate requires a capability the
|
||||
* failing stage does not hold (e.g. a build/contract gate needs `file_write` on a write-less
|
||||
* verifier), retrying the stage in place can never change the outcome. Instead open a
|
||||
* [FailureTicketOpenedEvent] and route to the graph's recovery stage — which does hold the
|
||||
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
|
||||
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
|
||||
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
|
||||
*/
|
||||
private suspend fun maybeRouteToRecovery(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
failure: StageExecutionResult.Failure,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
|
||||
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
|
||||
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
|
||||
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a [FailureTicketOpenedEvent] and route [stageId]'s failure down a two-tier repair ladder.
|
||||
*
|
||||
* **Tier 1 (owner):** when the gate's evidence names a file a stage actually wrote, the ticket goes
|
||||
* to that author ([resolveTicketOwner]) — it owns the code and the contract it declared, so it
|
||||
* patches its own layer. Bounded by [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* **Tier 2 (arbiter / intent-holder):** when the owner loop spends its budget without settling the
|
||||
* failure, the failure is a cross-file CONTRACT dispute no single owner can resolve (each side is
|
||||
* internally consistent; they disagree with each other). The ticket escalates to the synthesized
|
||||
* recovery stage ([findRecoveryStage]) recast as the intent-holder — given the initial intent as
|
||||
* authority (see buildRecoveryTicketEntry) and told to reconcile ALL the named files in one pass.
|
||||
* Bounded by its own independent [INTENT_ROUTE_BUDGET]. An orphan failure (no author) starts here.
|
||||
*
|
||||
* Both tiers charge progress-awarely: a route whose failure fingerprint changed from the previous
|
||||
* one moved the needle, so it is FREE; only a route reproducing the same failure charges the tier's
|
||||
* budget. Terminal only when every reachable tier is spent. Returns null when neither an owner nor a
|
||||
* recovery stage exists (caller falls back to the legacy per-gate retry path). Shared by the agency
|
||||
* guard ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion]).
|
||||
*/
|
||||
private suspend fun routeToRecovery(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
gate: String,
|
||||
requiredCapability: String,
|
||||
reason: String,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val owner = resolveTicketOwner(events, reason)
|
||||
?.takeIf { it != stageId && requiredCapability in (ctx.graph.stages[it]?.allowedTools ?: emptySet()) }
|
||||
val arbiter = findRecoveryStage(ctx.graph, stageId)
|
||||
val fingerprint = FailureFingerprint.of(reason)
|
||||
val ownerKey = stageId.value
|
||||
val intentKey = stageId.value + INTENT_BUDGET_SUFFIX
|
||||
|
||||
// (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, 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) {
|
||||
if (owner == null && arbiter == null) return null // nothing to route to: legacy retry path
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
"repair ladder exhausted for stage ${stageId.value} (gate=$gate): $reason",
|
||||
retryExhausted = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val prev = state.recoveryFailureFingerprints[route.budgetKey]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[route.budgetKey] ?: 0
|
||||
val routeAttempt = if (progressed) used else used + 1
|
||||
emit(
|
||||
ctx.sessionId,
|
||||
FailureTicketOpenedEvent(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = stageId,
|
||||
gate = gate,
|
||||
category = ticketCategory(gate),
|
||||
requiredCapability = requiredCapability,
|
||||
routeTo = route.target,
|
||||
evidence = reason,
|
||||
routeAttempt = routeAttempt,
|
||||
fingerprint = fingerprint,
|
||||
escalated = route.escalated,
|
||||
),
|
||||
)
|
||||
log.info(
|
||||
"[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> {}={} " +
|
||||
"(charged={}/{}, progressed={})",
|
||||
ctx.sessionId.value, stageId.value, gate, requiredCapability,
|
||||
if (route.escalated) "arbiter" else "owner", route.target.value,
|
||||
routeAttempt, route.budget, progressed,
|
||||
)
|
||||
val advancedTo = advanceStage(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
TransitionDecision.Move(transitionId = TICKET_ROUTE, to = route.target),
|
||||
)
|
||||
return enterStage(ctx.copy(currentStageId = advancedTo), route.target)
|
||||
}
|
||||
|
||||
/** A chosen rung of the repair ladder: where to route, which budget it charges, and its tier. */
|
||||
private data class RouteTier(
|
||||
val target: StageId,
|
||||
val budgetKey: String,
|
||||
val budget: Int,
|
||||
val escalated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Progress-aware budget check for one ladder tier. A route whose failure fingerprint changed from
|
||||
* the previous route on the same [key] made progress (fixed one cause, surfaced another) → NOT
|
||||
* exhausted (progress is free). Only a no-progress streak that reaches [budget] is exhausted.
|
||||
*/
|
||||
private fun budgetExhausted(
|
||||
state: OrchestrationState,
|
||||
key: String,
|
||||
fingerprint: String,
|
||||
budget: Int,
|
||||
): Boolean {
|
||||
val prev = state.recoveryFailureFingerprints[key]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[key] ?: 0
|
||||
return !progressed && used >= budget
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph's recovery stage: a stage marked `metadata["role"] == "recovery"` (or, as a
|
||||
* convenience, one whose id is literally "recovery"), other than [failingStageId] itself.
|
||||
* Null when the graph declares none — recovery routing is opt-in per workflow.
|
||||
*/
|
||||
private fun findRecoveryStage(graph: WorkflowGraph, failingStageId: StageId): StageId? =
|
||||
graph.stages.entries.firstOrNull { (id, cfg) ->
|
||||
id != failingStageId && (cfg.metadata["role"] == "recovery" || id.value == "recovery")
|
||||
}?.key
|
||||
|
||||
/**
|
||||
* Route-to-owner resolution: map the failing gate's [evidence] (build/tsc output that names
|
||||
* files, e.g. "src/App.tsx: TS2322") to the stage that most recently WROTE one of those files,
|
||||
* via the recorded manifest chain (ToolInvocationRequestedEvent.stageId ← FileWrittenEvent by
|
||||
* invocationId → path). The last write of a named file wins — its author is who to hand the
|
||||
* ticket to. Pure over recorded events (replay-deterministic). Null when the evidence names no
|
||||
* written file (an orphan failure — caller falls back to the synthesized recovery stage).
|
||||
*/
|
||||
private fun resolveTicketOwner(events: List<StoredEvent>, evidence: String): StageId? {
|
||||
val tokens = EVIDENCE_PATH_RE.findAll(evidence)
|
||||
.map { it.value.substringBefore(':').replace('\\', '/') }
|
||||
.filter { it.length >= MIN_EVIDENCE_TOKEN }
|
||||
.toSet()
|
||||
if (tokens.isEmpty()) return null
|
||||
val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.associate { it.invocationId to it.stageId }
|
||||
return events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.filter { it.postImageHash != null }
|
||||
.lastOrNull { fw ->
|
||||
val norm = fw.path.replace('\\', '/')
|
||||
invToStage.containsKey(fw.invocationId) && tokens.any { norm == it || norm.endsWith("/$it") }
|
||||
}
|
||||
?.let { invToStage[it.invocationId] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return-to-sender for a routed repair stage. When the current stage was entered by a failure
|
||||
* ticket ([TICKET_ROUTE]) rather than a plan edge, its exit goes BACK to the gate that opened the
|
||||
* ticket (origin of the latest [FailureTicketOpenedEvent] routed here) so that gate re-runs. This
|
||||
* holds for any repair destination — the file's author OR the fallback recovery stage — and for
|
||||
* arbitrary topology, instead of following the plan's forward edge (which would walk a
|
||||
* regenerating implementer, or skip intervening stages). Returns null when the stage was NOT
|
||||
* entered via a ticket (a normal forward visit — the resolver's edge is used).
|
||||
*/
|
||||
private fun ticketReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val enteredViaTicket = events.mapNotNull { it.payload as? TransitionExecutedEvent }
|
||||
.lastOrNull { it.to == ctx.currentStageId }
|
||||
?.transitionId == TICKET_ROUTE
|
||||
if (!enteredViaTicket) return null
|
||||
val origin = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
.lastOrNull { it.routeTo == ctx.currentStageId }
|
||||
?.stageId ?: return null
|
||||
return TransitionDecision.Move(transitionId = TICKET_RETURN, to = origin)
|
||||
}
|
||||
|
||||
/** Returns the cached validated artifact content for the given session + artifact id, or null if absent. */
|
||||
fun validatedArtifactContent(sessionId: SessionId, artifactId: ArtifactId): String? =
|
||||
artifactContentCache["${sessionId.value}:${artifactId.value}"]
|
||||
@@ -599,193 +253,6 @@ class DefaultSessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun executeMove(
|
||||
ctx: EnrichedExecutionContext,
|
||||
decision: TransitionDecision.Move,
|
||||
): StepResult {
|
||||
val nextStageId = decision.to
|
||||
|
||||
if (isTerminal(ctx.graph, nextStageId)) {
|
||||
// Terminal stage is a sentinel — not executed, so don't count it.
|
||||
return StepResult.Terminal(
|
||||
completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id),
|
||||
)
|
||||
}
|
||||
|
||||
// Runtime refinement guard: a back-edge (re-entering a stage already visited this
|
||||
// run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
|
||||
// event, so the guard is replay-deterministic. Exceeding the cap escalates to a
|
||||
// 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 ?: tuning.defaultMaxRefinement
|
||||
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
|
||||
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
||||
if (iteration > maxIterations) {
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
nextStageId,
|
||||
"refinement loop '$cycleKey' exceeded $maxIterations iterations — escalating",
|
||||
retryExhausted = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the transition before executing the next stage. Otherwise the next stage's
|
||||
// events (InferenceStarted, artifacts, …) are recorded ahead of the TransitionExecuted
|
||||
// that marks entering it, so the log shows the stage running before it was entered.
|
||||
val advancedTo = advanceStage(ctx.sessionId, ctx.currentStageId, decision)
|
||||
return when (val result = enterStage(ctx.copy(currentStageId = advancedTo), nextStageId)) {
|
||||
is StepResult.Continue -> StepResult.Continue(result.ctx)
|
||||
is StepResult.Terminal -> result
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun enterStage(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
): StepResult {
|
||||
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
|
||||
|
||||
if (ctx.graph.stages[stageId]?.metadata?.get("requiresApproval") == "true") {
|
||||
val alreadyApproved = repositories.eventStore.read(ctx.sessionId).let { events ->
|
||||
val stageRequestIds = events
|
||||
.mapNotNull { it.payload as? ApprovalRequestedEvent }
|
||||
.filter { it.stageId == stageId && it.toolName == null }
|
||||
.map { it.requestId }
|
||||
.toSet()
|
||||
stageRequestIds.isNotEmpty() && events.any {
|
||||
val decision = it.payload as? ApprovalDecisionResolvedEvent
|
||||
// A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
|
||||
// runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
|
||||
decision?.requestId in stageRequestIds &&
|
||||
decision?.outcome != ApprovalOutcome.REJECTED
|
||||
}
|
||||
}
|
||||
if (!alreadyApproved) {
|
||||
val gateArtifact = ctx.graph.stages[stageId]?.needs?.firstOrNull()?.value
|
||||
val preview = gateArtifact
|
||||
?.let { artifactContentCache["${ctx.sessionId.value}:$it"] }
|
||||
?: "Upstream stage has completed. Review and approve to continue to stage ${stageId.value}."
|
||||
val approved = requestStageApproval(ctx.sessionId, stageId, preview)
|
||||
if (!approved) {
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(ctx.sessionId, stageId, "approval rejected for stage ${stageId.value}", retryExhausted = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (isCancelled(ctx.sessionId)) {
|
||||
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
|
||||
}
|
||||
val result = subagentRunner.run(
|
||||
SubagentRunRequest(ctx.sessionId, stageId, ctx.graph, ctx.session, ctx.config),
|
||||
).outcome
|
||||
when (result) {
|
||||
is StageExecutionResult.Success -> {
|
||||
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
|
||||
// The stage raised open questions and the operator answered them; loop to
|
||||
// re-run the stage with the answers injected (no failure-retry budget spent).
|
||||
continue
|
||||
}
|
||||
compactionService?.let { svc ->
|
||||
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
||||
val journalText = DecisionJournalRenderer().render(journalState)
|
||||
val tokenEstimate = estimateTokens(journalText)
|
||||
svc.compactIfNeeded(
|
||||
sessionId = ctx.sessionId,
|
||||
state = journalState,
|
||||
renderedTokenEstimate = tokenEstimate,
|
||||
emit = { payload -> emit(ctx.sessionId, payload) },
|
||||
)
|
||||
}
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
|
||||
is StageExecutionResult.Failure -> {
|
||||
log.debug(
|
||||
"[Orchestrator] stage failed session=${ctx.sessionId.value} " +
|
||||
"stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}",
|
||||
)
|
||||
if (!result.retryable) {
|
||||
// Non-retryable: let the transition resolver handle the outcome
|
||||
// (e.g., back-edge via artifact_field_equals on failure)
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
val refreshedState = orchestrationRepository.getState(ctx.sessionId)
|
||||
// Retry-agency invariant: a gate this stage lacks the capability to fix must not
|
||||
// be retried in place (futile). Route to a recovery stage that holds the
|
||||
// capability, if one exists and the route budget remains.
|
||||
maybeRouteToRecovery(ctx, stageId, result, refreshedState)?.let { return it }
|
||||
val gateDecision = retryCoordinator.decide(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = stageId,
|
||||
gate = result.gate,
|
||||
failureReason = result.reason,
|
||||
state = refreshedState,
|
||||
policy = ctx.config.retryPolicy,
|
||||
)
|
||||
when (gateDecision) {
|
||||
RetryDecision.Retry -> Unit // retry — loop and re-execute
|
||||
RetryDecision.Exhausted -> {
|
||||
decideGateExhaustion(
|
||||
ctx, stageId, result.gate, result.reason, refreshedState,
|
||||
)?.let { return it }
|
||||
// review-gate salvage said CONTINUE — budget was reset by the reducer;
|
||||
// loop and re-execute the stage with a fresh budget.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid exhaustion (design 2026-07-06-per-gate-retry-budgets.md §3/T6): a deterministic gate
|
||||
* exhausting its budget fails the stage terminally, unchanged from today's behaviour (just
|
||||
* per-gate now). The "review" gate instead gets one salvageability judgement — CONTINUE resets
|
||||
* its budget (via the reducer folding [RetrySalvageDecidedEvent]) and the caller loops to retry;
|
||||
* FAIL is terminal. A gate that already spent its one salvage reset (state.gateSalvageUsed) fails
|
||||
* immediately on a second exhaustion, regardless of what the judge would say.
|
||||
*
|
||||
* Returns a terminal [StepResult] to fail/route with, the recovery stage's result on RECOVER,
|
||||
* or null to retry in place (loop and re-execute).
|
||||
*/
|
||||
private suspend fun decideGateExhaustion(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
gate: String,
|
||||
reason: String,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val sessionId = ctx.sessionId
|
||||
if (gate != "review" || state.gateSalvageUsed.contains(gate)) {
|
||||
return StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
|
||||
}
|
||||
// No judge wired: degrade safely with a deterministic allow-one-reset-then-fail policy —
|
||||
// the gateSalvageUsed check above already ensures this fires at most once per gate.
|
||||
val judgment = salvageJudge?.judge(sessionId, stageId, reason)
|
||||
?: SalvageJudgment(
|
||||
SalvageDecision.CONTINUE,
|
||||
"no salvage judge wired — deterministic one-time reset",
|
||||
)
|
||||
emit(sessionId, RetrySalvageDecidedEvent(sessionId, stageId, gate, judgment.decision, judgment.rationale))
|
||||
return when (judgment.decision) {
|
||||
SalvageDecision.CONTINUE -> null
|
||||
SalvageDecision.FAIL -> StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
|
||||
// The judge chose recovery: route to the recovery stage (file_write is the capability it
|
||||
// provides). Degrade to terminal if the graph declares no recovery stage.
|
||||
SalvageDecision.RECOVER ->
|
||||
routeToRecovery(ctx, stageId, gate, "file_write", reason, state)
|
||||
?: StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExecutionContext.enrich() = EnrichedExecutionContext(
|
||||
graph, sessionId, stageCount, currentStageId, config,
|
||||
session = repositories.sessionRepository.getSession(sessionId),
|
||||
@@ -795,15 +262,15 @@ class DefaultSessionOrchestrator(
|
||||
|
||||
// A back-edge re-enters a stage already transitioned into this run (e.g. reviewer→implementer).
|
||||
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
|
||||
private fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
|
||||
internal fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
|
||||
events.any { (it.payload as? TransitionExecutedEvent)?.to == target }
|
||||
|
||||
private sealed class StepResult {
|
||||
internal sealed class StepResult {
|
||||
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
|
||||
data class Terminal(val result: WorkflowResult) : StepResult()
|
||||
}
|
||||
|
||||
private data class ExecutionContext(
|
||||
internal data class ExecutionContext(
|
||||
val graph: WorkflowGraph,
|
||||
val sessionId: SessionId,
|
||||
val stageCount: Int,
|
||||
@@ -813,7 +280,7 @@ private data class ExecutionContext(
|
||||
val state: OrchestrationState?,
|
||||
)
|
||||
|
||||
private data class EnrichedExecutionContext(
|
||||
internal data class EnrichedExecutionContext(
|
||||
val graph: WorkflowGraph,
|
||||
val sessionId: SessionId,
|
||||
val stageCount: Int,
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.retry.FailureFingerprint
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
|
||||
ctx: EnrichedExecutionContext,
|
||||
reason: String,
|
||||
): WorkflowResult {
|
||||
val attempt = orchestrationRepository.getState(ctx.sessionId).retryCount
|
||||
val shouldRetry = retryCoordinator.shouldRetry(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = ctx.currentStageId,
|
||||
currentAttempt = attempt,
|
||||
policy = ctx.config.retryPolicy,
|
||||
failureReason = reason,
|
||||
)
|
||||
if (!shouldRetry) {
|
||||
return failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true)
|
||||
}
|
||||
return when (val r = enterStage(ctx, ctx.currentStageId)) {
|
||||
is StepResult.Continue -> step(r.ctx)
|
||||
is StepResult.Terminal -> r.result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retry-agency guard (ticket + recovery model). When [failure]'s gate requires a capability the
|
||||
* failing stage does not hold (e.g. a build/contract gate needs `file_write` on a write-less
|
||||
* verifier), retrying the stage in place can never change the outcome. Instead open a
|
||||
* [FailureTicketOpenedEvent] and route to the graph's recovery stage — which does hold the
|
||||
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
|
||||
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
|
||||
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
|
||||
*/
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
failure: StageExecutionResult.Failure,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
|
||||
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
|
||||
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
|
||||
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a [FailureTicketOpenedEvent] and route [stageId]'s failure down a two-tier repair ladder.
|
||||
*
|
||||
* **Tier 1 (owner):** when the gate's evidence names a file a stage actually wrote, the ticket goes
|
||||
* to that author ([resolveTicketOwner]) — it owns the code and the contract it declared, so it
|
||||
* patches its own layer. Bounded by [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* **Tier 2 (arbiter / intent-holder):** when the owner loop spends its budget without settling the
|
||||
* failure, the failure is a cross-file CONTRACT dispute no single owner can resolve (each side is
|
||||
* internally consistent; they disagree with each other). The ticket escalates to the synthesized
|
||||
* recovery stage ([findRecoveryStage]) recast as the intent-holder — given the initial intent as
|
||||
* authority (see buildRecoveryTicketEntry) and told to reconcile ALL the named files in one pass.
|
||||
* Bounded by its own independent [INTENT_ROUTE_BUDGET]. An orphan failure (no author) starts here.
|
||||
*
|
||||
* Both tiers charge progress-awarely: a route whose failure fingerprint changed from the previous
|
||||
* one moved the needle, so it is FREE; only a route reproducing the same failure charges the tier's
|
||||
* budget. Terminal only when every reachable tier is spent. Returns null when neither an owner nor a
|
||||
* recovery stage exists (caller falls back to the legacy per-gate retry path). Shared by the agency
|
||||
* guard ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion]).
|
||||
*/
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.routeToRecovery(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
gate: String,
|
||||
requiredCapability: String,
|
||||
reason: String,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val owner = resolveTicketOwner(events, reason)
|
||||
?.takeIf { it != stageId && requiredCapability in (ctx.graph.stages[it]?.allowedTools ?: emptySet()) }
|
||||
val arbiter = findRecoveryStage(ctx.graph, stageId)
|
||||
val fingerprint = FailureFingerprint.of(reason)
|
||||
val ownerKey = stageId.value
|
||||
val intentKey = stageId.value + INTENT_BUDGET_SUFFIX
|
||||
|
||||
// (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, 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) {
|
||||
if (owner == null && arbiter == null) return null // nothing to route to: legacy retry path
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
"repair ladder exhausted for stage ${stageId.value} (gate=$gate): $reason",
|
||||
retryExhausted = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val prev = state.recoveryFailureFingerprints[route.budgetKey]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[route.budgetKey] ?: 0
|
||||
val routeAttempt = if (progressed) used else used + 1
|
||||
emit(
|
||||
ctx.sessionId,
|
||||
FailureTicketOpenedEvent(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = stageId,
|
||||
gate = gate,
|
||||
category = ticketCategory(gate),
|
||||
requiredCapability = requiredCapability,
|
||||
routeTo = route.target,
|
||||
evidence = reason,
|
||||
routeAttempt = routeAttempt,
|
||||
fingerprint = fingerprint,
|
||||
escalated = route.escalated,
|
||||
),
|
||||
)
|
||||
log.info(
|
||||
"[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> {}={} " +
|
||||
"(charged={}/{}, progressed={})",
|
||||
ctx.sessionId.value, stageId.value, gate, requiredCapability,
|
||||
if (route.escalated) "arbiter" else "owner", route.target.value,
|
||||
routeAttempt, route.budget, progressed,
|
||||
)
|
||||
val advancedTo = advanceStage(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
TransitionDecision.Move(transitionId = TICKET_ROUTE, to = route.target),
|
||||
)
|
||||
return enterStage(ctx.copy(currentStageId = advancedTo), route.target)
|
||||
}
|
||||
|
||||
/** A chosen rung of the repair ladder: where to route, which budget it charges, and its tier. */
|
||||
private data class RouteTier(
|
||||
val target: StageId,
|
||||
val budgetKey: String,
|
||||
val budget: Int,
|
||||
val escalated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Progress-aware budget check for one ladder tier. A route whose failure fingerprint changed from
|
||||
* the previous route on the same [key] made progress (fixed one cause, surfaced another) → NOT
|
||||
* exhausted (progress is free). Only a no-progress streak that reaches [budget] is exhausted.
|
||||
*/
|
||||
|
||||
internal fun DefaultSessionOrchestrator.budgetExhausted(
|
||||
state: OrchestrationState,
|
||||
key: String,
|
||||
fingerprint: String,
|
||||
budget: Int,
|
||||
): Boolean {
|
||||
val prev = state.recoveryFailureFingerprints[key]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[key] ?: 0
|
||||
return !progressed && used >= budget
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph's recovery stage: a stage marked `metadata["role"] == "recovery"` (or, as a
|
||||
* convenience, one whose id is literally "recovery"), other than [failingStageId] itself.
|
||||
* Null when the graph declares none — recovery routing is opt-in per workflow.
|
||||
*/
|
||||
|
||||
internal fun DefaultSessionOrchestrator.findRecoveryStage(graph: WorkflowGraph, failingStageId: StageId): StageId? =
|
||||
graph.stages.entries.firstOrNull { (id, cfg) ->
|
||||
id != failingStageId && (cfg.metadata["role"] == "recovery" || id.value == "recovery")
|
||||
}?.key
|
||||
|
||||
/**
|
||||
* Route-to-owner resolution: map the failing gate's [evidence] (build/tsc output that names
|
||||
* files, e.g. "src/App.tsx: TS2322") to the stage that most recently WROTE one of those files,
|
||||
* via the recorded manifest chain (ToolInvocationRequestedEvent.stageId ← FileWrittenEvent by
|
||||
* invocationId → path). The last write of a named file wins — its author is who to hand the
|
||||
* ticket to. Pure over recorded events (replay-deterministic). Null when the evidence names no
|
||||
* written file (an orphan failure — caller falls back to the synthesized recovery stage).
|
||||
*/
|
||||
|
||||
internal fun DefaultSessionOrchestrator.resolveTicketOwner(events: List<StoredEvent>, evidence: String): StageId? {
|
||||
val tokens = EVIDENCE_PATH_RE.findAll(evidence)
|
||||
.map { it.value.substringBefore(':').replace('\\', '/') }
|
||||
.filter { it.length >= MIN_EVIDENCE_TOKEN }
|
||||
.toSet()
|
||||
if (tokens.isEmpty()) return null
|
||||
val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.associate { it.invocationId to it.stageId }
|
||||
return events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.filter { it.postImageHash != null }
|
||||
.lastOrNull { fw ->
|
||||
val norm = fw.path.replace('\\', '/')
|
||||
invToStage.containsKey(fw.invocationId) && tokens.any { norm == it || norm.endsWith("/$it") }
|
||||
}
|
||||
?.let { invToStage[it.invocationId] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return-to-sender for a routed repair stage. When the current stage was entered by a failure
|
||||
* ticket ([TICKET_ROUTE]) rather than a plan edge, its exit goes BACK to the gate that opened the
|
||||
* ticket (origin of the latest [FailureTicketOpenedEvent] routed here) so that gate re-runs. This
|
||||
* holds for any repair destination — the file's author OR the fallback recovery stage — and for
|
||||
* arbitrary topology, instead of following the plan's forward edge (which would walk a
|
||||
* regenerating implementer, or skip intervening stages). Returns null when the stage was NOT
|
||||
* entered via a ticket (a normal forward visit — the resolver's edge is used).
|
||||
*/
|
||||
|
||||
internal fun DefaultSessionOrchestrator.ticketReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val enteredViaTicket = events.mapNotNull { it.payload as? TransitionExecutedEvent }
|
||||
.lastOrNull { it.to == ctx.currentStageId }
|
||||
?.transitionId == TICKET_ROUTE
|
||||
if (!enteredViaTicket) return null
|
||||
val origin = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
.lastOrNull { it.routeTo == ctx.currentStageId }
|
||||
?.stageId ?: return null
|
||||
return TransitionDecision.Move(transitionId = TICKET_RETURN, to = origin)
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.journal.DecisionJournalRenderer
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.RetrySalvageDecidedEvent
|
||||
import com.correx.core.events.events.SalvageDecision
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunRequest
|
||||
import com.correx.core.kernel.retry.RetryDecision
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
@Suppress("LongMethod")
|
||||
internal tailrec suspend fun DefaultSessionOrchestrator.step(ctx: EnrichedExecutionContext): WorkflowResult {
|
||||
log.debug(
|
||||
"[Orchestrator] step session={} stage={} stageCount={}",
|
||||
ctx.sessionId.value, ctx.currentStageId.value, ctx.stageCount,
|
||||
)
|
||||
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
|
||||
|
||||
val enriched = EnrichedExecutionContext(
|
||||
graph = ctx.graph,
|
||||
sessionId = ctx.sessionId,
|
||||
stageCount = ctx.stageCount,
|
||||
currentStageId = ctx.currentStageId,
|
||||
config = ctx.config,
|
||||
state = orchestrationRepository.getState(ctx.sessionId),
|
||||
session = repositories.sessionRepository.getSession(ctx.sessionId),
|
||||
)
|
||||
|
||||
val stageConfig = enriched.graph.stages[enriched.currentStageId]
|
||||
val targetIds: Set<ArtifactId> = stageConfig?.produces?.map { it.name }?.toSet() ?: emptySet()
|
||||
val stageArtifacts: Map<ArtifactId, ArtifactState> = if (targetIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
val validated = repositories.eventStore.read(enriched.sessionId)
|
||||
.mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId }
|
||||
.filter { it in targetIds }
|
||||
.toSet()
|
||||
validated.associateWith { ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) }
|
||||
}
|
||||
|
||||
val artifactContent: Map<ArtifactId, String> = targetIds.mapNotNull { id ->
|
||||
val cacheKey = "${enriched.sessionId.value}:${id.value}"
|
||||
artifactContentCache[cacheKey]?.let { content -> id to content }
|
||||
}.toMap()
|
||||
|
||||
val resolved = resolveTransition(
|
||||
enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
|
||||
)
|
||||
// Ticket return-to-sender: a repair stage (the file's author, or the fallback recovery stage)
|
||||
// is entered by kernel routing from whichever stage failed a gate, so its exit must go BACK to
|
||||
// that exact gate (re-run it) — not follow a plan edge that would walk a regenerating
|
||||
// implementer or skip intervening stages. Derived from the latest failure ticket routed here
|
||||
// (replay-deterministic). Overrides the resolved edge; an operator preempt below still wins.
|
||||
val baseDecision = ticketReturnMove(enriched) ?: resolved
|
||||
// Preemptive redirect override (freestyle graph re-routing): if the operator confirmed a
|
||||
// jump (a recorded, unconsumed PreemptRedirectEvent), it overrides the resolver's edge —
|
||||
// unless the target is unknown or its needs aren't satisfied, in which case it is blocked
|
||||
// and the normal edge is taken. The decision is pure over the event log (replay-deterministic);
|
||||
// only the block-event emission is a side effect here.
|
||||
val decision = when (
|
||||
val outcome = PreemptRedirect.decide(
|
||||
events = repositories.eventStore.read(enriched.sessionId),
|
||||
graph = enriched.graph,
|
||||
sessionId = enriched.sessionId,
|
||||
artifactAvailable = { id ->
|
||||
!artifactContentCache["${enriched.sessionId.value}:${id.value}"].isNullOrBlank()
|
||||
},
|
||||
nowMs = Clock.System.now().toEpochMilliseconds(),
|
||||
)
|
||||
) {
|
||||
is PreemptRedirect.Outcome.Override -> outcome.move
|
||||
is PreemptRedirect.Outcome.Block -> {
|
||||
emit(enriched.sessionId, outcome.event)
|
||||
log.info(
|
||||
"[Orchestrator] redirect blocked session={} to={} reason={}",
|
||||
enriched.sessionId.value, outcome.event.toStageId.value, outcome.event.reason,
|
||||
)
|
||||
baseDecision
|
||||
}
|
||||
PreemptRedirect.Outcome.None -> baseDecision
|
||||
}
|
||||
log.debug(
|
||||
"[Orchestrator] transition session={} stage={} decision={}",
|
||||
enriched.sessionId.value, enriched.currentStageId.value, decision::class.simpleName,
|
||||
)
|
||||
return when (decision) {
|
||||
is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
|
||||
is StepResult.Continue -> step(r.ctx)
|
||||
is StepResult.Terminal -> r.result
|
||||
}
|
||||
|
||||
is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
|
||||
completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount, enriched.graph.id)
|
||||
} else {
|
||||
// No outgoing edge matched: the stage finished but produced nothing that satisfies a
|
||||
// transition (e.g. the analyst never emitted a validated `analysis`). Treat it as a
|
||||
// retryable stage failure — a fresh attempt lets the model gather more before writing —
|
||||
// rather than killing the run. Only terminal once the retry budget is spent.
|
||||
retryStageOrFail(
|
||||
enriched,
|
||||
"no transition condition matched from stage ${enriched.currentStageId.value}",
|
||||
)
|
||||
}
|
||||
|
||||
is TransitionDecision.Blocked -> failWorkflow(
|
||||
sessionId = enriched.sessionId,
|
||||
stageId = enriched.currentStageId,
|
||||
reason = decision.reason,
|
||||
retryExhausted = false,
|
||||
)
|
||||
|
||||
is TransitionDecision.NoMatch -> retryStageOrFail(
|
||||
enriched,
|
||||
"no matching transition from stage ${enriched.currentStageId.value}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-terminal stage whose outcome matched no outgoing transition (Stay/NoMatch) produced no
|
||||
* usable artifact. Route it back through the stage's retry budget instead of failing the run:
|
||||
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
|
||||
*/
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.executeMove(
|
||||
ctx: EnrichedExecutionContext,
|
||||
decision: TransitionDecision.Move,
|
||||
): StepResult {
|
||||
val nextStageId = decision.to
|
||||
|
||||
if (isTerminal(ctx.graph, nextStageId)) {
|
||||
// Terminal stage is a sentinel — not executed, so don't count it.
|
||||
return StepResult.Terminal(
|
||||
completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id),
|
||||
)
|
||||
}
|
||||
|
||||
// Runtime refinement guard: a back-edge (re-entering a stage already visited this
|
||||
// run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
|
||||
// event, so the guard is replay-deterministic. Exceeding the cap escalates to a
|
||||
// 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 ?: tuning.defaultMaxRefinement
|
||||
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
|
||||
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
||||
if (iteration > maxIterations) {
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
nextStageId,
|
||||
"refinement loop '$cycleKey' exceeded $maxIterations iterations — escalating",
|
||||
retryExhausted = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the transition before executing the next stage. Otherwise the next stage's
|
||||
// events (InferenceStarted, artifacts, …) are recorded ahead of the TransitionExecuted
|
||||
// that marks entering it, so the log shows the stage running before it was entered.
|
||||
val advancedTo = advanceStage(ctx.sessionId, ctx.currentStageId, decision)
|
||||
return when (val result = enterStage(ctx.copy(currentStageId = advancedTo), nextStageId)) {
|
||||
is StepResult.Continue -> StepResult.Continue(result.ctx)
|
||||
is StepResult.Terminal -> result
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
): StepResult {
|
||||
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
|
||||
|
||||
if (ctx.graph.stages[stageId]?.metadata?.get("requiresApproval") == "true") {
|
||||
val alreadyApproved = repositories.eventStore.read(ctx.sessionId).let { events ->
|
||||
val stageRequestIds = events
|
||||
.mapNotNull { it.payload as? ApprovalRequestedEvent }
|
||||
.filter { it.stageId == stageId && it.toolName == null }
|
||||
.map { it.requestId }
|
||||
.toSet()
|
||||
stageRequestIds.isNotEmpty() && events.any {
|
||||
val decision = it.payload as? ApprovalDecisionResolvedEvent
|
||||
// A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
|
||||
// runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
|
||||
decision?.requestId in stageRequestIds &&
|
||||
decision?.outcome != ApprovalOutcome.REJECTED
|
||||
}
|
||||
}
|
||||
if (!alreadyApproved) {
|
||||
val gateArtifact = ctx.graph.stages[stageId]?.needs?.firstOrNull()?.value
|
||||
val preview = gateArtifact
|
||||
?.let { artifactContentCache["${ctx.sessionId.value}:$it"] }
|
||||
?: "Upstream stage has completed. Review and approve to continue to stage ${stageId.value}."
|
||||
val approved = requestStageApproval(ctx.sessionId, stageId, preview)
|
||||
if (!approved) {
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(ctx.sessionId, stageId, "approval rejected for stage ${stageId.value}", retryExhausted = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (isCancelled(ctx.sessionId)) {
|
||||
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
|
||||
}
|
||||
val result = subagentRunner.run(
|
||||
SubagentRunRequest(ctx.sessionId, stageId, ctx.graph, ctx.session, ctx.config),
|
||||
).outcome
|
||||
when (result) {
|
||||
is StageExecutionResult.Success -> {
|
||||
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
|
||||
// The stage raised open questions and the operator answered them; loop to
|
||||
// re-run the stage with the answers injected (no failure-retry budget spent).
|
||||
continue
|
||||
}
|
||||
compactionService?.let { svc ->
|
||||
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
||||
val journalText = DecisionJournalRenderer().render(journalState)
|
||||
val tokenEstimate = estimateTokens(journalText)
|
||||
svc.compactIfNeeded(
|
||||
sessionId = ctx.sessionId,
|
||||
state = journalState,
|
||||
renderedTokenEstimate = tokenEstimate,
|
||||
emit = { payload -> emit(ctx.sessionId, payload) },
|
||||
)
|
||||
}
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
|
||||
is StageExecutionResult.Failure -> {
|
||||
log.debug(
|
||||
"[Orchestrator] stage failed session=${ctx.sessionId.value} " +
|
||||
"stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}",
|
||||
)
|
||||
if (!result.retryable) {
|
||||
// Non-retryable: let the transition resolver handle the outcome
|
||||
// (e.g., back-edge via artifact_field_equals on failure)
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
val refreshedState = orchestrationRepository.getState(ctx.sessionId)
|
||||
// Retry-agency invariant: a gate this stage lacks the capability to fix must not
|
||||
// be retried in place (futile). Route to a recovery stage that holds the
|
||||
// capability, if one exists and the route budget remains.
|
||||
maybeRouteToRecovery(ctx, stageId, result, refreshedState)?.let { return it }
|
||||
val gateDecision = retryCoordinator.decide(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = stageId,
|
||||
gate = result.gate,
|
||||
failureReason = result.reason,
|
||||
state = refreshedState,
|
||||
policy = ctx.config.retryPolicy,
|
||||
)
|
||||
when (gateDecision) {
|
||||
RetryDecision.Retry -> Unit // retry — loop and re-execute
|
||||
RetryDecision.Exhausted -> {
|
||||
decideGateExhaustion(
|
||||
ctx, stageId, result.gate, result.reason, refreshedState,
|
||||
)?.let { return it }
|
||||
// review-gate salvage said CONTINUE — budget was reset by the reducer;
|
||||
// loop and re-execute the stage with a fresh budget.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid exhaustion (design 2026-07-06-per-gate-retry-budgets.md §3/T6): a deterministic gate
|
||||
* exhausting its budget fails the stage terminally, unchanged from today's behaviour (just
|
||||
* per-gate now). The "review" gate instead gets one salvageability judgement — CONTINUE resets
|
||||
* its budget (via the reducer folding [RetrySalvageDecidedEvent]) and the caller loops to retry;
|
||||
* FAIL is terminal. A gate that already spent its one salvage reset (state.gateSalvageUsed) fails
|
||||
* immediately on a second exhaustion, regardless of what the judge would say.
|
||||
*
|
||||
* Returns a terminal [StepResult] to fail/route with, the recovery stage's result on RECOVER,
|
||||
* or null to retry in place (loop and re-execute).
|
||||
*/
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.decideGateExhaustion(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
gate: String,
|
||||
reason: String,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val sessionId = ctx.sessionId
|
||||
if (gate != "review" || state.gateSalvageUsed.contains(gate)) {
|
||||
return StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
|
||||
}
|
||||
// No judge wired: degrade safely with a deterministic allow-one-reset-then-fail policy —
|
||||
// the gateSalvageUsed check above already ensures this fires at most once per gate.
|
||||
val judgment = salvageJudge?.judge(sessionId, stageId, reason)
|
||||
?: SalvageJudgment(
|
||||
SalvageDecision.CONTINUE,
|
||||
"no salvage judge wired — deterministic one-time reset",
|
||||
)
|
||||
emit(sessionId, RetrySalvageDecidedEvent(sessionId, stageId, gate, judgment.decision, judgment.rationale))
|
||||
return when (judgment.decision) {
|
||||
SalvageDecision.CONTINUE -> null
|
||||
SalvageDecision.FAIL -> StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
|
||||
// The judge chose recovery: route to the recovery stage (file_write is the capability it
|
||||
// provides). Degrade to terminal if the graph declares no recovery stage.
|
||||
SalvageDecision.RECOVER ->
|
||||
routeToRecovery(ctx, stageId, gate, "file_write", reason, state)
|
||||
?: StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -24,6 +24,7 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator {
|
||||
|
||||
// allowComments/allowTrailingComma make tsconfig.json (JSONC — comments + trailing commas are
|
||||
// legal and tsc/Vite parse them) pass valid_json instead of false-failing a valid config file.
|
||||
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -81,12 +82,14 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator {
|
||||
private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict {
|
||||
val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() }
|
||||
?: return verdict(false, "no scripts block")
|
||||
return verdict(scripts.containsKey(name), if (scripts.containsKey(name)) "script '$name' defined" else "missing script '$name'")
|
||||
val hasScript = scripts.containsKey(name)
|
||||
return verdict(hasScript, if (hasScript) "script '$name' defined" else "missing script '$name'")
|
||||
}
|
||||
|
||||
private fun contains(path: Path, exists: Boolean, pattern: String): ContractAssertionVerdict {
|
||||
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
|
||||
return verdict(text.contains(pattern), if (text.contains(pattern)) "contains '$pattern'" else "missing '$pattern'")
|
||||
val hasPattern = text.contains(pattern)
|
||||
return verdict(hasPattern, if (hasPattern) "contains '$pattern'" else "missing '$pattern'")
|
||||
}
|
||||
|
||||
private fun textMatch(path: Path, exists: Boolean, needle: String, failMsg: String): ContractAssertionVerdict {
|
||||
|
||||
+3
-1
@@ -158,7 +158,9 @@ class ReplayOrchestrator(
|
||||
session: Session,
|
||||
): ReplayStepResult {
|
||||
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
|
||||
return when (val result = executeStage(ctx.sessionId, stageId, ctx.graph, session, ctx.config, effectivesFor(ctx.config))) {
|
||||
val result =
|
||||
executeStage(ctx.sessionId, stageId, ctx.graph, session, ctx.config, effectivesFor(ctx.config))
|
||||
return when (result) {
|
||||
is StageExecutionResult.Success -> ReplayStepResult.Continue(
|
||||
ctx.copy(stageCount = ctx.stageCount + 1),
|
||||
)
|
||||
|
||||
+84
-3350
File diff suppressed because it is too large
Load Diff
+195
@@ -0,0 +1,195 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.RiskSummaryId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.risk.RiskContext
|
||||
import com.correx.core.risk.toApprovalTier
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.validation.pipeline.ValidationOutcome
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
// Maps the operator profile's free-text approval_mode onto the engine's [ApprovalMode].
|
||||
// Unset/unknown falls back to PROMPT so an absent profile keeps the human-in-the-loop
|
||||
// default; only an explicit auto/yolo opts a session into unattended approval.
|
||||
internal fun SessionOrchestrator.approvalModeFor(profileMode: String?): ApprovalMode =
|
||||
when (profileMode?.trim()?.lowercase()) {
|
||||
"deny" -> ApprovalMode.DENY
|
||||
"auto" -> ApprovalMode.AUTO
|
||||
"yolo" -> ApprovalMode.YOLO
|
||||
else -> ApprovalMode.PROMPT
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.requestStageApproval(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
preview: String,
|
||||
): Boolean {
|
||||
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
||||
val validationReportId = ValidationReportId(UUID.randomUUID().toString())
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[requestId] = deferred
|
||||
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T2,
|
||||
validationReportId = validationReportId,
|
||||
riskSummaryId = null,
|
||||
riskSummary = null,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
preview = preview,
|
||||
),
|
||||
)
|
||||
|
||||
return try {
|
||||
val decision = deferred.await()
|
||||
emitDecisionResolved(
|
||||
sessionId,
|
||||
DomainApprovalRequest(
|
||||
id = requestId,
|
||||
tier = Tier.T2,
|
||||
validationReportId = validationReportId,
|
||||
riskSummaryId = null,
|
||||
riskSummary = null,
|
||||
timestamp = Clock.System.now(),
|
||||
),
|
||||
decision,
|
||||
)
|
||||
if (decision.isApproved) {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
true
|
||||
} else {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
false
|
||||
}
|
||||
} finally {
|
||||
pendingApprovals.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
// --- private functions ---
|
||||
|
||||
internal suspend fun SessionOrchestrator.handleApproval(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): StageExecutionResult {
|
||||
log.debug("[Orchestrator] approval required session={} stage={}", sessionId.value, stageId.value)
|
||||
val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[domainRequest.id] = deferred
|
||||
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
requestId = domainRequest.id,
|
||||
tier = domainRequest.tier,
|
||||
validationReportId = domainRequest.validationReportId,
|
||||
riskSummaryId = domainRequest.riskSummaryId,
|
||||
riskSummary = domainRequest.riskSummary,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
|
||||
return try {
|
||||
log.debug(
|
||||
"[Orchestrator] awaiting approval session={} requestId={}",
|
||||
sessionId.value, domainRequest.id.value,
|
||||
)
|
||||
val decision = deferred.await()
|
||||
log.debug(
|
||||
"[Orchestrator] approval resolved session={} approved={}",
|
||||
sessionId.value, decision.isApproved,
|
||||
)
|
||||
emitDecisionResolved(sessionId, domainRequest, decision)
|
||||
if (decision.isApproved) {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
StageExecutionResult.Success(emptyList())
|
||||
} else {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
StageExecutionResult.Failure(decision.reason ?: "approval rejected", retryable = false)
|
||||
}
|
||||
} finally {
|
||||
pendingApprovals.remove(domainRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildApprovalRequest(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): DomainApprovalRequest {
|
||||
val state = orchestrationRepository.getState(sessionId)
|
||||
val inferenceState = inferenceRepository.getInferenceState(sessionId)
|
||||
val riskSummary = riskAssessor.assess(
|
||||
RiskContext(
|
||||
validationReport = outcome.request.validationReport,
|
||||
orchestrationState = state,
|
||||
inferenceState = inferenceState,
|
||||
),
|
||||
)
|
||||
val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString())
|
||||
// RiskAssessedEvent records the indexed level+action summary; ApprovalRequestedEvent carries the full
|
||||
// authoritative RiskSummary (signals + rationale). Both derive from the same assessment value so
|
||||
// they cannot diverge.
|
||||
emit(
|
||||
sessionId,
|
||||
RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction),
|
||||
)
|
||||
return DomainApprovalRequest(
|
||||
id = ApprovalRequestId(UUID.randomUUID().toString()),
|
||||
tier = riskSummary.level.toApprovalTier(),
|
||||
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
|
||||
riskSummaryId = riskSummaryId,
|
||||
riskSummary = riskSummary,
|
||||
timestamp = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitDecisionResolved(
|
||||
sessionId: SessionId,
|
||||
domainRequest: DomainApprovalRequest,
|
||||
decision: ApprovalDecision,
|
||||
) {
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalDecisionResolvedEvent(
|
||||
decisionId = ApprovalDecisionId(UUID.randomUUID().toString()),
|
||||
requestId = domainRequest.id,
|
||||
outcome = decision.outcome ?: ApprovalOutcome.REJECTED,
|
||||
status = ApprovalStatus.COMPLETED,
|
||||
tier = domainRequest.tier,
|
||||
resolutionTimestamp = Clock.System.now(),
|
||||
reason = decision.reason,
|
||||
userSteering = decision.userSteering,
|
||||
),
|
||||
)
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.context.model.ContextBucket
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.context.model.EntryRole
|
||||
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.ArtifactRepairAttemptedEvent
|
||||
import com.correx.core.events.events.ArtifactRepairFailedEvent
|
||||
import com.correx.core.events.events.ArtifactRepairResolvedEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||
import com.correx.core.events.events.StageCheckpointPassedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.toolintent.WorkspacePolicy
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.inference.ToolDefinition
|
||||
import com.correx.core.inference.ToolFunction
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
|
||||
import com.correx.core.validation.artifact.ArtifactFailure
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.nio.file.Files
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
// --- per-run effective registry + policy ---
|
||||
|
||||
internal fun SessionOrchestrator.effectivesFor(config: OrchestrationConfig): RunEffectives {
|
||||
val wsTools = config.workspace?.let { workspaceToolRegistryProvider?.forWorkspace(it) }
|
||||
val registry = wsTools?.registry ?: toolRegistry
|
||||
val executor = wsTools?.executor ?: toolExecutor
|
||||
val policy = config.workspace
|
||||
?.let { WorkspacePolicy(it.workspaceRoot, it.privilegedLocations) }
|
||||
?: workspacePolicy
|
||||
return RunEffectives(registry, executor, policy)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.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. */
|
||||
internal suspend fun SessionOrchestrator.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
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitArtifactRepairAttempted(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
slot: TypedArtifactSlot,
|
||||
classification: ArtifactFailure,
|
||||
rung: String,
|
||||
) = emit(
|
||||
sessionId,
|
||||
ArtifactRepairAttemptedEvent(slot.name, classification.name, rung, sessionId, stageId),
|
||||
)
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitArtifactRepairResolved(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
slot: TypedArtifactSlot,
|
||||
json: String,
|
||||
) {
|
||||
val hash = artifactStore.put(json.toByteArray())
|
||||
emit(sessionId, ArtifactRepairResolvedEvent(slot.name, hash, sessionId, stageId))
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.stampBuckets(entries: List<ContextEntry>): List<ContextEntry> = entries.map {
|
||||
if (it.sourceType in REQUIRED_SOURCE_TYPES) it.copy(bucket = ContextBucket.REQUIRED) else it
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.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).
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.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()
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitStageCheckpoint(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
) {
|
||||
if (eventStore.read(sessionId).none { it.payload is ExecutionPlanLockedEvent }) return
|
||||
val expected = stageConfig.produces.map { it.name.value }.toSet()
|
||||
if (expected.isEmpty()) return
|
||||
val produced = eventStore.read(sessionId)
|
||||
.mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId?.value }
|
||||
.toSet()
|
||||
val cp = StageCheckpointReconciler.reconcile(expected, produced)
|
||||
if (cp.passed) {
|
||||
emit(sessionId, StageCheckpointPassedEvent(sessionId, stageId, expected, produced))
|
||||
} else {
|
||||
emit(sessionId, StageCheckpointFailedEvent(sessionId, stageId, expected, produced, cp.missing))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity grounding (role-reliability §3): for a stage that opted in (`ground_references`),
|
||||
* check every workspace-relative file path its brief named against the filesystem. Existence
|
||||
* is a nondeterministic observation, so the result is recorded as a [BriefGroundingCheckedEvent]
|
||||
* (invariant #9) — replay reads it back, never re-probes. A hallucinated path fails the stage
|
||||
* (retryable), feeding the missing paths back so the model re-grounds on the next attempt.
|
||||
*/
|
||||
|
||||
// emit_artifact gives the model a structured channel to produce its required artifact: it calls
|
||||
// the tool with the fields filled in (parameter schema = the artifact's own schema) instead of
|
||||
// emitting free-form JSON as a final message. Tool-calling models handle this far more reliably,
|
||||
// and it sidesteps llama.cpp's grammar+tools incompatibility (the artifact can't be grammar-
|
||||
// constrained while tools are present). Empty when the stage has no LLM-emitted slot.
|
||||
internal fun SessionOrchestrator.emitArtifactTool(stageConfig: StageConfig): List<ToolDefinition> {
|
||||
val slot = stageConfig.produces.firstOrNull { it.kind.llmEmitted } ?: return emptyList()
|
||||
val schema = Json.encodeToJsonElement(JsonSchema.serializer(), slot.kind.deriveJsonSchema()).jsonObject
|
||||
return listOf(
|
||||
ToolDefinition(
|
||||
function = ToolFunction(
|
||||
name = EMIT_ARTIFACT_TOOL,
|
||||
description = "Emit the required '${slot.name.value}' artifact by filling in the fields " +
|
||||
"below. Prefer this over writing the JSON as a plain message.",
|
||||
parameters = schema,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationQuestions
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeHit
|
||||
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ClarificationRequestId
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Cache mapping "sessionId:artifactId" to JSON-serialized artifact payload content.
|
||||
* Populated when ProcessResult artifacts are stored on tool failure.
|
||||
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
|
||||
|
||||
internal fun SessionOrchestrator.evictArtifactContentCache(sessionId: SessionId) {
|
||||
val prefix = "${sessionId.value}:"
|
||||
artifactContentCache.keys.removeAll { it.startsWith(prefix) }
|
||||
}
|
||||
|
||||
/** Deterministic extraction/repair ladder for near-miss LLM artifact text (prose-wrapped JSON,
|
||||
* code fences, trailing commas). Pure — recomputes on replay, records no events. */
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildSchemaEntries(
|
||||
responseFormat: ResponseFormat,
|
||||
stageId: StageId,
|
||||
): List<ContextEntry> {
|
||||
if (responseFormat !is ResponseFormat.Json) return emptyList()
|
||||
val compactSchema = Json.encodeToString(JsonSchema.serializer(), responseFormat.schema)
|
||||
val instruction = "Respond with a single JSON object matching this schema. " +
|
||||
"Do not include markdown, code fences, or commentary outside the JSON. " +
|
||||
"Schema: $compactSchema"
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = instruction,
|
||||
sourceType = "schemaInstruction",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(instruction),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildSteeringNoteEntries(sessionId: SessionId): List<ContextEntry> {
|
||||
val events = eventStore.read(sessionId)
|
||||
var locked = false
|
||||
return events.mapNotNull { event ->
|
||||
when (val p = event.payload) {
|
||||
is ExecutionPlanLockedEvent -> { locked = true; null }
|
||||
is SteeringNoteAddedEvent -> ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = if (locked) ContextLayer.L0 else ContextLayer.L2,
|
||||
content = if (locked) "PRIORITY DIRECTIVE — overrides the locked plan: ${p.content}"
|
||||
else p.content,
|
||||
sourceType = "steeringNote",
|
||||
sourceId = p.stageId?.value ?: sessionId.value,
|
||||
tokenEstimate = estimateTokens(p.content),
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
is ApprovalDecisionResolvedEvent -> {
|
||||
val steering = p.userSteering
|
||||
when {
|
||||
steering != null -> ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
content = steering.text,
|
||||
sourceType = "steeringNote",
|
||||
sourceId = steering.sessionId.value,
|
||||
tokenEstimate = estimateTokens(steering.text),
|
||||
role = EntryRole.USER,
|
||||
)
|
||||
// A bare rejection (no note) still feeds back so the model does not
|
||||
// re-propose the identical call on the retryable re-run (anti-loop).
|
||||
p.outcome == ApprovalOutcome.REJECTED -> {
|
||||
val feedback = "A previous tool call was rejected by the user. " +
|
||||
"Do not repeat the same call; choose a different approach."
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
content = feedback,
|
||||
sourceType = "rejectionFeedback",
|
||||
sourceId = sessionId.value,
|
||||
tokenEstimate = estimateTokens(feedback),
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the initial user intent (the freeform request that started the run) as a pinned L0
|
||||
* SYSTEM entry present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent
|
||||
* is the single most load-bearing constraint of a run, yet it previously reached normal stages only
|
||||
* as a repo-map retrieval seed (repoKnowledgeQuery) or, on the rare Tier-2 recovery path, the
|
||||
* arbiter ticket — so an implementer could drift from the goal with the goal itself absent from its
|
||||
* authoritative context. Standing at L0/SYSTEM it is weighted as an instruction and never dropped
|
||||
* under budget. Absent for fixed-task workflows (no InitialIntentEvent) → empty.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId): List<ContextEntry> {
|
||||
val intent = initialIntent(sessionId)?.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||
val content = "## Authoritative intent\nThe operator's request that started this run. Everything " +
|
||||
"you produce must serve it; do not drift from it or expand its scope:\n$intent"
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = content,
|
||||
sourceType = "initialIntent",
|
||||
sourceId = sessionId.value,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The freeform request that started the run, or null for a fixed-task workflow. */
|
||||
internal fun SessionOrchestrator.initialIntent(sessionId: SessionId): String? =
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? InitialIntentEvent }
|
||||
.firstOrNull()?.intent?.trim()
|
||||
|
||||
/**
|
||||
* Injects the operator's answers to a stage's open questions as a pinned L0 SYSTEM entry, so the
|
||||
* stage sees its own questions resolved on the clarification re-run. The prompt calls these answers
|
||||
* "authoritative", so they are placed as authoritative standing instructions (L0/SYSTEM), not a
|
||||
* droppable L2 USER turn — matching the mechanics to the stated authority. Correlates each answer's
|
||||
* questionId back to the prompt recorded on the [ClarificationRequestedEvent].
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(sessionId: SessionId): List<ContextEntry> {
|
||||
val events = eventStore.read(sessionId)
|
||||
val answered = events.mapNotNull { it.payload as? ClarificationAnsweredEvent }
|
||||
if (answered.isEmpty()) return emptyList()
|
||||
val prompts = events
|
||||
.mapNotNull { it.payload as? ClarificationRequestedEvent }
|
||||
.flatMap { it.questions }
|
||||
.associate { it.id to it.prompt }
|
||||
val rendered = answered.flatMap { it.answers }.joinToString("\n") { a ->
|
||||
"Q: ${prompts[a.questionId] ?: a.questionId}\nA: ${a.value}"
|
||||
}
|
||||
val content = "The operator answered your open questions. Treat these answers as authoritative " +
|
||||
"and do not ask them again:\n$rendered"
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = content,
|
||||
sourceType = "clarificationAnswer",
|
||||
sourceId = sessionId.value,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
|
||||
* run: record the questions (invariant #9 — observed at parse time), await the operator's
|
||||
* answers, record them, and return true so the caller re-runs the stage with the answers in
|
||||
* context. Bounded by [MAX_CLARIFICATION_ROUNDS] so a stage that keeps re-asking eventually
|
||||
* proceeds. Returns false when there are no questions or the round budget is spent.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.requestClarificationIfNeeded(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
graph: WorkflowGraph,
|
||||
): Boolean {
|
||||
val slot = graph.stages[stageId]?.produces?.firstOrNull { it.kind.llmEmitted } ?: return false
|
||||
val content = artifactContentCache["${sessionId.value}:${slot.name.value}"] ?: return false
|
||||
val questions = ClarificationQuestions.parse(content)
|
||||
if (questions.isEmpty()) return false
|
||||
|
||||
val priorRounds = eventStore.read(sessionId)
|
||||
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
|
||||
if (priorRounds >= tuning.maxClarificationRounds) return false
|
||||
|
||||
val requestId = ClarificationRequestId(UUID.randomUUID().toString())
|
||||
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
|
||||
pendingClarifications[requestId] = deferred
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "CLARIFICATION_PENDING"))
|
||||
emit(sessionId, ClarificationRequestedEvent(requestId, sessionId, stageId, questions))
|
||||
return try {
|
||||
val answers = deferred.await()
|
||||
emit(sessionId, ClarificationAnsweredEvent(requestId, sessionId, stageId, answers))
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
true
|
||||
} finally {
|
||||
pendingClarifications.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the recorded repo map as a structural "what exists" listing — directories with
|
||||
* their files, alphabetical, no recency ranking and no symbol excerpts. Relevance is the
|
||||
* retrieval layer's job (buildRelevantFilesEntry); the map only tells the model what it can
|
||||
* `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).
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildRepoMapEntries(sessionId: SessionId, stagePrompt: String = ""): List<ContextEntry> {
|
||||
val map = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? RepoMapComputedEvent }
|
||||
.lastOrNull() ?: return emptyList()
|
||||
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
|
||||
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 {
|
||||
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(tuning.repoMapFilesPerDir)
|
||||
val more = names.size - shown.size
|
||||
val suffix = if (more > 0) ", …(+$more more)" else ""
|
||||
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
|
||||
}
|
||||
}.trimEnd()
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L3,
|
||||
content = content,
|
||||
sourceType = "repoMap",
|
||||
sourceId = "repo-map",
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [stageId]'s own prompt must be suppressed because the stage was entered to repair a
|
||||
* gate failure. That happens when the most recent transition INTO this stage was a ticket route
|
||||
* ([TICKET_ROUTE], same entry check as DefaultSessionOrchestrator.ticketReturnMove) — the failure
|
||||
* ticket routes an owner stage back to fix a file it authored, and its original generative mandate
|
||||
* ("scaffold the project") is exactly what drives it to overwrite that file with a stub. The
|
||||
* recovery-ticket entry ([buildRecoveryTicketEntry]) becomes the sole mandate instead.
|
||||
*
|
||||
* The purpose-built recovery arbiter (role=recovery) is excluded: it is also ticket-entered but
|
||||
* its own prompt IS a tuned repair prompt, so it keeps it. Replay-safe (recorded events only).
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildContextualRepoEntries(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
): List<ContextEntry> {
|
||||
val stagePrompt = repoKnowledgeQuery(sessionId, stageId, stageConfig)
|
||||
val recorded = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
|
||||
.lastOrNull { it.stageId == stageId }
|
||||
if (recorded != null) {
|
||||
return repoEntriesOrMapFloor(sessionId, recorded.hits, stagePrompt) + buildDocsCatalogEntry(sessionId)
|
||||
}
|
||||
|
||||
val retriever = repoKnowledgeRetriever
|
||||
?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId)
|
||||
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)
|
||||
emptyList()
|
||||
}
|
||||
// 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.
|
||||
if (hits.isNotEmpty()) emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, stagePrompt, hits))
|
||||
return repoEntriesOrMapFloor(sessionId, hits, stagePrompt) + buildDocsCatalogEntry(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on, capped catalog of the docs that exist, as `path — descriptor` lines (the descriptor
|
||||
* is the doc's frontmatter/H1/first-line summary recorded on [RepoMapEntry.symbols]). This is the
|
||||
* read-on-demand doc index: one line per doc lets a stage decide whether to file_read it, so docs
|
||||
* stay discoverable without the heading-flood the docs gate was added to suppress. Distinct from
|
||||
* [buildRepoMapEntries]/[repoEntriesOrMapFloor], which still exclude doc *content* from the source
|
||||
* layout for non-doc prompts. Reads the recorded map (replay-safe — never re-scans the FS).
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildDocsCatalogEntry(sessionId: SessionId): List<ContextEntry> {
|
||||
val map = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? RepoMapComputedEvent }
|
||||
.lastOrNull() ?: return emptyList()
|
||||
val docs = map.entries
|
||||
.filter { isDocPath(it.path) }
|
||||
.sortedByDescending { it.score }
|
||||
.take(tuning.docsCatalogMax)
|
||||
if (docs.isEmpty()) return emptyList()
|
||||
val content = buildString {
|
||||
appendLine("## Docs available (file_read to open — do not assume contents)")
|
||||
docs.forEach { entry ->
|
||||
val descriptor = entry.symbols.firstOrNull()?.takeIf { it.isNotBlank() }
|
||||
appendLine(if (descriptor != null) "- ${entry.path} — $descriptor" else "- ${entry.path}")
|
||||
}
|
||||
}.trimEnd()
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L3,
|
||||
content = content,
|
||||
sourceType = "docsCatalog",
|
||||
sourceId = "docs-catalog",
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
|
||||
* them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
|
||||
* useful survives, fall back to the deterministic repo map so the stage still has real grounding.
|
||||
*
|
||||
* Markdown/docs hits are gated exactly like the repo-map floor (buildRepoMapEntries): with the
|
||||
* embedder live, semantic similarity happily ranks churny kernel docs (epics, ADRs, QA plans)
|
||||
* above real source for a code task, re-poisoning the context the floor's gate was added to fix
|
||||
* (2026-07-05 root cause). They only survive when the stage prompt explicitly asks about docs.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.repoEntriesOrMapFloor(
|
||||
sessionId: SessionId,
|
||||
hits: List<RepoKnowledgeHit>,
|
||||
stagePrompt: String,
|
||||
): List<ContextEntry> {
|
||||
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
|
||||
val useful = hits.filter { it.score > 0f && (docsWanted || !isDocPath(it.path)) }
|
||||
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.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.buildNeedsArtifactEntries(
|
||||
sessionId: SessionId,
|
||||
stageConfig: StageConfig,
|
||||
criticIds: Set<ArtifactId> = emptySet(),
|
||||
criticFromStage: String? = null,
|
||||
): List<ContextEntry> {
|
||||
return stageConfig.needs.mapNotNull { needed ->
|
||||
val cached = artifactContentCache["${sessionId.value}:${needed.value}"]
|
||||
?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val content = if (needed in criticIds) {
|
||||
"## Critic feedback from stage '${criticFromStage ?: "reviewer"}': ${needed.value}\n" +
|
||||
"This is reviewer feedback on your previous attempt. " +
|
||||
"Address every finding before completing the stage.\n$cached"
|
||||
} else {
|
||||
// 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(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = content,
|
||||
sourceType = if (needed in criticIds) "criticFeedback" else "neededArtifact",
|
||||
sourceId = needed.value,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.USER,
|
||||
)
|
||||
}
|
||||
}
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.context.builder.RequiredContextOverflowException
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
|
||||
import com.correx.core.validation.artifact.ArtifactFailure
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
internal suspend fun SessionOrchestrator.executeStage(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
graph: WorkflowGraph,
|
||||
session: Session,
|
||||
config: OrchestrationConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val stageConfig = requireNotNull(graph.stages[stageId]) {
|
||||
"Stage '${stageId.value}' not declared in workflow graph"
|
||||
}
|
||||
val systemPrompt = stageConfig.metadata["systemPrompt"]
|
||||
?.let { path ->
|
||||
runCatching { promptResolver.resolve(path) }
|
||||
.onFailure {
|
||||
log.error(error(stageId, path, it))
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = text,
|
||||
sourceType = "systemPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(text),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
} ?: config.defaultSystemPromptPath
|
||||
?.let { path ->
|
||||
runCatching { promptResolver.resolve(path) }
|
||||
.onFailure {
|
||||
log.error(error(stageId, path, it))
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = text,
|
||||
sourceType = "systemPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(text),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
|
||||
// SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
|
||||
// recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
|
||||
// "make the smallest change, don't rewrite from scratch" plus the gate evidence. See
|
||||
// mandateSuppressedByTicket.
|
||||
val promptEntries = if (mandateSuppressedByTicket(sessionId, stageId, stageConfig)) {
|
||||
emptyList()
|
||||
} else {
|
||||
stageConfig.metadata["promptInline"]
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = text,
|
||||
sourceType = "agentPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(text),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
)
|
||||
}
|
||||
?: stageConfig.metadata["prompt"]
|
||||
?.let { path ->
|
||||
val resolvedText = runCatching { promptResolver.resolve(path) }
|
||||
.onFailure {
|
||||
log.error(
|
||||
"[SessionOrchestrator] stage=${stageId.value}: " +
|
||||
"failed to load prompt '$path': ${it.message}",
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
val text = resolvedText?.takeIf { it.isNotBlank() }
|
||||
?: error(
|
||||
"[SessionOrchestrator] stage=${stageId.value}: " +
|
||||
"declared prompt '$path' could not be resolved",
|
||||
)
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = text,
|
||||
sourceType = "agentPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(text),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
)
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
val llmEmittedSlots = stageConfig.produces.filter { it.kind.llmEmitted }
|
||||
require(llmEmittedSlots.size <= 1) {
|
||||
"Stage '${stageId.value}' declares ${llmEmittedSlots.size} LLM-emitted artifact kinds; " +
|
||||
"only 0 or 1 is supported"
|
||||
}
|
||||
val responseFormat = llmEmittedSlots.firstOrNull()
|
||||
?.let { ResponseFormat.Json(it.kind.deriveJsonSchema()) }
|
||||
?: ResponseFormat.Text
|
||||
|
||||
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
|
||||
val intentEntries = buildIntentEntry(sessionId)
|
||||
val steeringEntries = buildSteeringNoteEntries(sessionId)
|
||||
val clarificationEntries = buildClarificationAnswerEntries(sessionId)
|
||||
|
||||
// Maintain the running transcript in true chronological order. Reading it back
|
||||
// from currentContext.layers (a Map grouped by layer) would scramble turn order
|
||||
// across rounds; instead we grow our own ordered list and let the builder restamp
|
||||
// ordinals from it each round.
|
||||
val journalState = decisionJournalRepository.getJournal(sessionId)
|
||||
val summaryText = journalState.summaryArtifactId?.let { id ->
|
||||
artifactStore.get(id)?.toString(Charsets.UTF_8)
|
||||
?: run {
|
||||
log.warn("Journal summary artifact {} not found in store — rendering without summary", id.value)
|
||||
null
|
||||
}
|
||||
}
|
||||
val journalText = decisionJournalRenderer.render(journalState, summaryText)
|
||||
val journalEntries = if (journalText.isBlank()) emptyList() else listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = journalText,
|
||||
sourceType = "decisionJournal",
|
||||
sourceId = "decision-journal",
|
||||
tokenEstimate = estimateTokens(journalText),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
val repoMapEntries = buildContextualRepoEntries(sessionId, stageId, stageConfig)
|
||||
val sessionEvents = eventStore.read(sessionId)
|
||||
val criticIds = criticArtifactIds(sessionEvents, graph, stageId)
|
||||
val criticFrom = sessionEvents
|
||||
.mapNotNull { it.payload as? RefinementIterationEvent }
|
||||
.lastOrNull { it.cycleKey.endsWith("->${stageId.value}") }
|
||||
?.cycleKey?.substringBefore("->")
|
||||
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig, criticIds, criticFrom)
|
||||
val profileEntries = session.state.boundProfile?.about
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { about ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = "## Operator profile\n$about",
|
||||
sourceType = "operatorProfile",
|
||||
sourceId = "operator-profile",
|
||||
tokenEstimate = about.length / 4,
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
val projectProfileEntries = session.state.boundProjectProfile
|
||||
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
|
||||
// AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
|
||||
// carries a mandatory "Read Before Editing" walk — "read the whole AGENTS.md chain, do not
|
||||
// rely on memory, re-read before editing" — which drives a small model into an endless
|
||||
// read-loop that never writes (2026-07-05 root cause). Reincorporating DOX properly (scoped
|
||||
// to the stage's subproject, not the repo root; not read-mandating) is deferred.
|
||||
val agentInstructionsEntries = emptyList<ContextEntry>()
|
||||
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val vocabularyEntries = artifactKindRegistry
|
||||
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
|
||||
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
|
||||
// Deterministic claim: a stage flagged claimTask gets the kernel to claim the next ready task
|
||||
// (or re-surface the one already claimed) and inject its context bundle as L0 — the loop
|
||||
// advances by recorded fact, not by the agent remembering to claim.
|
||||
val claimedTaskEntries = if (stageConfig.metadata["claimTask"] == "true") {
|
||||
taskClaimCoordinator?.claimNext(sessionId)?.let { bundle ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = bundle,
|
||||
sourceType = "claimedTask",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = estimateTokens(bundle),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
// Remaining-delta checklist (stage-termination design 2026-07-11 §1a): the stage's currently
|
||||
// unmet file-contract assertions as a forward-looking, shrinking TODO. Injected pinned
|
||||
// (neverDrop) so it survives the budget/dedup passes that otherwise wipe the model's memory of
|
||||
// progress; refreshed inside the tool loop each time a write lands (§1b, cache-until-write).
|
||||
var remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
|
||||
val remainingDeltaEntries = remainingDeltaResults
|
||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
var accumulatedEntries = stampBuckets(
|
||||
systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
||||
)
|
||||
val contextPack = runCatching {
|
||||
contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = accumulatedEntries,
|
||||
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)
|
||||
|
||||
var currentContext = contextPack
|
||||
var inferenceResult = runInference(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
)
|
||||
var toolRounds = 0
|
||||
var consecutiveReadOnlyRounds = 0
|
||||
var consecutiveRejectedRounds = 0
|
||||
// Fingerprints ("tool:arguments") of every read-only call made during the current
|
||||
// no-write streak. A round only counts against the read-loop breaker if it repeats calls
|
||||
// already seen — a stage reading N distinct files before writing is legitimate context
|
||||
// gathering, not a loop, and shouldn't trip the same counter as re-reading the same file.
|
||||
val seenReadFingerprints = mutableSetOf<String>()
|
||||
// Set when the model produces its artifact via the emit_artifact tool instead of a final
|
||||
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
|
||||
var llmArtifactOverride: String? = null
|
||||
|
||||
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
|
||||
fun owesFileWrite(): Boolean = fileWrittenSlots.isNotEmpty() &&
|
||||
fileWrittenSlots.any { artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank() }
|
||||
|
||||
// Gate: if the stage declares require_task_decompose, block stage_complete until at least one
|
||||
// task_decompose (or task_create) call has completed successfully in this stage. Checked via
|
||||
// ToolInvocationRequestedEvent + ToolExecutionCompletedEvent in the session event log.
|
||||
// ponytail: scans events on every stage_complete attempt; negligible cost (in-memory list, rare call).
|
||||
fun owesTaskDecompose(): Boolean {
|
||||
if (stageConfig.metadata["requireTaskDecompose"] != "true") return false
|
||||
val events = eventStore.read(sessionId)
|
||||
val requestedIds = events
|
||||
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.filter { it.stageId == stageId && it.toolName in setOf("task_decompose", "task_create") }
|
||||
.map { it.invocationId }
|
||||
.toSet()
|
||||
if (requestedIds.isEmpty()) return true
|
||||
val completedIds = events
|
||||
.mapNotNull { it.payload as? ToolExecutionCompletedEvent }
|
||||
.filter { it.toolName in setOf("task_decompose", "task_create") && it.invocationId in requestedIds }
|
||||
.map { it.invocationId }
|
||||
.toSet()
|
||||
return completedIds.isEmpty()
|
||||
}
|
||||
|
||||
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
|
||||
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
|
||||
suspend fun pushBack(nudge: String, forceWriteOnly: Boolean = false): InferenceResult {
|
||||
accumulatedEntries = accumulatedEntries + ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = UUID.randomUUID().toString(),
|
||||
content = nudge,
|
||||
tokenEstimate = estimateTokens(nudge),
|
||||
role = EntryRole.TOOL,
|
||||
)
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = accumulatedEntries,
|
||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||
)
|
||||
emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
|
||||
toolRounds++
|
||||
return runInference(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
forceWriteOnly = forceWriteOnly,
|
||||
)
|
||||
}
|
||||
|
||||
val writeNudge = "ERROR: you described the change but did not apply it. You MUST call the " +
|
||||
"file_edit or file_write tool to write the change to the file before finishing — do " +
|
||||
"not output the file contents as a message."
|
||||
|
||||
val readLoopNudge = "STOP reading. You have read enough files and this stage has produced " +
|
||||
"nothing yet. You MUST now call the file_write tool to create the required file(s) for " +
|
||||
"this stage. Do not call file_read or list_dir again before you have written a file."
|
||||
|
||||
while (
|
||||
inferenceResult is InferenceResult.Success &&
|
||||
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
|
||||
// model emitted the change as prose instead of invoking the write tool. Nudge it to
|
||||
// actually write (F-018).
|
||||
if (inferenceResult.response.finishReason !is FinishReason.ToolCall) {
|
||||
inferenceResult = pushBack(writeNudge)
|
||||
continue
|
||||
}
|
||||
// emit_artifact is a meta-tool: the LLM calls it with the artifact's fields as arguments.
|
||||
// Capture those arguments as the artifact content and exit the loop straight to validation
|
||||
// (the post-loop capture honours this override instead of the empty assistant text).
|
||||
val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
|
||||
if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
|
||||
llmArtifactOverride = emitCall.function.arguments
|
||||
break
|
||||
}
|
||||
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
|
||||
// Skip tool dispatch — the loop exits and normal validation/transition follows
|
||||
// (emitToolArtifacts + verifyProduces run on the success path after the loop).
|
||||
if (inferenceResult.response.toolCalls.any { it.function.name == STAGE_COMPLETE_TOOL }) {
|
||||
// Premature-completion guard: don't accept stage_complete while the stage still
|
||||
// owes an artifact. For an LLM-emitted slot, nudge to emit the JSON (F-010); for a
|
||||
// file_written slot, nudge to invoke the write tool (F-018). Otherwise complete.
|
||||
val owedLlm = llmEmittedSlots.firstOrNull()?.takeIf {
|
||||
inferenceResult.response.text.isBlank() &&
|
||||
artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank()
|
||||
}
|
||||
when {
|
||||
owedLlm != null -> inferenceResult = pushBack(
|
||||
"ERROR: stage_complete is not allowed yet — you have not emitted the " +
|
||||
"required '${owedLlm.name.value}' artifact. Call the emit_artifact tool with " +
|
||||
"the fields filled in (or respond with the artifact as a single JSON message " +
|
||||
"matching the provided schema).",
|
||||
)
|
||||
owesFileWrite() -> inferenceResult = pushBack(writeNudge)
|
||||
owesTaskDecompose() -> inferenceResult = pushBack(
|
||||
"ERROR: stage_complete is not allowed yet — this stage requires a successful " +
|
||||
"task_decompose (or task_create) call before it can complete. Call task_decompose " +
|
||||
"with a parent epic and DEPENDS_ON-linked children representing the full task graph. " +
|
||||
"Do not call stage_complete until task_decompose has returned successfully.",
|
||||
)
|
||||
else -> break
|
||||
}
|
||||
continue
|
||||
}
|
||||
val toolEntries = dispatchToolCalls(sessionId, stageId,
|
||||
inferenceResult.response.toolCalls, stageConfig, effectives,
|
||||
approvalModeFor(session.state.boundProfile?.approvalMode),
|
||||
inferenceResult.response.reasoning)
|
||||
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
||||
if (fatalEntry != null) {
|
||||
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
||||
return StageExecutionResult.Failure(
|
||||
fatalEntry.content.removePrefix("FATAL: "),
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
// Recoverable (ERROR:) tool failures are NOT terminal — they flow back into the
|
||||
// loop as tool-result context so the model can see the error and adapt (bounded by
|
||||
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
|
||||
accumulatedEntries = accumulatedEntries + toolEntries
|
||||
// Read-loop breaker: this round called only read-only tools yet the stage still owes a
|
||||
// file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
|
||||
// never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
|
||||
// an endless read-loop). After READ_LOOP_NUDGE_THRESHOLD such rounds, force the write
|
||||
// nudge; a real write resets the counter so multi-file stages are unaffected.
|
||||
if (owesFileWrite() &&
|
||||
inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
|
||||
) {
|
||||
val roundFingerprints = inferenceResult.response.toolCalls
|
||||
.map { "${it.function.name}:${it.function.arguments}" }
|
||||
val madeProgress = roundFingerprints.any { it !in seenReadFingerprints }
|
||||
seenReadFingerprints += roundFingerprints
|
||||
if (madeProgress) {
|
||||
consecutiveReadOnlyRounds = 0
|
||||
} else {
|
||||
consecutiveReadOnlyRounds++
|
||||
if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
|
||||
consecutiveReadOnlyRounds = 0
|
||||
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutiveReadOnlyRounds = 0
|
||||
seenReadFingerprints.clear()
|
||||
}
|
||||
// Rejection-loop breaker (stage-type agnostic): every tool result this round was a
|
||||
// rejection (plane-2 BLOCKED or a stage/policy ERROR) and nothing succeeded. A stage that
|
||||
// owes no file_written artifact (discovery, analyst) never trips the read-loop breaker, so
|
||||
// without this it re-issues the same rejected call until MAX_TOOL_ROUNDS. After the
|
||||
// threshold, tell it to stop retrying and produce its output; any success resets.
|
||||
val toolResults = toolEntries.filter { it.role == EntryRole.TOOL }
|
||||
val allRejected = toolResults.isNotEmpty() &&
|
||||
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
|
||||
if (allRejected) {
|
||||
consecutiveRejectedRounds++
|
||||
if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
|
||||
consecutiveRejectedRounds = 0
|
||||
inferenceResult = pushBack(
|
||||
"STOP. Your last tool calls were all rejected and retrying the same paths will " +
|
||||
"keep failing. Do not repeat them. Proceed with the information you already " +
|
||||
"have: produce this stage's required output now (call stage_complete, or emit " +
|
||||
"the required artifact). If a path you wanted does not exist yet, that is a " +
|
||||
"fact to record — do not keep probing for it.",
|
||||
)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
consecutiveRejectedRounds = 0
|
||||
}
|
||||
// Refresh the remaining-delta checklist only when a write actually landed this round
|
||||
// (cache-until-write, §1b): reads can never change the contract verdict, so re-evaluating
|
||||
// on them would just burn filesystem work and re-emit identical events. On a successful
|
||||
// write, recompute contract-minus-reality and swap the pinned entry so the model watches
|
||||
// the list shrink toward the empty=done signal it previously lacked.
|
||||
val wroteThisRound = inferenceResult.response.toolCalls.any { it.function.name in WRITE_TOOL_NAMES } &&
|
||||
toolEntries.any {
|
||||
it.role == EntryRole.TOOL &&
|
||||
!it.content.startsWith("ERROR:") && !it.content.startsWith("BLOCKED:")
|
||||
}
|
||||
if (wroteThisRound) {
|
||||
remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
|
||||
val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } +
|
||||
listOfNotNull(refreshed)
|
||||
}
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = accumulatedEntries,
|
||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||
)
|
||||
emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
|
||||
inferenceResult = runInference(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
)
|
||||
toolRounds++
|
||||
}
|
||||
// Final clean emission. Producing the artifact while tools are in the request is unreliable on
|
||||
// some local chat templates (Gemma leaks `<|channel>` markers into the text; other models keep
|
||||
// tool-calling until the round cap and never emit). For an LLM-emitted stage that hasn't already
|
||||
// produced its artifact via the emit_artifact tool, fire ONE tools-less inference — verified to
|
||||
// yield clean JSON deterministically. Skipped when the model already emitted clean final text.
|
||||
if (inferenceResult is InferenceResult.Success && llmEmittedSlots.isNotEmpty() && llmArtifactOverride == null) {
|
||||
val last = inferenceResult.response
|
||||
val needsCleanEmission = last.finishReason is FinishReason.ToolCall ||
|
||||
last.text.isBlank() || last.text.contains("<|channel")
|
||||
if (needsCleanEmission && !isCancelled(sessionId)) {
|
||||
val nudge = "Stop calling tools. Output the required '${llmEmittedSlots.first().name.value}' " +
|
||||
"artifact now as a single JSON object matching the schema — no tool calls, no commentary."
|
||||
accumulatedEntries = accumulatedEntries + ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = UUID.randomUUID().toString(),
|
||||
content = nudge,
|
||||
tokenEstimate = estimateTokens(nudge),
|
||||
role = EntryRole.TOOL,
|
||||
)
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = accumulatedEntries,
|
||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||
)
|
||||
inferenceResult = runInference(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs,
|
||||
responseFormat, effectives, withTools = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
return when (inferenceResult) {
|
||||
is InferenceResult.Cancelled -> StageExecutionResult.Failure("CANCELLED", retryable = false)
|
||||
is InferenceResult.Failed ->
|
||||
StageExecutionResult.Failure(inferenceResult.reason, retryable = isRetryableFailure(inferenceResult.reason))
|
||||
is InferenceResult.Success -> {
|
||||
if (isCancelled(sessionId)) return StageExecutionResult.Failure("CANCELLED", retryable = false)
|
||||
// Capture the LLM-emitted artifact's JSON so transition conditions
|
||||
// (e.g. ArtifactFieldEquals on a reviewer's verdict) can read it. An emit_artifact
|
||||
// tool call (llmArtifactOverride) supplies the content directly; otherwise the
|
||||
// artifact is the final assistant message text.
|
||||
val rawArtifactText = llmArtifactOverride ?: inferenceResult.response.text
|
||||
val slot = llmEmittedSlots.firstOrNull()
|
||||
// Artifact-emission ladder: deterministic repair (prose/fences/trailing commas), then
|
||||
// — rarely, gated by the failure classifier — one grammar-constrained LLM-repair rung,
|
||||
// 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
|
||||
artifactHash?.let { hash ->
|
||||
emit(
|
||||
sessionId,
|
||||
ArtifactContentStoredEvent(
|
||||
artifactId = slot.name,
|
||||
contentHash = hash,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val validationCtx = ValidationContext(
|
||||
graph = graph,
|
||||
sessionState = session.state,
|
||||
availableTools = effectives.registry?.all()?.map { it.name }?.toSet(),
|
||||
producedArtifactContent = graph.stages.values.flatMap { it.produces }.mapNotNull { slot ->
|
||||
artifactContentCache["${sessionId.value}:${slot.name.value}"]?.let { slot.name.value to it }
|
||||
}.toMap(),
|
||||
)
|
||||
when (val outcome = mapValidationOutcome(sessionId, stageId, validationCtx)) {
|
||||
is StageExecutionResult.Success -> {
|
||||
emitToolArtifacts(sessionId, stageId, stageConfig)
|
||||
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
||||
runPostStageGates(
|
||||
sessionId, stageId, stageConfig, effectives,
|
||||
session.state.boundProjectProfile?.commands ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
is StageExecutionResult.Failure -> outcome
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifacts.kind.KindContractTable
|
||||
import com.correx.core.artifacts.kind.KindInference
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
import com.correx.core.events.events.ContractAssertionResult
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.GroundingReference
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
internal fun SessionOrchestrator.verifyProduces(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
): StageExecutionResult {
|
||||
if (stageConfig.produces.isEmpty()) {
|
||||
if (stageConfig.allowedTools.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
val ranTool = eventStore.read(sessionId).any {
|
||||
(it.payload as? ToolInvocationRequestedEvent)?.stageId == stageId
|
||||
}
|
||||
if (ranTool) return StageExecutionResult.Success(emptyList())
|
||||
log.error(
|
||||
"[Orchestrator] stage declared no artifacts and ran no tools " +
|
||||
"session=${sessionId.value} stage=${stageId.value}",
|
||||
)
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} declared no artifacts and ran no tools",
|
||||
retryable = true,
|
||||
gate = "produces",
|
||||
)
|
||||
}
|
||||
val producedIds = stageConfig.produces.map { it.name }.toSet()
|
||||
val present = eventStore.read(sessionId)
|
||||
.mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId }
|
||||
.toSet()
|
||||
val missing = producedIds - present
|
||||
if (missing.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
val missingIds = missing.joinToString(", ") { it.value }
|
||||
log.error(
|
||||
"[Orchestrator] stage missing produced artifacts " +
|
||||
"session=${sessionId.value} stage=${stageId.value} missing=$missingIds",
|
||||
)
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not produce declared artifacts: $missingIds",
|
||||
retryable = true,
|
||||
gate = "produces",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage-level plan checkpointing (BACKLOG §C-A2): make plan adherence observable and replayable
|
||||
* by emitting a first-class checkpoint event per stage, reconciling produced-vs-expected against
|
||||
* the confirmed (locked) plan. Only runs on plan-driven (phase-2) sessions — i.e. a log that
|
||||
* holds an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. The halt itself is owned
|
||||
* by [verifyProduces]; this method only EMITS — a [StageCheckpointFailedEvent] accompanies that
|
||||
* halt and surfaces *why* the plan diverged.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.groundBriefReferences(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
if (stageConfig.metadata["groundReferences"] != "true") return StageExecutionResult.Success(emptyList())
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||
val artifact = stageConfig.produces
|
||||
.filter { it.kind.llmEmitted }
|
||||
.firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] }
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val references = BriefReferenceExtractor.fileReferences(artifact)
|
||||
if (references.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val results = references.map { ref ->
|
||||
GroundingReference(ref, worldProbe.exists(workspaceRoot.resolve(ref)))
|
||||
}
|
||||
emit(sessionId, BriefGroundingCheckedEvent(sessionId, stageId, stageId.value, results))
|
||||
|
||||
val missing = results.filterNot { it.grounded }.map { it.reference }
|
||||
return if (missing.isEmpty()) {
|
||||
StageExecutionResult.Success(emptyList())
|
||||
} else {
|
||||
log.warn(
|
||||
"[Orchestrator] brief references nonexistent paths session={} stage={} missing={}",
|
||||
sessionId.value, stageId.value, missing.joinToString(", "),
|
||||
)
|
||||
StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} references paths that do not exist in the workspace: " +
|
||||
"${missing.joinToString(", ")}. Reference only files you have read.",
|
||||
retryable = true,
|
||||
gate = "brief_grounding",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brief echo gate (plan-pipeline-addenda §A1): for a stage that opted in (`brief_echo`),
|
||||
* diff the model's restatement artifact against the original brief. Both are already in the
|
||||
* artifact content cache (recorded via ArtifactCreatedEvent), so the diff is a pure function
|
||||
* of recorded data — deterministically recomputable on replay without re-probing any
|
||||
* environment (invariants #8, #9). Emits [BriefEchoMismatchEvent] on mismatch as a domain
|
||||
* signal, then fails the stage (retryable) so the pipeline cannot reach plan generation on a
|
||||
* misread brief.
|
||||
*
|
||||
* Brief-source selection among `needs`: prefers the artifact named "analysis"; if absent,
|
||||
* falls back to the single needs artifact present. When there are multiple needs and none is
|
||||
* named "analysis", the gate skips (ambiguous brief) and returns Success.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.checkBriefEcho(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
): StageExecutionResult {
|
||||
if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val echoSlot = stageConfig.produces.firstOrNull { it.kind.llmEmitted }
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
val echo = artifactContentCache["${sessionId.value}:${echoSlot.name.value}"]
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val brief = when {
|
||||
stageConfig.needs.any { it.value == "analysis" } ->
|
||||
artifactContentCache["${sessionId.value}:analysis"]
|
||||
stageConfig.needs.size == 1 ->
|
||||
artifactContentCache["${sessionId.value}:${stageConfig.needs.single().value}"]
|
||||
else -> null // ambiguous — skip
|
||||
} ?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val div = BriefEchoDiff.compare(brief, echo)
|
||||
|
||||
return if (div.isMismatch) {
|
||||
emit(
|
||||
sessionId,
|
||||
BriefEchoMismatchEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
uncoveredRequirements = div.uncoveredRequirements,
|
||||
hallucinatedFiles = div.hallucinatedFiles,
|
||||
hallucinatedSymbols = div.hallucinatedSymbols,
|
||||
),
|
||||
)
|
||||
log.warn(
|
||||
"[Orchestrator] brief echo mismatch session={} stage={} uncovered={} hallucinatedFiles={}",
|
||||
sessionId.value, stageId.value,
|
||||
div.uncoveredRequirements.joinToString(", "),
|
||||
div.hallucinatedFiles.joinToString(", "),
|
||||
)
|
||||
StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} restated the brief unfaithfully — " +
|
||||
"dropped requirements: [${div.uncoveredRequirements.joinToString("; ")}]; " +
|
||||
"files not in the brief: [${div.hallucinatedFiles.joinToString(", ")}]. " +
|
||||
"Restate the brief exactly; do not drop requirements or invent files.",
|
||||
retryable = true,
|
||||
gate = "brief_echo",
|
||||
)
|
||||
} else {
|
||||
StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-validation stage gates, run in order; the first failure short-circuits and is returned.
|
||||
* Cheap deterministic checks run first (produces presence, brief grounding, brief echo), then
|
||||
* the heavier process-spawning static-analysis step last, so it only runs once a stage is
|
||||
* otherwise sound.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.runPostStageGates(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
profileCommands: Map<String, String>,
|
||||
): StageExecutionResult {
|
||||
val produced = verifyProduces(sessionId, stageId, stageConfig)
|
||||
if (produced is StageExecutionResult.Failure) return produced
|
||||
// Stage-level plan checkpoint (C-A2): once a stage has produced its declared artifacts,
|
||||
// record the checkpoint before the heavier brief/static gates run.
|
||||
emitStageCheckpoint(sessionId, stageId, stageConfig)
|
||||
val gates: List<suspend () -> StageExecutionResult> = listOf(
|
||||
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
||||
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
||||
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
|
||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
||||
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
|
||||
)
|
||||
for (gate in gates) {
|
||||
val result = gate()
|
||||
if (result is StageExecutionResult.Failure) return result
|
||||
}
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 2 — contract (design 2026-07-06-staged-verification-and-review.md §1). For a stage that
|
||||
* produces file_written artifacts, project the files it actually wrote (Option B: runtime
|
||||
* manifest), infer each file's kind from its path, derive the [KindContractTable] assertions
|
||||
* (universal floor for unknown kinds), and evaluate the FS/TEXT ones deterministically. Every
|
||||
* verdict is recorded in a [ContractGateEvaluatedEvent] (invariant #9), so replay reads it back
|
||||
* and never re-inspects the filesystem.
|
||||
*
|
||||
* A failed assertion fails the stage retryably with the failing assertion ids + evidence fed
|
||||
* back verbatim — failing-test-name granular hand-back, so the implementer fixes exactly the
|
||||
* empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
|
||||
* skipped here; the static-analysis command gate is their authoritative check.
|
||||
*/
|
||||
/**
|
||||
* Evaluate the stage's file contract against current filesystem reality and record the verdict
|
||||
* in a [ContractGateEvaluatedEvent] (invariant #9). Returns the per-assertion results, or null
|
||||
* when the gate does not apply (no evaluator, no workspace root, no file_written produces, or no
|
||||
* paths). Shared by the post-stage [runContractGate] and the within-loop remaining-delta refresh
|
||||
* ([buildRemainingDeltaEntry]) so both read the same contract-minus-reality computation from a
|
||||
* single evaluation + single event emission.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.evaluateStageContract(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): List<ContractAssertionResult>? {
|
||||
val evaluator = contractAssertionEvaluator ?: return null
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return null
|
||||
if (stageConfig.produces.none { it.kind.id == "file_written" }) return null
|
||||
|
||||
// Option A ∪ B: the files the stage actually wrote (runtime manifest) unioned with the
|
||||
// concrete files the plan declared it would produce (expectedFiles). A declared file that
|
||||
// was never written survives here and fails file_exists — that is the missing-file catch.
|
||||
val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
|
||||
if (paths.isEmpty()) return null
|
||||
|
||||
val assertions = paths.flatMap { path ->
|
||||
KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
|
||||
}
|
||||
val results = assertions.map { a ->
|
||||
val v = evaluator.evaluate(workspaceRoot, a)
|
||||
ContractAssertionResult(
|
||||
assertionId = a.id,
|
||||
target = a.target,
|
||||
layer = a.layer.name,
|
||||
evaluator = a.evaluator.name,
|
||||
passed = v.passed,
|
||||
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
|
||||
)
|
||||
}
|
||||
emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))
|
||||
return results
|
||||
}
|
||||
|
||||
/** The currently-failing assertions as (target, assertionId, evidence) triples for the checklist. */
|
||||
internal fun SessionOrchestrator.contractFailureItems(results: List<ContractAssertionResult>): List<Triple<String, String, String>> =
|
||||
results.filterNot { it.passed }.map { Triple(it.target, it.assertionId, it.evidence) }
|
||||
|
||||
internal suspend fun SessionOrchestrator.runContractGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val results = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val failures = results.filterNot { it.passed }
|
||||
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] contract gate failed session={} stage={} assertions={}",
|
||||
sessionId.value, stageId.value,
|
||||
failures.joinToString(", ") { "${it.assertionId}(${it.target})" },
|
||||
)
|
||||
val detail = failures.joinToString("\n") { "- ${it.assertionId} on ${it.target}: ${it.evidence}" }
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not satisfy its file contract. Fix these before review:\n$detail",
|
||||
retryable = true,
|
||||
gate = "contract",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan-compile gate: for a stage that produces an `execution_plan` artifact (the freestyle
|
||||
* architect), compile its produced plan JSON as a post-stage gate. A compile failure fails the
|
||||
* stage retryably with the compiler error fed back, so the existing retry-feedback loop hands it
|
||||
* to the architect and the model self-corrects — rather than the post-planning compiler rejecting
|
||||
* a broken plan with no re-plan path (which parked the session in ACTIVE forever). The verdict is
|
||||
* recorded in a [PlanCompileCheckedEvent] (invariant #9) so replay reads it back and never
|
||||
* re-compiles.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.runPlanCompileGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
): StageExecutionResult {
|
||||
val check = planCompilationCheck ?: return StageExecutionResult.Success(emptyList())
|
||||
val planSlot = stageConfig.produces.firstOrNull { it.kind.id == "execution_plan" }
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
val json = artifactContentCache["${sessionId.value}:${planSlot.name.value}"]
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val error = check.check(json, "freestyle-${sessionId.value}")
|
||||
emit(sessionId, PlanCompileCheckedEvent(sessionId, stageId, passed = error == null, error = error))
|
||||
if (error == null) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] plan-compile gate failed session={} stage={}: {}",
|
||||
sessionId.value, stageId.value, error,
|
||||
)
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} produced an execution_plan that does not compile. " +
|
||||
"Fix the plan before it can be locked:\n$error",
|
||||
retryable = true,
|
||||
gate = "plan_compile",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
|
||||
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
|
||||
* under them. Pure projection, replay-safe.
|
||||
*/
|
||||
/**
|
||||
* True if the session has committed to a buildable project — it wrote either a real code module
|
||||
* (a path whose inferred kind carries the `imports_resolve` COMPILER assertion: TS/React/Kotlin
|
||||
* source) OR a build manifest ([BUILD_MANIFEST_KINDS]: package.json, a Gradle module) that
|
||||
* declares a toolchain. Read from the recorded FileWritten manifest (invariant #9: replay-safe,
|
||||
* no filesystem access), it is the run-time signal that warrants the auto build gate — the
|
||||
* planner's declared kinds (uniformly `file_written`) never reveal this, but the written paths do.
|
||||
*
|
||||
* The manifest case is what catches the degenerate scaffold that writes only `package.json` and
|
||||
* no sources: `npm run build` then fails for lack of an entry, so the gate rejects the empty
|
||||
* "project" instead of letting it reach COMPLETED. A docs-only plan writes neither, so it is left
|
||||
* alone.
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.sessionProducedBuildTarget(sessionId: SessionId): Boolean =
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.filter { it.postImageHash != null }
|
||||
.map { it.path }
|
||||
.distinct()
|
||||
.any { path ->
|
||||
val kind = KindInference.kindFor(path) ?: return@any false
|
||||
kind in BUILD_MANIFEST_KINDS ||
|
||||
KindContractTable.assertionsFor(kind, path).any { it.id == "imports_resolve" }
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
|
||||
val events = eventStore.read(sessionId)
|
||||
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.filter { it.stageId == stageId }
|
||||
.map { it.invocationId }
|
||||
.toSet()
|
||||
return events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.filter { it.invocationId in invocationIds && it.postImageHash != null }
|
||||
.map { it.path }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
|
||||
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
|
||||
* workspace root. Every result is recorded in a [StaticAnalysisCompletedEvent] — an environment
|
||||
* observation captured at run time (invariant #9), so replay reads it back and never re-runs.
|
||||
*
|
||||
* A non-clean command fails the stage retryably with its output fed back verbatim (§2: compiler/
|
||||
* test output is ground truth, not LLM noise), so the producing stage fixes the issue and only
|
||||
* static-clean output ever reaches the downstream LLM reviewer — its context thereby excludes
|
||||
* anything static tools already catch, with no parsing or context plumbing.
|
||||
*/
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisFinding
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.BuildExpectation
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import java.nio.file.Path
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
internal suspend fun SessionOrchestrator.runStaticAnalysis(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
if (stageConfig.staticAnalysis.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
val runner = staticAnalysisRunner
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||
if (runner == null || workspaceRoot == null) {
|
||||
log.warn(
|
||||
"[Orchestrator] stage declares static_analysis but no {} is wired — skipping " +
|
||||
"session={} stage={}",
|
||||
if (runner == null) "runner" else "workspace root",
|
||||
sessionId.value, stageId.value,
|
||||
)
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
val results = stageConfig.staticAnalysis.map { command ->
|
||||
val run = runner.run(workspaceRoot, command)
|
||||
StaticAnalysisFinding(
|
||||
command = command,
|
||||
exitCode = run.exitCode,
|
||||
summary = run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP),
|
||||
) to run.output
|
||||
}
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, results.map { it.first }))
|
||||
|
||||
val failures = results.filterNot { it.first.clean }
|
||||
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] static analysis failed session={} stage={} commands={}",
|
||||
sessionId.value, stageId.value,
|
||||
failures.joinToString(", ") { it.first.command },
|
||||
)
|
||||
val detail = failures.joinToString("\n\n") { (finding, output) ->
|
||||
"$ ${finding.command} (exit ${finding.exitCode})\n${output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP)}"
|
||||
}
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not pass static analysis. Fix these before review:\n\n$detail",
|
||||
retryable = true,
|
||||
gate = "static_analysis",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
|
||||
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
|
||||
* command and runs it as a deterministic build gate. NONE runs nothing (early stages where the
|
||||
* project isn't yet runnable). A profile missing the alias skips with a warning — the vocabulary
|
||||
* degrades safely rather than failing when the operator hasn't configured commands. A non-clean
|
||||
* run fails the stage retryably with the build output fed back (recorded as a
|
||||
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.runExecutionGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
profileCommands: Map<String, String>,
|
||||
): StageExecutionResult {
|
||||
// An explicit build_expectation resolves directly. Otherwise, if the compiler flagged this
|
||||
// (terminal) stage as auto-gateable and the session actually wrote a code module, promote to a
|
||||
// PROJECT build — the LLM planner never sets build_expectation on its file_written stages, so
|
||||
// this is the only path that gates a scaffold's compilability. Code-ness is judged here (not at
|
||||
// compile time) from the real FileWritten manifest, so a docs-only plan is left alone.
|
||||
val expectation: BuildExpectation? = when {
|
||||
stageConfig.buildExpectation != BuildExpectation.NONE -> stageConfig.buildExpectation
|
||||
stageConfig.autoBuildGate && sessionProducedBuildTarget(sessionId) -> BuildExpectation.PROJECT
|
||||
else -> null
|
||||
}
|
||||
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
|
||||
val runner = staticAnalysisRunner
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
|
||||
val command = profileCommands[alias]
|
||||
if (command.isNullOrBlank()) {
|
||||
log.warn(
|
||||
"[Orchestrator] stage {} needs a {} build gate but project profile has no '{}' " +
|
||||
"command — skipping execution gate",
|
||||
stageId.value, expectation, alias,
|
||||
)
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
runSetupCommand(sessionId, stageId, workspaceRoot, profileCommands, runner)?.let { return it }
|
||||
|
||||
val run = runner.run(workspaceRoot, command)
|
||||
val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
|
||||
if (finding.clean) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
|
||||
sessionId.value, stageId.value, expectation, command,
|
||||
)
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not pass its $expectation build gate. " +
|
||||
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
|
||||
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
|
||||
retryable = true,
|
||||
gate = "execution",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the operator-declared `setup` command (profile alias [SETUP_COMMAND_ALIAS]) before a build
|
||||
* gate, when one is configured. A build like `tsc && vite build` fails spuriously if the project
|
||||
* was never prepared (deps uninstalled, codegen not run) — the funnel reads that as a code failure
|
||||
* and routes into futile repair. The operator names the fix in their profile (`setup = "npm ci"`,
|
||||
* `"cargo fetch"`, `"./gradlew dependencies"`, …); the kernel stays ecosystem-agnostic. Runs in the
|
||||
* workspace root, recorded as a [StaticAnalysisCompletedEvent] (invariant #9). Returns a
|
||||
* [StageExecutionResult.Failure] when setup itself fails (a real, actionable error), else null (no
|
||||
* setup configured, or it succeeded — proceed to the build).
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.runSetupCommand(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
workspaceRoot: Path,
|
||||
profileCommands: Map<String, String>,
|
||||
runner: StaticAnalysisRunner,
|
||||
): StageExecutionResult? {
|
||||
val setup = profileCommands[SETUP_COMMAND_ALIAS]?.takeIf { it.isNotBlank() } ?: return null
|
||||
log.info(
|
||||
"[Orchestrator] running setup command before build gate session={} stage={} command='{}'",
|
||||
sessionId.value, stageId.value, setup,
|
||||
)
|
||||
val run = runner.run(workspaceRoot, setup)
|
||||
val finding = StaticAnalysisFinding(setup, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
|
||||
if (finding.clean) return null
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} setup command failed before its build gate. " +
|
||||
"Fix these before proceeding:\n\n$ $setup (exit ${run.exitCode})\n" +
|
||||
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
|
||||
retryable = true,
|
||||
gate = "execution",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
|
||||
* (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
|
||||
* (the FileWritten manifest — never blind context) and reviews them against the stage objective,
|
||||
* returning PR-comment-style findings. Runs last, so its input is static-clean (the deterministic
|
||||
* funnel already passed) and its findings exclude anything the cheap gates catch.
|
||||
*
|
||||
* The reviewer is advisory: every finding is recorded in a [ReviewFindingsRaisedEvent] and
|
||||
* surfaces to the operator regardless of verdict. A [ReviewVerdict.FAIL] blocks the stage
|
||||
* retryably ONLY when it carries a high-confidence *correctness* finding and the review-block
|
||||
* budget ([REVIEW_BLOCK_RETRY_CAP]) is not yet spent — so a stuck reviewer can never trap the
|
||||
* stage; after the budget, findings surface without blocking. Suboptimal/opinion findings never
|
||||
* block. Recorded as an environment observation (invariant #9); replay never re-runs the reviewer.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.runReviewGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
if (!stageConfig.semanticReview) return StageExecutionResult.Success(emptyList())
|
||||
val reviewer = semanticReviewer ?: return StageExecutionResult.Success(emptyList())
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||
val files = stageWrittenPaths(sessionId, stageId)
|
||||
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
// Goal-conformance framing (architecture-conformance 2026-07-14): lead the reviewer with the
|
||||
// authoritative intent and the stage's declared scope so it can judge whether the files serve
|
||||
// the goal (and stay within scope), not just whether they are internally defect-free — the
|
||||
// deterministic gates already cover mechanical correctness. Absent intent/scope degrade to the
|
||||
// prior upstream-artifact objective.
|
||||
val intentHeader = initialIntent(sessionId)?.takeIf { it.isNotBlank() }
|
||||
?.let { "## Authoritative intent (the goal these files must serve)\n$it\n\n" } ?: ""
|
||||
val scopeHeader = stageConfig.touches.takeIf { it.isNotEmpty() }
|
||||
?.let { "## This stage's declared scope\n${it.joinToString(", ")}\n\n" } ?: ""
|
||||
val objective = (
|
||||
intentHeader + scopeHeader +
|
||||
stageConfig.needs
|
||||
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
|
||||
.joinToString("\n\n")
|
||||
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
|
||||
).take(REVIEW_OBJECTIVE_CAP)
|
||||
|
||||
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
|
||||
|
||||
// A FAIL blocks only on a high-confidence correctness finding, and only while the review-block
|
||||
// budget remains — count prior review blocks for this stage from the log (replay-safe).
|
||||
val priorBlocks = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
|
||||
.count { it.stageId == stageId && it.blocked }
|
||||
val blockingFinding = outcome.findings.firstOrNull {
|
||||
it.correctness && it.confidence >= tuning.reviewBlockMinConfidence
|
||||
}
|
||||
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
|
||||
blockingFinding != null &&
|
||||
priorBlocks < tuning.reviewBlockRetryCap
|
||||
|
||||
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
|
||||
|
||||
if (!shouldBlock) {
|
||||
if (outcome.findings.isNotEmpty()) {
|
||||
log.info(
|
||||
"[Orchestrator] semantic review advisory session={} stage={} verdict={} findings={}",
|
||||
sessionId.value, stageId.value, outcome.verdict, outcome.findings.size,
|
||||
)
|
||||
}
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] semantic review blocked session={} stage={} finding={}",
|
||||
sessionId.value, stageId.value, blockingFinding?.target,
|
||||
)
|
||||
val detail = outcome.findings
|
||||
.filter { it.correctness }
|
||||
.joinToString("\n") { "- [${it.severity}] ${it.target}: ${it.message}" +
|
||||
(it.suggestedFix?.let { fix -> "\n fix: $fix" } ?: "") }
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} failed semantic review — fix these correctness issues:\n$detail",
|
||||
retryable = true,
|
||||
gate = "review",
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitProcessResultEvents(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
) {
|
||||
// Emit artifact lifecycle events for process_result slots on failure branches.
|
||||
// Content was already stored in CAS + cache by dispatchToolCalls for every outcome.
|
||||
stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot ->
|
||||
emitAll(
|
||||
sessionId,
|
||||
listOf(
|
||||
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
|
||||
ArtifactValidatingEvent(slot.name, sessionId, stageId),
|
||||
ArtifactValidatedEvent(slot.name, sessionId, stageId),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse a validation report into a concise, actionable reason. This string becomes the
|
||||
// RetryAttemptedEvent.failureReason that the retry-feedback entry feeds back to the model, so a
|
||||
// weak model is told *what* was wrong (e.g. "required property 'summary' missing") instead of a
|
||||
// bare "validation failed" it can't act on. Capped to keep the feedback prompt small.
|
||||
internal fun SessionOrchestrator.summarizeRejection(report: ValidationReport): String {
|
||||
val errors = report.sections.flatMap { section ->
|
||||
section.issues
|
||||
.filter { it.severity == ValidationSeverity.ERROR }
|
||||
.map { it.message }
|
||||
}.distinct()
|
||||
return if (errors.isEmpty()) {
|
||||
"validation failed"
|
||||
} else {
|
||||
"validation failed: " + errors.take(tuning.maxFeedbackIssues).joinToString("; ")
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
/**
|
||||
* Compute a unified-diff preview for file-affecting tool calls.
|
||||
* For [file_write] with operation "write": reads the existing file (if any) and diffs it
|
||||
* against the proposed content from the LLM arguments.
|
||||
* For all other tools: returns null (caller falls back to raw JSON args).
|
||||
*
|
||||
* This function performs blocking I/O (file reads) and must be called from a suspend context
|
||||
* that will dispatch it on [Dispatchers.IO].
|
||||
*/
|
||||
internal suspend fun computeToolPreview(
|
||||
toolName: String,
|
||||
parameters: Map<String, Any>,
|
||||
workspaceRoot: java.nio.file.Path?,
|
||||
): String? {
|
||||
if (toolName == "shell") return shellCommandPreview(parameters)
|
||||
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
|
||||
if (toolName == "file_edit") return computeFileEditPreview(parameters, workspaceRoot)
|
||||
if (toolName != "file_write") return null
|
||||
val path = parameters["path"] as? String ?: return null
|
||||
// file_write no longer carries an `operation` param (delete was split into file_delete), so the
|
||||
// preview is unconditional: path + content is always a write. The old `operation == "write"` gate
|
||||
// silently bailed to the raw-JSON args fallback for every file_write.
|
||||
val proposedContent = parameters["content"] as? String ?: return null
|
||||
|
||||
val existingContent = readFileIfExists(path, workspaceRoot)
|
||||
return buildDiffString(path, existingContent, proposedContent)
|
||||
}
|
||||
|
||||
internal suspend fun readFileIfExists(path: String, workspaceRoot: java.nio.file.Path?): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// Resolve relative paths against the session's workspace root, same as the tools do —
|
||||
// resolving against the daemon CWD showed the operator the wrong file (or nothing) when
|
||||
// server CWD ≠ workspace_root.
|
||||
val raw = java.nio.file.Paths.get(path)
|
||||
val filePath = if (raw.isAbsolute || workspaceRoot == null) raw else workspaceRoot.resolve(raw)
|
||||
if (java.nio.file.Files.exists(filePath)) {
|
||||
java.nio.file.Files.readString(filePath)
|
||||
} else null
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview for `file_edit`'s "replace" and "append" operations: reads the existing file and
|
||||
* simulates the edit locally to produce a diff, mirroring `file_write`'s preview. "patch" is left
|
||||
* to the raw-JSON-args fallback — simulating `patch -p1` application here isn't worth the risk of
|
||||
* the preview silently disagreeing with what the tool actually does.
|
||||
*/
|
||||
internal suspend fun computeFileEditPreview(
|
||||
parameters: Map<String, Any>,
|
||||
workspaceRoot: java.nio.file.Path?,
|
||||
): String? {
|
||||
val path = parameters["path"] as? String ?: return null
|
||||
val operation = parameters["operation"] as? String ?: return null
|
||||
val existingContent = readFileIfExists(path, workspaceRoot) ?: return null
|
||||
|
||||
val proposedContent = when (operation) {
|
||||
"append" -> existingContent + (parameters["content"] as? String ?: return null)
|
||||
"replace" -> {
|
||||
val target = parameters["target"] as? String ?: return null
|
||||
val replacement = parameters["replacement"] as? String ?: return null
|
||||
if (!existingContent.contains(target)) return null
|
||||
existingContent.replaceFirst(target, replacement)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return buildDiffString(path, existingContent, proposedContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a shell tool's argv as a full, shell-quoted command line for the approval
|
||||
* card (tui-requirements §5 / §3 "full untruncated command"). Returning the rendered
|
||||
* line — rather than the raw `arguments.take(200)` fallback — keeps the command both
|
||||
* untruncated and human-readable; the TUI's deterministic parser re-tokenizes it.
|
||||
*/
|
||||
internal fun shellCommandPreview(parameters: Map<String, Any>): String? {
|
||||
val argv = when (val raw = parameters["argv"]) {
|
||||
is List<*> -> raw.filterIsInstance<String>()
|
||||
is String -> runCatching { Json.decodeFromString<List<String>>(raw) }.getOrElse { emptyList() }
|
||||
else -> emptyList()
|
||||
}
|
||||
if (argv.isEmpty()) return null
|
||||
return argv.joinToString(" ") { shellQuoteToken(it) }
|
||||
}
|
||||
|
||||
/** Single-quote a token when it carries whitespace or shell metacharacters. */
|
||||
internal fun shellQuoteToken(token: String): String {
|
||||
if (token.isEmpty()) return "''"
|
||||
val needsQuote = token.any { it.isWhitespace() || it in "\"'\\|&;<>(){}\$`*?[]~#!" }
|
||||
return if (needsQuote) "'" + token.replace("'", "'\\''") + "'" else token
|
||||
}
|
||||
|
||||
internal enum class DiffOpKind { EQUAL, DELETE, INSERT }
|
||||
internal data class DiffOp(val kind: DiffOpKind, val line: String)
|
||||
|
||||
/**
|
||||
* Build a unified-diff string for a full file replacement, using a line-level LCS diff
|
||||
* so a small edit to a large file shows as a tight hunk rather than a full-file replacement.
|
||||
* When the file doesn't exist yet (new file), only `+` lines are shown.
|
||||
*/
|
||||
internal fun buildDiffString(path: String, existingContent: String?, proposedContent: String): String {
|
||||
val existingLines = existingContent?.lines() ?: emptyList()
|
||||
val proposedLines = proposedContent.lines()
|
||||
if (existingLines == proposedLines) return " (no change)"
|
||||
|
||||
val ops = diffLines(existingLines, proposedLines)
|
||||
return buildString {
|
||||
appendLine("--- a/$path")
|
||||
appendLine("+++ b/$path")
|
||||
append(renderHunks(ops))
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
/** Line-level LCS diff producing an edit script of EQUAL/DELETE/INSERT ops. */
|
||||
internal fun diffLines(oldLines: List<String>, newLines: List<String>): List<DiffOp> {
|
||||
val n = oldLines.size
|
||||
val m = newLines.size
|
||||
val lcs = Array(n + 1) { IntArray(m + 1) }
|
||||
for (i in n - 1 downTo 0) {
|
||||
for (j in m - 1 downTo 0) {
|
||||
lcs[i][j] = if (oldLines[i] == newLines[j]) {
|
||||
lcs[i + 1][j + 1] + 1
|
||||
} else {
|
||||
maxOf(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
val ops = mutableListOf<DiffOp>()
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < n && j < m) {
|
||||
when {
|
||||
oldLines[i] == newLines[j] -> {
|
||||
ops += DiffOp(DiffOpKind.EQUAL, oldLines[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
lcs[i + 1][j] >= lcs[i][j + 1] -> {
|
||||
ops += DiffOp(DiffOpKind.DELETE, oldLines[i])
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
ops += DiffOp(DiffOpKind.INSERT, newLines[j])
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops += DiffOp(DiffOpKind.DELETE, oldLines[i])
|
||||
i++
|
||||
}
|
||||
while (j < m) {
|
||||
ops += DiffOp(DiffOpKind.INSERT, newLines[j])
|
||||
j++
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
/** Groups an edit script into unified-diff hunks with [context] lines of surrounding EQUAL context. */
|
||||
internal fun renderHunks(ops: List<DiffOp>, context: Int = 3): String {
|
||||
val changedIndices = ops.indices.filter { ops[it].kind != DiffOpKind.EQUAL }
|
||||
if (changedIndices.isEmpty()) return ""
|
||||
|
||||
data class Hunk(val start: Int, val end: Int)
|
||||
val hunks = mutableListOf<Hunk>()
|
||||
var hunkStart = changedIndices.first()
|
||||
var hunkEnd = changedIndices.first()
|
||||
for (idx in changedIndices.drop(1)) {
|
||||
if (idx - hunkEnd <= context * 2) {
|
||||
hunkEnd = idx
|
||||
} else {
|
||||
hunks += Hunk(hunkStart, hunkEnd)
|
||||
hunkStart = idx
|
||||
hunkEnd = idx
|
||||
}
|
||||
}
|
||||
hunks += Hunk(hunkStart, hunkEnd)
|
||||
|
||||
var oldLine = 1
|
||||
var newLine = 1
|
||||
var opIdx = 0
|
||||
return buildString {
|
||||
for (hunk in hunks) {
|
||||
val from = maxOf(0, hunk.start - context)
|
||||
val to = minOf(ops.size - 1, hunk.end + context)
|
||||
while (opIdx < from) {
|
||||
if (ops[opIdx].kind != DiffOpKind.INSERT) oldLine++
|
||||
if (ops[opIdx].kind != DiffOpKind.DELETE) newLine++
|
||||
opIdx++
|
||||
}
|
||||
val oldStart = oldLine
|
||||
val newStart = newLine
|
||||
var oldCount = 0
|
||||
var newCount = 0
|
||||
val lines = StringBuilder()
|
||||
for (k in from..to) {
|
||||
val op = ops[k]
|
||||
when (op.kind) {
|
||||
DiffOpKind.EQUAL -> {
|
||||
lines.append(' ').append(op.line).append('\n')
|
||||
oldCount++
|
||||
newCount++
|
||||
}
|
||||
DiffOpKind.DELETE -> {
|
||||
lines.append('-').append(op.line).append('\n')
|
||||
oldCount++
|
||||
}
|
||||
DiffOpKind.INSERT -> {
|
||||
lines.append('+').append(op.line).append('\n')
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
appendLine("@@ -$oldStart,${oldCount.coerceAtLeast(1)} +$newStart,${newCount.coerceAtLeast(1)} @@")
|
||||
append(lines)
|
||||
oldLine = oldStart + oldCount
|
||||
newLine = newStart + newCount
|
||||
opIdx = to + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate in [paths] that shares [guessed]'s filename and the most trailing path segments,
|
||||
* or null when nothing shares the filename. Lets a wrong package/dir prefix
|
||||
* (`com/correx/server/routes/SessionRoutes.kt`) still resolve to the real file
|
||||
* (`.../com/correx/apps/server/routes/SessionRoutes.kt`) without ever inventing a non-existent path.
|
||||
*/
|
||||
internal fun closestPathByFilename(guessed: String, paths: List<String>): String? {
|
||||
val guessedName = guessed.substringAfterLast('/')
|
||||
if (guessedName.isBlank()) return null
|
||||
val guessedSegs = guessed.split('/').filter { it.isNotBlank() }
|
||||
return paths.asSequence()
|
||||
.filter { it.substringAfterLast('/') == guessedName }
|
||||
.maxByOrNull { candidate ->
|
||||
val segs = candidate.split('/').filter { it.isNotBlank() }
|
||||
var shared = 0
|
||||
var i = segs.size - 1
|
||||
var j = guessedSegs.size - 1
|
||||
while (i >= 0 && j >= 0 && segs[i] == guessedSegs[j]) {
|
||||
shared++; i--; j--
|
||||
}
|
||||
shared
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered
|
||||
* child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic
|
||||
* fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are
|
||||
* re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments.
|
||||
*/
|
||||
internal fun renderDecomposePreview(parameters: Map<String, Any>): String? {
|
||||
val project = parameters["project"] as? String ?: return null
|
||||
val tasks = (parameters["tasks"] as? String)
|
||||
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null
|
||||
if (tasks.isEmpty()) return null
|
||||
val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content }
|
||||
val refToIndex = tasks.mapIndexedNotNull { i, e ->
|
||||
((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i }
|
||||
}.toMap()
|
||||
val parentTitle = (parameters["parent"] as? String)
|
||||
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject }
|
||||
?.let { (it["title"] as? JsonPrimitive)?.content }
|
||||
|
||||
return buildString {
|
||||
append("Decompose '").append(project).append("' into:")
|
||||
parentTitle?.let { append("\n epic: ").append(it) }
|
||||
tasks.forEachIndexed { i, e ->
|
||||
val o = e as? JsonObject
|
||||
append("\n ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)")
|
||||
val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList())
|
||||
.map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d }
|
||||
if (afters.isNotEmpty()) append(" (after: ").append(afters.joinToString(", ")).append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
internal fun SessionOrchestrator.error(
|
||||
stageId: StageId,
|
||||
path: String,
|
||||
throwable: Throwable,
|
||||
): String = "[SessionOrchestrator] stage=${stageId.value}: " +
|
||||
"failed to load prompt '$path': ${throwable.message}"
|
||||
|
||||
/**
|
||||
* Strip an outer markdown code fence (```` ```json `` / ``` ``` ````) when the whole response
|
||||
* is a single fenced block. Conservative: only acts when the trimmed text both starts and ends
|
||||
* with a fence, so prose responses that merely contain an inline block are left untouched.
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.resolveStagePromptText(stageId: StageId, stageConfig: StageConfig): String {
|
||||
stageConfig.metadata["promptInline"]?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
stageConfig.metadata["prompt"]?.let { path ->
|
||||
runCatching { promptResolver.resolve(path) }.getOrNull()
|
||||
?.trim()?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
}
|
||||
return stageId.value
|
||||
}
|
||||
|
||||
/**
|
||||
* The role's own prompt text (see [resolveStagePromptText]) is identical across every session
|
||||
* that runs the stage — it carries zero session-specific signal, so similarity against it can
|
||||
* only ever reflect generic lexical overlap with the role's boilerplate ("file_read", "ls",
|
||||
* "grep"...), never what the user actually asked for. The one thing that IS session-specific
|
||||
* is the initial user intent, so it's prepended here as the real anchor for the query (2026-07-08
|
||||
* finding: without it, the query matched a tool script's own symbol names over the files the
|
||||
* user's request actually named).
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.repoKnowledgeQuery(sessionId: SessionId, stageId: StageId, stageConfig: StageConfig): String {
|
||||
val intent = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? InitialIntentEvent }
|
||||
.firstOrNull()?.intent?.trim()
|
||||
val stagePrompt = resolveStagePromptText(stageId, stageConfig)
|
||||
return if (intent.isNullOrBlank()) stagePrompt else "$intent\n\n$stagePrompt"
|
||||
}
|
||||
|
||||
// --- event emission ---
|
||||
|
||||
// Surface context-budget overflow as an event on the workflow path. The builder already
|
||||
// enforces the budget (drops oldest/compressible entries, pinning steering + event history);
|
||||
// this records that it happened so replay and operators can see it, mirroring the router path.
|
||||
internal suspend fun SessionOrchestrator.emitContextTruncationIfNeeded(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
contextPack: ContextPack,
|
||||
) {
|
||||
val meta = contextPack.compressionMetadata
|
||||
if (meta.entriesDropped <= 0) return
|
||||
emit(
|
||||
sessionId,
|
||||
ContextTruncatedEvent(
|
||||
sessionId = sessionId,
|
||||
turnId = stageId.value,
|
||||
entriesDropped = meta.entriesDropped,
|
||||
truncatedLayers = meta.truncatedLayers.map { it.name },
|
||||
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.fallbackTokenEstimate(content: String): Int {
|
||||
return (content.length / 4).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
// --- stage-entry approval gate ---
|
||||
|
||||
/**
|
||||
* Emits [OrchestrationPausedEvent] + [ApprovalRequestedEvent] for a stage flagged
|
||||
* `requiresApproval`, awaits the user decision, and records it.
|
||||
* Returns `true` if the run should proceed into the stage, `false` if the approval was rejected.
|
||||
*/
|
||||
+732
@@ -0,0 +1,732 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
|
||||
import com.correx.core.approvals.ProjectIdentity
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.isAtMost
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||
import com.correx.core.artifacts.kind.ProcessResultArtifact
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
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.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||
import com.correx.core.toolintent.toAssessedIssues
|
||||
import com.correx.core.toolintent.toRiskSummary
|
||||
import com.correx.core.sessions.projections.EgressAllowlistProjection
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.inference.ToolCallRequest
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.tools.compression.ToolOutputContext
|
||||
import com.correx.core.tools.contract.FileAffectingTool
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
internal fun SessionOrchestrator.stripCodeFence(text: String): String {
|
||||
val trimmed = text.trim()
|
||||
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
|
||||
val withoutClose = trimmed.removeSuffix("```").trimEnd()
|
||||
val firstNewline = withoutClose.indexOf('\n')
|
||||
if (firstNewline < 0) return text
|
||||
// Drop the opening fence line (``` optionally followed by a language tag).
|
||||
return withoutClose.substring(firstNewline + 1).trim()
|
||||
}
|
||||
|
||||
// On a recoverable tool failure, append the tool's argument schema to the error fed back to the
|
||||
// model so it can self-correct its next attempt (it already sees its own malformed call in the
|
||||
// assistant entry above) rather than repeating the same mistake. Kept compact to bound context.
|
||||
// NOTE: the "corrected arguments" phrasing was removed because it actively drives loops: a model
|
||||
// that called list_dir frontend/ and got "directory not found" interprets "re-issue with corrected
|
||||
// arguments" as "retry the same call" rather than "try a different path" — the arguments were
|
||||
// valid, the path simply doesn't exist. The schema reference stays so malformed calls can still
|
||||
// self-correct; the model must infer the tactic from the tool's error text.
|
||||
internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String =
|
||||
tool?.let {
|
||||
"\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
|
||||
}.orEmpty()
|
||||
|
||||
|
||||
/**
|
||||
* Render a tool result into the consistently-framed, bounded text the model sees. Success output
|
||||
* is run through the tool's [ToolOutputCompressor], then framed with a uniform `[tool exit=N]`
|
||||
* header. If it still exceeds [TOOL_RESULT_MAX_CHARS] the FULL raw output is spilled to the
|
||||
* artifact store (CAS — durable, its hash recorded on the ToolReceipt in the event log) and only a
|
||||
* head+tail preview is shown, with a marker telling the model to call `tool_output(ref=…)` for the
|
||||
* rest. Failures keep their `ERROR:`/`FATAL:` sentinels unchanged — the all-rejected loop breaker
|
||||
* keys on those prefixes.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.renderToolResult(toolName: String, tool: Tool?, result: ToolResult): RenderedToolResult =
|
||||
when (result) {
|
||||
is ToolResult.Failure -> RenderedToolResult(
|
||||
if (!result.recoverable) "FATAL: ${result.reason}" else "ERROR: ${result.reason}${toolArgsHint(tool)}",
|
||||
null,
|
||||
)
|
||||
is ToolResult.Success -> {
|
||||
val compressed = tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode))
|
||||
?: result.output
|
||||
val header = "[$toolName exit=${result.exitCode}]"
|
||||
if (compressed.length <= TOOL_RESULT_MAX_CHARS) {
|
||||
RenderedToolResult("$header\n$compressed", null)
|
||||
} else {
|
||||
// Spill the full RAW output (most complete) so retrieval returns everything, not the
|
||||
// already-compressed preview the model saw.
|
||||
val ref = artifactStore.put(result.output.toByteArray()).value
|
||||
RenderedToolResult(frameTruncatedToolResult(header, compressed, ref), ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.dispatchToolCalls(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
toolCalls: List<ToolCallRequest>,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
approvalMode: ApprovalMode,
|
||||
// The reasoning that preceded this whole batch of tool calls (they all came from one
|
||||
// inference response). Attached only to the first call's assistant entry — one thought
|
||||
// block per model turn, matching how the response actually arrived.
|
||||
reasoning: String? = null,
|
||||
): List<ContextEntry> {
|
||||
val executor = effectives.executor ?: return emptyList()
|
||||
val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
|
||||
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
|
||||
return toolCalls.withIndex().flatMap { (toolCallIndex, toolCall) ->
|
||||
// Only the first call's assistant entry carries the reasoning — the whole batch came
|
||||
// from one model turn/one thought block, so later calls in the same batch get none.
|
||||
val toolCallReasoning = reasoning.takeIf { toolCallIndex == 0 }
|
||||
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
val parameters = parseToolArguments(toolCall.function.arguments)
|
||||
val request = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = toolCall.function.name,
|
||||
parameters = parameters,
|
||||
)
|
||||
val tool = effectives.registry?.resolve(toolCall.function.name)
|
||||
val tier = tool?.tier ?: Tier.T2
|
||||
// Out-of-workspace read access: a read whose target lexically resolves outside the
|
||||
// workspace root. The intent plane raises PROMPT_USER for it; once the operator approves
|
||||
// (recorded as OutsidePathAccessGrantedEvent), the same path this session skips the prompt
|
||||
// and the tool jail is widened for it. Writes never take this path (isRead gate).
|
||||
val isRead = tool?.requiredCapabilities?.contains(ToolCapability.FILE_READ) == true &&
|
||||
tool.requiredCapabilities.contains(ToolCapability.FILE_WRITE) != true
|
||||
val outsideReadTarget = if (isRead) {
|
||||
outsideWorkspaceTarget(parameters, effectives.policy?.workspaceRoot)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val grantedOutside = grantedOutsidePaths(sessionId)
|
||||
val alreadyGranted = outsideReadTarget != null && outsideReadTarget.toString() in grantedOutside
|
||||
emit(
|
||||
sessionId,
|
||||
ToolInvocationRequestedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
request = request,
|
||||
capabilities = tool?.requiredCapabilities ?: emptySet(),
|
||||
),
|
||||
)
|
||||
val deniedByStage = toolCall.function.name != STAGE_COMPLETE_TOOL &&
|
||||
toolCall.function.name !in stageConfig.effectiveAllowedTools
|
||||
val deniedByReadOnly = !deniedByStage && isReadOnlyMode(sessionId) &&
|
||||
(tool?.requiredCapabilities?.contains(ToolCapability.FILE_WRITE) == true)
|
||||
if (deniedByStage || deniedByReadOnly) {
|
||||
val denyReason = if (deniedByReadOnly) {
|
||||
"read-before-write: '${toolCall.function.name}' requires reading the file first — use file_read"
|
||||
} else {
|
||||
"tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
|
||||
}
|
||||
emit(
|
||||
sessionId,
|
||||
ToolExecutionRejectedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
reason = denyReason,
|
||||
),
|
||||
)
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
return@flatMap listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "assistantToolCall",
|
||||
sourceId = sourceId,
|
||||
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
|
||||
tokenEstimate = estimateTokens(toolCall.function.arguments),
|
||||
role = EntryRole.ASSISTANT,
|
||||
reasoning = toolCallReasoning,
|
||||
),
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = sourceId,
|
||||
content = "ERROR: $denyReason",
|
||||
tokenEstimate = estimateTokens(denyReason),
|
||||
role = EntryRole.TOOL,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Per-task write scope: while a task is claimed, narrow the manifest to its affected
|
||||
// paths (recorded on the task) so the implementer can't write outside its unit of work.
|
||||
// Falls back to the stage's static manifest when nothing is claimed.
|
||||
val effectiveManifest = taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() }
|
||||
?: stageConfig.writeManifest
|
||||
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
||||
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
|
||||
effectiveManifest,
|
||||
)?.let { assessment ->
|
||||
if (assessment.recommendedAction == RiskAction.BLOCK) {
|
||||
val rationale = assessment.rationale.joinToString("; ")
|
||||
// On a bad-path block, point the model at the closest real file so it fixes the
|
||||
// path instead of retrying the same wrong guess (deep package paths are easy to
|
||||
// misremember). Only fires when we can name a concrete match from the repo map.
|
||||
val suggestion = if (assessment.rationale.any { it.contains(REFERENCE_EXISTS_CODE) }) {
|
||||
(parameters["path"] as? String)
|
||||
?.let { suggestClosestPath(sessionId, it) }
|
||||
?.let { " Did you mean: $it ?" }
|
||||
.orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
emit(
|
||||
sessionId,
|
||||
ToolExecutionRejectedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
// Record the specific rule rationale, not a generic string, so the audit
|
||||
// trail (and task history) shows why a call was blocked.
|
||||
reason = rationale.ifBlank { "blocked by tool-call policy" },
|
||||
),
|
||||
)
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
return@flatMap listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "assistantToolCall",
|
||||
sourceId = sourceId,
|
||||
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
|
||||
tokenEstimate = estimateTokens(toolCall.function.arguments),
|
||||
role = EntryRole.ASSISTANT,
|
||||
reasoning = toolCallReasoning,
|
||||
),
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = sourceId,
|
||||
content = "BLOCKED: $rationale$suggestion",
|
||||
tokenEstimate = estimateTokens(rationale + suggestion),
|
||||
role = EntryRole.TOOL,
|
||||
),
|
||||
)
|
||||
}
|
||||
assessment
|
||||
}
|
||||
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
|
||||
// A steering note attached to a human approval is captured here and injected after the
|
||||
// tool result, so the same-stage loop re-infers with it and the model acts on the note.
|
||||
var approvalNote: String? = null
|
||||
if ((tier.isAtMost(Tier.T1) && !plane2Prompts) || alreadyGranted) {
|
||||
// no approval needed — either within the auto-approve tier, or this out-of-workspace
|
||||
// read path was already approved earlier this session (this-path-this-session).
|
||||
} else {
|
||||
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
|
||||
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
|
||||
// workspace root so PROJECT grants match later sessions on the same repo.
|
||||
val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
|
||||
val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
|
||||
val activeGrants = (sessionGrants + ledgerGrants).toList()
|
||||
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
|
||||
val approvalCtx = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
|
||||
mode = approvalMode,
|
||||
)
|
||||
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
||||
val toolPreview = computeToolPreview(
|
||||
toolCall.function.name, parameters, effectives.policy?.workspaceRoot,
|
||||
)
|
||||
val domainRequest = DomainApprovalRequest(
|
||||
id = requestId,
|
||||
tier = tier,
|
||||
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
|
||||
riskSummaryId = null,
|
||||
timestamp = Clock.System.now(),
|
||||
toolName = toolCall.function.name,
|
||||
preview = toolPreview ?: toolCall.function.arguments.take(200),
|
||||
)
|
||||
val engineDecision = approvalEngine.evaluate(
|
||||
domainRequest, approvalCtx, activeGrants, Clock.System.now(),
|
||||
)
|
||||
if (engineDecision.state == ApprovalStatus.COMPLETED) {
|
||||
emitDecisionResolved(sessionId, domainRequest, engineDecision)
|
||||
if (!engineDecision.isApproved) {
|
||||
val rejectReason = engineDecision.reason ?: "denied"
|
||||
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
|
||||
emit(sessionId, ToolExecutionRejectedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
reason = rejectReason,
|
||||
))
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
return@flatMap listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "assistantToolCall",
|
||||
sourceId = sourceId,
|
||||
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
|
||||
tokenEstimate = estimateTokens(toolCall.function.arguments),
|
||||
role = EntryRole.ASSISTANT,
|
||||
reasoning = toolCallReasoning,
|
||||
),
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = sourceId,
|
||||
content = "ERROR: $rejectReason",
|
||||
tokenEstimate = estimateTokens(rejectReason),
|
||||
role = EntryRole.TOOL,
|
||||
),
|
||||
)
|
||||
}
|
||||
// grant auto-approved — fall through to execute
|
||||
} else {
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[requestId] = deferred
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = tier,
|
||||
validationReportId = domainRequest.validationReportId,
|
||||
riskSummaryId = null,
|
||||
riskSummary = plane2Risk,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
toolName = toolCall.function.name,
|
||||
preview = toolPreview ?: toolCall.function.arguments.take(200),
|
||||
),
|
||||
)
|
||||
val userDecision = try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pendingApprovals.remove(requestId)
|
||||
}
|
||||
emitDecisionResolved(sessionId, domainRequest, userDecision)
|
||||
if (!userDecision.isApproved) {
|
||||
val rejectReason = userDecision.reason ?: "approval denied"
|
||||
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
|
||||
emit(sessionId, ToolExecutionRejectedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
reason = rejectReason,
|
||||
))
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
return@flatMap listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "assistantToolCall",
|
||||
sourceId = sourceId,
|
||||
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
|
||||
tokenEstimate = estimateTokens(toolCall.function.arguments),
|
||||
role = EntryRole.ASSISTANT,
|
||||
reasoning = toolCallReasoning,
|
||||
),
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = sourceId,
|
||||
content = "ERROR: $rejectReason",
|
||||
tokenEstimate = estimateTokens(rejectReason),
|
||||
role = EntryRole.TOOL,
|
||||
),
|
||||
)
|
||||
}
|
||||
approvalNote = userDecision.userSteering?.text
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
}
|
||||
}
|
||||
// Reaching here means the call is authorized (all deny/block/pending paths returned early).
|
||||
// For a first-time out-of-workspace read, record the grant so future reads of the same path
|
||||
// this session skip the prompt (invariants #8/#9 — replay reads the recorded fact). Then
|
||||
// widen the executed request's jail with every path approved outside the workspace.
|
||||
if (outsideReadTarget != null && !alreadyGranted) {
|
||||
emit(
|
||||
sessionId,
|
||||
OutsidePathAccessGrantedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
path = outsideReadTarget.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val executedRequest = outsideReadTarget
|
||||
?.let { request.copy(grantedPaths = grantedOutside + it.toString()) }
|
||||
?: request
|
||||
val result = executor.execute(executedRequest)
|
||||
// Frame + bound the result once; on truncation this spills the full output to CAS and
|
||||
// returns its hash, which we record on the receipt so the event log points at the full text.
|
||||
val rendered = renderToolResult(toolCall.function.name, tool, result)
|
||||
|
||||
// Store ProcessResult artifact for every shell execution outcome
|
||||
for (slot in processResultSlots) {
|
||||
val processResult = when (result) {
|
||||
is ToolResult.Success -> ProcessResultArtifact(
|
||||
status = "success",
|
||||
command = toolCall.function.arguments,
|
||||
exitCode = result.exitCode,
|
||||
stdout = result.output,
|
||||
stderr = result.metadata["stderr"] ?: "",
|
||||
durationMs = 0,
|
||||
)
|
||||
is ToolResult.Failure -> ProcessResultArtifact(
|
||||
status = "failed",
|
||||
command = toolCall.function.arguments,
|
||||
exitCode = -1,
|
||||
stdout = "",
|
||||
stderr = result.reason,
|
||||
durationMs = 0,
|
||||
)
|
||||
}
|
||||
val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult)
|
||||
val storedHash = artifactStore.put(jsonContent.toByteArray())
|
||||
artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent
|
||||
emit(
|
||||
sessionId,
|
||||
ArtifactContentStoredEvent(
|
||||
artifactId = slot.name,
|
||||
contentHash = storedHash,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Record the tool execution outcome as events (invariant #5: every side effect is
|
||||
// captured; silent execution is not allowed). For file-mutating tools, materialise a
|
||||
// real file_written artifact from the actual on-disk result — so a stage that did NOT
|
||||
// write cannot have its file_written slot rubber-stamped as validated (F-015), and the
|
||||
// artifact carries the diff as evidence of the change.
|
||||
recordToolExecution(
|
||||
sessionId, stageId, toolCall, invocationId, tier, result,
|
||||
tool as? FileAffectingTool, request, fileWrittenSlots, rendered.fullOutputHash,
|
||||
)
|
||||
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
val assistantEntry = ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "assistantToolCall",
|
||||
sourceId = sourceId,
|
||||
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
|
||||
tokenEstimate = estimateTokens(toolCall.function.arguments),
|
||||
role = EntryRole.ASSISTANT,
|
||||
reasoning = toolCallReasoning,
|
||||
)
|
||||
val resultEntry = ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = sourceId,
|
||||
content = rendered.content,
|
||||
tokenEstimate = estimateTokens(rendered.content),
|
||||
role = EntryRole.TOOL,
|
||||
)
|
||||
val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "steeringNote",
|
||||
sourceId = sourceId,
|
||||
content = "User steering on the approved '${toolCall.function.name}' call: $it",
|
||||
tokenEstimate = estimateTokens(it),
|
||||
role = EntryRole.USER,
|
||||
)
|
||||
}
|
||||
listOfNotNull(assistantEntry, resultEntry, steeringEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected [SCOPE_PROPOSAL_TOOL] call means the operator denied widening the claimed task's
|
||||
* scope — block the task so the loop advances instead of the implementer retrying the same
|
||||
* out-of-scope write. Only fires for the scope-proposal tool; other denied tools are unaffected.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) {
|
||||
if (toolName == SCOPE_PROPOSAL_TOOL) {
|
||||
taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason")
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.runPlane2Assessment(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
invocationId: ToolInvocationId,
|
||||
toolName: String,
|
||||
request: ToolRequest,
|
||||
tool: Tool?,
|
||||
effectives: RunEffectives,
|
||||
writeManifest: List<String>,
|
||||
): RiskSummary? {
|
||||
val assessor = toolCallAssessor ?: return null
|
||||
val policy = effectives.policy ?: return null
|
||||
// Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents
|
||||
// through the projection. Unioned with the static allow-list in NetworkHostRule; empty when
|
||||
// nothing has been granted, which never narrows the static allow-list.
|
||||
val sessionEgressHosts = resolveSessionEgressHosts(sessionId)
|
||||
val assessment = assessor.assess(
|
||||
ToolCallAssessmentInput(
|
||||
request = request,
|
||||
capabilities = tool?.requiredCapabilities ?: emptySet(),
|
||||
workspace = policy,
|
||||
probe = worldProbe,
|
||||
paramRoles = tool?.paramRoles ?: emptyMap(),
|
||||
writeManifest = writeManifest,
|
||||
sessionEgressHosts = sessionEgressHosts,
|
||||
session = resolveSessionContext(sessionId),
|
||||
),
|
||||
)
|
||||
emit(
|
||||
sessionId,
|
||||
ToolCallAssessedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = toolName,
|
||||
issues = assessment.toAssessedIssues(),
|
||||
observations = assessment.observations,
|
||||
disposition = assessment.disposition,
|
||||
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
||||
),
|
||||
)
|
||||
return assessment.toRiskSummary()
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through
|
||||
* [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads
|
||||
* the log, never live state); empty when no grants have been emitted.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitToolArtifacts(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
) {
|
||||
stageConfig.produces
|
||||
.filter { !it.kind.llmEmitted }
|
||||
.forEach { slot ->
|
||||
// A file_written slot must be backed by real content materialised from an actual
|
||||
// successful write (see recordToolExecution). If none happened, do NOT fabricate a
|
||||
// validated artifact (F-015) — leave the slot unproduced so verifyProduces fails the
|
||||
// stage and it retries, rather than reporting a green run that changed nothing.
|
||||
if (slot.kind.id == "file_written" &&
|
||||
artifactContentCache["${sessionId.value}:${slot.name.value}"] == null
|
||||
) {
|
||||
return@forEach
|
||||
}
|
||||
emitAll(
|
||||
sessionId,
|
||||
listOf(
|
||||
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
|
||||
ArtifactValidatingEvent(slot.name, sessionId, stageId),
|
||||
ArtifactValidatedEvent(slot.name, sessionId, stageId),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.recordToolExecution(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
toolCall: ToolCallRequest,
|
||||
invocationId: ToolInvocationId,
|
||||
tier: Tier,
|
||||
result: ToolResult,
|
||||
fileTool: FileAffectingTool?,
|
||||
request: ToolRequest,
|
||||
fileWrittenSlots: List<TypedArtifactSlot>,
|
||||
fullOutputHash: String? = null,
|
||||
) {
|
||||
// Invariant #5: every tool side effect is captured. This is the single authoritative
|
||||
// ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
|
||||
// FileWrittenEvent (with pre/post-image hashes for reversibility) and ToolExecutionStarted are
|
||||
// emitted once, by SandboxedToolExecutor — NOT here — so completions aren't duplicated in the log.
|
||||
when (result) {
|
||||
is ToolResult.Failure -> emit(
|
||||
sessionId,
|
||||
ToolExecutionFailedEvent(invocationId, sessionId, toolCall.function.name, result.reason),
|
||||
)
|
||||
|
||||
is ToolResult.Success -> {
|
||||
val diff = result.metadata["diff"]?.takeIf { it.isNotBlank() }
|
||||
val affected = fileTool?.affectedPaths(request).orEmpty()
|
||||
// Read-only tools (file_read, list_dir) have no FileAffectingTool, so `affected`
|
||||
// is empty — fall back to the raw `path` argument so the receipt still names
|
||||
// the file/dir the tool looked at (surfaced in the TUI's output row).
|
||||
val affectedNames = affected.map { it.toString() }
|
||||
.ifEmpty { listOfNotNull(request.parameters["path"] as? String) }
|
||||
emit(
|
||||
sessionId,
|
||||
ToolExecutionCompletedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
receipt = ToolReceipt(
|
||||
invocationId = invocationId,
|
||||
toolName = toolCall.function.name,
|
||||
exitCode = result.exitCode,
|
||||
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
|
||||
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
|
||||
// session projections can read it.
|
||||
structuredOutput = result.metadata,
|
||||
affectedEntities = affectedNames,
|
||||
durationMs = 0,
|
||||
tier = tier,
|
||||
timestamp = Clock.System.now(),
|
||||
diff = diff,
|
||||
fullOutputHash = fullOutputHash,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (affected.isNotEmpty()) {
|
||||
materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a [FileWrittenArtifact] from the actual on-disk file after a successful mutation,
|
||||
* store it as the stage's file_written slot content, and record [ArtifactContentStoredEvent].
|
||||
* This ties the file_written artifact to a real write so validation can no longer be faked.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.materializeFileWritten(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
request: ToolRequest,
|
||||
affected: Set<java.nio.file.Path>,
|
||||
fileWrittenSlots: List<TypedArtifactSlot>,
|
||||
diff: String?,
|
||||
) {
|
||||
if (fileWrittenSlots.isEmpty()) return
|
||||
val relPath = request.parameters["path"] as? String ?: return
|
||||
val onDisk = affected.firstOrNull() ?: Paths.get(relPath)
|
||||
val bytes = runCatching { Files.readAllBytes(onDisk) }.getOrNull() ?: return
|
||||
val contentHash = artifactStore.put(bytes)
|
||||
val artifact = FileWrittenArtifact(
|
||||
path = relPath,
|
||||
contentHash = contentHash.value,
|
||||
size = bytes.size.toLong(),
|
||||
mode = "0644",
|
||||
diff = diff,
|
||||
)
|
||||
val json = Json.encodeToString(FileWrittenArtifact.serializer(), artifact)
|
||||
val storedHash = artifactStore.put(json.toByteArray())
|
||||
fileWrittenSlots.forEach { slot ->
|
||||
artifactContentCache["${sessionId.value}:${slot.name.value}"] = json
|
||||
emit(
|
||||
sessionId,
|
||||
ArtifactContentStoredEvent(
|
||||
artifactId = slot.name,
|
||||
contentHash = storedHash,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitLlmArtifacts(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
) {
|
||||
stageConfig.produces
|
||||
.filter { it.kind.llmEmitted }
|
||||
.forEach { slot ->
|
||||
// Same rule as the file_written gate (F-015): no stored content means the
|
||||
// model never produced this artifact — do not fabricate a validated one.
|
||||
val cached = artifactContentCache["${sessionId.value}:${slot.name.value}"]
|
||||
if (cached.isNullOrBlank()) {
|
||||
log.warn(
|
||||
"[Orchestrator] stage={} slot={}: no content stored — skipping artifact emission",
|
||||
stageId.value,
|
||||
slot.name.value,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
emitAll(
|
||||
sessionId,
|
||||
listOf(
|
||||
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
|
||||
ArtifactValidatingEvent(slot.name, sessionId, stageId),
|
||||
ArtifactValidatedEvent(slot.name, sessionId, stageId),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.toolintent.WorkspacePolicy
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
|
||||
/** Per-run effective tool wiring (workspace-scoped registry/executor/policy or the orchestrator defaults). */
|
||||
internal data class RunEffectives(
|
||||
val registry: ToolRegistry?,
|
||||
val executor: ToolExecutor?,
|
||||
val policy: WorkspacePolicy?,
|
||||
)
|
||||
|
||||
/** Result of the artifact extraction/repair ladder: usable text, or a hard stage failure. */
|
||||
internal sealed interface ArtifactLadderOutcome {
|
||||
data class Text(val text: String) : ArtifactLadderOutcome
|
||||
data class Reject(val failure: StageExecutionResult.Failure) : ArtifactLadderOutcome
|
||||
}
|
||||
|
||||
/** A tool result rendered for the model, with the CAS hash of the full (pre-truncation) output if spilled. */
|
||||
internal data class RenderedToolResult(val content: String, val fullOutputHash: String?)
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
// A provider 4xx client error (e.g. llama.cpp 400 "invalid_request") is a deterministic
|
||||
// request-construction failure — retrying re-sends the identical bad request and exhausts the
|
||||
// budget for nothing (F-002). Treat 4xx as terminal, except 408 (timeout) and 429 (rate-limit)
|
||||
// which are transient. Everything else (network/5xx/unknown) stays retryable.
|
||||
internal fun SessionOrchestrator.isRetryableFailure(reason: String): Boolean {
|
||||
val status = Regex("""returned\s+(\d{3})""").find(reason)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: return true
|
||||
return status !in 400..499 || status == HTTP_TIMEOUT || status == HTTP_TOO_MANY_REQUESTS
|
||||
}
|
||||
|
||||
// --- transition helpers ---
|
||||
|
||||
internal fun SessionOrchestrator.resolveTransition(
|
||||
graph: WorkflowGraph,
|
||||
sessionId: SessionId,
|
||||
currentStageId: StageId,
|
||||
artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
||||
artifactContent: Map<ArtifactId, String> = emptyMap(),
|
||||
): TransitionDecision {
|
||||
val ctx = EvaluationContext(
|
||||
sessionId = sessionId,
|
||||
currentStage = currentStageId,
|
||||
artifacts = artifacts,
|
||||
artifactContent = artifactContent,
|
||||
readyTaskCount = readyTaskCounter?.count(sessionId) ?: 0,
|
||||
)
|
||||
return transitionResolver.resolve(graph, ctx)
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.isTerminal(graph: WorkflowGraph, stageId: StageId): Boolean =
|
||||
graph.transitions.none { it.from == stageId }
|
||||
|
||||
internal suspend fun SessionOrchestrator.advanceStage(
|
||||
sessionId: SessionId,
|
||||
fromStageId: StageId,
|
||||
decision: TransitionDecision.Move,
|
||||
): StageId {
|
||||
emit(
|
||||
sessionId,
|
||||
TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId, decision.redirectId),
|
||||
)
|
||||
return decision.to
|
||||
}
|
||||
|
||||
// --- terminal state helpers ---
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) {
|
||||
emit(
|
||||
sessionId,
|
||||
WorkflowStartedEvent(
|
||||
sessionId = sessionId,
|
||||
workflowId = graph.id,
|
||||
startStageId = graph.start,
|
||||
retryPolicy = config.retryPolicy,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.completeWorkflow(
|
||||
sessionId: SessionId,
|
||||
terminalStageId: StageId,
|
||||
stageCount: Int,
|
||||
workflowId: String,
|
||||
): WorkflowResult.Completed {
|
||||
log.debug(
|
||||
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
|
||||
sessionId.value, terminalStageId.value, stageCount,
|
||||
)
|
||||
correlateCritiqueOutcomes(sessionId)
|
||||
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
|
||||
cancellations.remove(sessionId)
|
||||
evictArtifactContentCache(sessionId)
|
||||
return WorkflowResult.Completed(sessionId, terminalStageId)
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.failWorkflow(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
reason: String,
|
||||
retryExhausted: Boolean,
|
||||
): WorkflowResult.Failed {
|
||||
log.warn(
|
||||
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
|
||||
sessionId.value, stageId.value, reason, retryExhausted,
|
||||
)
|
||||
// failWorkflow IS the clean-termination path: whatever goes wrong while recording it
|
||||
// (event-store hiccup, correlation lookup, or any other transient failure) must never be
|
||||
// allowed to escape as a raw exception — that would blow past this function's own
|
||||
// WorkflowFailedEvent (correct stageId/reason/retryExhausted) and land in the server's
|
||||
// top-level catch-all, which fabricates its own event with the wrong stage (graph.start)
|
||||
// and a garbage reason (the escaping throwable's message/toString). Record what we can and
|
||||
// still return a clean Failed result either way.
|
||||
// Critique correlation is best-effort bookkeeping; if it throws we log and press on, because
|
||||
// the terminal WorkflowFailedEvent below MUST still be recorded — skipping it would leave the
|
||||
// event log with no terminal marker for the session (breaks replay-completeness, invariants
|
||||
// #1/#8) and let the server catch-all fabricate a wrong one.
|
||||
runCatching { correlateCritiqueOutcomes(sessionId) }.onFailure { e ->
|
||||
log.error(
|
||||
"[Orchestrator] failWorkflow: critique correlation failed session={} stage={}",
|
||||
sessionId.value, stageId.value, e,
|
||||
)
|
||||
}
|
||||
// Last-ditch: the one unrecoverable case. If emitting the terminal event itself throws
|
||||
// (e.g. a serialization edge), we cannot record the failure at all — log it loudly with the
|
||||
// full throwable so it is never silent, then still return a clean Failed result.
|
||||
runCatching {
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
||||
}.onFailure { e ->
|
||||
log.error(
|
||||
"[Orchestrator] failWorkflow: FAILED to record terminal WorkflowFailedEvent — event " +
|
||||
"log has no terminal marker for session={} stage={}",
|
||||
sessionId.value, stageId.value, e,
|
||||
)
|
||||
}
|
||||
cancellations.remove(sessionId)
|
||||
evictArtifactContentCache(sessionId)
|
||||
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
|
||||
}
|
||||
|
||||
/**
|
||||
* At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that
|
||||
* feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were
|
||||
* recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent
|
||||
* — it skips when outcomes already exist — so it is safe on every terminal path.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.correlateCritiqueOutcomes(sessionId: SessionId) {
|
||||
val events = eventStore.read(sessionId)
|
||||
if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return
|
||||
val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent }
|
||||
if (reviews.isEmpty()) return
|
||||
CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) }
|
||||
}
|
||||
|
||||
internal suspend fun SessionOrchestrator.emit(sessionId: SessionId, payload: EventPayload) {
|
||||
log.debug("[session {}] emitting event, payload: {}", sessionId.value.take(7), payload)
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Appends [payloads] as one transaction (one flow-publish pass) for events that belong together. */
|
||||
internal suspend fun SessionOrchestrator.emitAll(sessionId: SessionId, payloads: List<EventPayload>) {
|
||||
if (payloads.size <= 1) {
|
||||
payloads.firstOrNull()?.let { emit(sessionId, it) }
|
||||
return
|
||||
}
|
||||
eventStore.appendAll(
|
||||
payloads.map { payload ->
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = payload,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.toolintent.SessionContextProjection
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.sessions.projections.EgressAllowlistProjection
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
internal fun SessionOrchestrator.resolveSessionEgressHosts(sessionId: SessionId): Set<String> {
|
||||
val projection = EgressAllowlistProjection(sessionId)
|
||||
return eventStore.read(sessionId)
|
||||
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds this session's tool events through [SessionContextProjection] into the read-model the
|
||||
* anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
|
||||
* never live state); empty before anything has happened.
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
|
||||
val projection = SessionContextProjection(sessionId)
|
||||
return eventStore.read(sessionId)
|
||||
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The out-of-workspace path a read tool targets, or null if the read stays inside the workspace
|
||||
* (or there's no workspace policy / path arg). Resolution is purely lexical (absolute-normalize,
|
||||
* or resolve-against-root for relatives) so the containment decision is deterministic and never
|
||||
* touches the filesystem. Symlink escapes and privileged locations are caught upstream by the
|
||||
* plane-2 PathContainmentRule (BLOCK before any prompt), and by the tool's own realize() jail.
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.outsideWorkspaceTarget(parameters: Map<String, Any>, workspaceRoot: Path?): Path? {
|
||||
workspaceRoot ?: return null
|
||||
val raw = parameters["path"] as? String ?: return null
|
||||
val candidate = runCatching { Path.of(raw) }.getOrNull() ?: return null
|
||||
val root = workspaceRoot.toAbsolutePath().normalize()
|
||||
val resolved = (if (candidate.isAbsolute) candidate else root.resolve(candidate)).normalize()
|
||||
return resolved.takeUnless { it.startsWith(root) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths outside the workspace the operator has approved this session (folded from
|
||||
* [OutsidePathAccessGrantedEvent]). Replay-safe: reads the log, never re-prompts or re-probes.
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.grantedOutsidePaths(sessionId: SessionId): Set<String> =
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? OutsidePathAccessGrantedEvent }
|
||||
.filter { it.sessionId == sessionId }
|
||||
.map { it.path }
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* Returns true when the agent must be offered only read-only tools on the next inference turn.
|
||||
* Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it.
|
||||
* Derived purely from the event log — no separate flag stored.
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.isReadOnlyMode(sessionId: SessionId): Boolean {
|
||||
var blocked = false
|
||||
val pendingReadIds = mutableSetOf<String>()
|
||||
for (event in eventStore.read(sessionId)) {
|
||||
when (val p = event.payload) {
|
||||
is ToolInvocationRequestedEvent ->
|
||||
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
|
||||
pendingReadIds += p.invocationId.value
|
||||
is ToolCallAssessedEvent ->
|
||||
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
|
||||
when {
|
||||
p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
|
||||
blocked = true
|
||||
pendingReadIds.clear()
|
||||
}
|
||||
// A read BLOCKED because its target doesn't exist (REFERENCE_EXISTS) can never
|
||||
// produce a completion, so it can't be what lifts read-only mode. Treat the
|
||||
// attempt as satisfying read-before-write: the file is absent, so there is
|
||||
// nothing to read before creating it. Without this, a stage that writes a NEW
|
||||
// file deadlocks — writes are filtered out and every read of the not-yet-created
|
||||
// target is blocked, burning MAX_TOOL_ROUNDS with no artifact.
|
||||
p.issues.any { it.code == REFERENCE_EXISTS_CODE } -> {
|
||||
blocked = false
|
||||
pendingReadIds.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
is ToolExecutionCompletedEvent ->
|
||||
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
|
||||
blocked = false
|
||||
pendingReadIds.clear()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.mandateSuppressedByTicket(sessionId: SessionId, stageId: StageId, stageConfig: StageConfig): Boolean {
|
||||
if (stageConfig.metadata["role"] == "recovery") return false
|
||||
return eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? TransitionExecutedEvent }
|
||||
.lastOrNull { it.to == stageId }
|
||||
?.transitionId == TICKET_ROUTE
|
||||
}
|
||||
|
||||
/**
|
||||
* Best real path in the repo map that shares [guessed]'s filename, ranked by shared trailing
|
||||
* path segments — so a wrong package prefix (e.g. `com/correx/server/...` for the real
|
||||
* `com/correx/apps/server/...`) still resolves. Null when nothing shares the filename, so we
|
||||
* only ever suggest a concrete, existing file. Reads the recorded map (replay-safe).
|
||||
*/
|
||||
|
||||
internal fun SessionOrchestrator.suggestClosestPath(sessionId: SessionId, guessed: String): String? {
|
||||
val map = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? RepoMapComputedEvent }
|
||||
.lastOrNull() ?: return null
|
||||
return closestPathByFilename(guessed, map.entries.map { it.path })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the actual prompt text a stage will see, for use as the repo-knowledge query.
|
||||
* `metadata["prompt"]` holds a *path* to the prompt file (resolved into context separately,
|
||||
* around line 431) — using that path string itself as the embedding query means the query the
|
||||
* retriever compares against is a filesystem path, not the stage's real instructions, so
|
||||
* similarity scores never reflect genuine relevance (2026-07-08 finding: no file scored a
|
||||
* clear match, everything clustered in the embedder's baseline noise band for short strings).
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.handleCancellation(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
): WorkflowResult.Cancelled {
|
||||
log.warn("[Orchestrator] CANCELLED session={} stage={}", sessionId.value, stageId.value)
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Cancelled(sessionId)
|
||||
}
|
||||
|
||||
// --- cancellation ---
|
||||
|
||||
internal fun SessionOrchestrator.isCancelled(sessionId: SessionId): Boolean =
|
||||
cancellations[sessionId]?.get() == true
|
||||
+7
-3
@@ -20,13 +20,17 @@ class ReplayInferenceProvider(
|
||||
|
||||
override val id: ProviderId = ProviderId("replay-provider")
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private companion object {
|
||||
/** Rough char→token ratio for the replay tokenizer (no real BPE at replay time). */
|
||||
const val CHARS_PER_TOKEN = 4
|
||||
}
|
||||
|
||||
override val tokenizer: Tokenizer = object : Tokenizer {
|
||||
override suspend fun tokenize(text: String): List<Token> =
|
||||
List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars
|
||||
List(text.chunked(CHARS_PER_TOKEN).size) { i -> Token(i) }
|
||||
|
||||
override suspend fun countTokens(text: String): Int =
|
||||
(text.length + 3) / 4
|
||||
(text.length + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN
|
||||
}
|
||||
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ import org.junit.jupiter.api.Test
|
||||
|
||||
class ArtifactPolicyDecisionTest {
|
||||
|
||||
private fun decideFresh(f: ArtifactFailure) = decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
|
||||
private fun decideFresh(f: ArtifactFailure) =
|
||||
decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
|
||||
|
||||
@Test
|
||||
fun `unsafe always aborts, even with budget and approver`() {
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FrameTruncatedToolResultTest {
|
||||
|
||||
@Test
|
||||
fun `keeps header, head, tail and a marker naming the retrieval ref`() {
|
||||
val body = (1..300).joinToString("\n") { "line-$it" }
|
||||
val out = frameTruncatedToolResult("[grep exit=0]", body, "abc123")
|
||||
|
||||
assertTrue(out.startsWith("[grep exit=0]"), out)
|
||||
assertTrue(out.contains("line-1\n"), "head lines kept")
|
||||
assertTrue(out.contains("line-300"), "tail lines kept")
|
||||
assertTrue(out.contains("output truncated (300 lines)"), out)
|
||||
assertTrue(out.contains("tool_output(ref=\"abc123\")"), out)
|
||||
// Middle is dropped — a line well inside the elided span must be absent.
|
||||
assertTrue(!out.contains("line-150"), "middle elided")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `char-caps a single pathological long line so the bound is not defeated`() {
|
||||
val giant = "x".repeat(1_000_000)
|
||||
val out = frameTruncatedToolResult("[shell exit=0]", giant, "ref")
|
||||
// One line means head==tail==giant; each is char-capped, so output can't be ~2MB.
|
||||
assertTrue(out.length < 20_000, "length was ${out.length}")
|
||||
assertTrue(out.contains("tool_output(ref=\"ref\")"), out)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -51,7 +51,8 @@ class JournalCompactionServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): Unit = runBlocking {
|
||||
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`():
|
||||
Unit = runBlocking {
|
||||
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = { 100 })
|
||||
val state = stateWithRecords(
|
||||
makeRecord(1, DecisionKind.INTENT), // HIGH
|
||||
|
||||
@@ -108,6 +108,9 @@ class DefaultTalkieContextBuilder(
|
||||
|
||||
/** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */
|
||||
private const val MAX_IDEAS_IN_CONTEXT = 10
|
||||
|
||||
/** Rough chars-per-token heuristic used when no tokenizer is available. */
|
||||
private const val CHARS_PER_TOKEN_ESTIMATE = 4
|
||||
}
|
||||
|
||||
override suspend fun build(
|
||||
@@ -389,7 +392,7 @@ class DefaultTalkieContextBuilder(
|
||||
}
|
||||
|
||||
private fun fallbackEstimate(content: String): Int {
|
||||
return (content.length / 4).coerceAtLeast(1)
|
||||
return (content.length / CHARS_PER_TOKEN_ESTIMATE).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -146,7 +146,12 @@ class DefaultTalkieFacade(
|
||||
// Rebuild state so lastRetrievedMemory reflects this turn
|
||||
state = routerRepository.getTalkieState(sessionId)
|
||||
|
||||
val contextPack = routerContextBuilder.build(state, config.tokenBudget, workflowSummaryProvider(), sessionProfileProvider(sessionId))
|
||||
val contextPack = routerContextBuilder.build(
|
||||
state,
|
||||
config.tokenBudget,
|
||||
workflowSummaryProvider(),
|
||||
sessionProfileProvider(sessionId),
|
||||
)
|
||||
|
||||
if (contextPack.compressionMetadata.entriesDropped > 0) {
|
||||
val truncNow = Clock.System.now()
|
||||
@@ -195,7 +200,8 @@ class DefaultTalkieFacade(
|
||||
"without producing an answer (likely spent on hidden reasoning). " +
|
||||
"Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again."
|
||||
} else {
|
||||
"The model returned an empty response (finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
|
||||
"The model returned an empty response " +
|
||||
"(finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,22 @@ data class TaskContextBundle(
|
||||
appendLine("task $id [$status] ${title.orEmpty()}".trimEnd())
|
||||
goal?.let { appendLine("goal: $it") }
|
||||
if (blocked) appendLine("BLOCKED — waiting on ${blockedBy.size} unfinished dependency(ies):")
|
||||
appendList("blocked_by", blockedBy) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
|
||||
appendList("blocked_by", blockedBy) {
|
||||
" - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd()
|
||||
}
|
||||
appendList("acceptance_criteria", acceptanceCriteria) { " - $it" }
|
||||
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}")
|
||||
appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
|
||||
appendList("dependencies", dependencies) {
|
||||
" - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd()
|
||||
}
|
||||
appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" }
|
||||
appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() }
|
||||
appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() }
|
||||
appendList("artifacts", artifacts) {
|
||||
val stageSuffix = it.stage?.let { s -> " @$s" }.orEmpty()
|
||||
" - ${it.targetId} (${it.type})$stageSuffix: ${it.excerpt.orEmpty()}".trimEnd()
|
||||
}
|
||||
appendList("sessions", sessions) {
|
||||
" - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd()
|
||||
}
|
||||
appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
|
||||
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
|
||||
appendList("notes", notes) { " - [${it.author}] ${it.body}" }
|
||||
|
||||
@@ -118,7 +118,12 @@ class DefaultTaskReducerTest {
|
||||
val state = reduceAll(
|
||||
created(),
|
||||
TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")),
|
||||
TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK),
|
||||
TaskLinkedEvent(
|
||||
taskId,
|
||||
targetId = "auth-138",
|
||||
type = TaskLinkType.DEPENDS_ON,
|
||||
targetKind = TaskTargetKind.TASK,
|
||||
),
|
||||
TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"),
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@ class ManifestContainmentRule : ToolCallRule {
|
||||
|
||||
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
|
||||
val candidate = Path.of(raw)
|
||||
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedInput =
|
||||
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
||||
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@ class PathContainmentRule : ToolCallRule {
|
||||
|
||||
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
|
||||
val candidate = Path.of(raw)
|
||||
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedInput =
|
||||
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||
val exists = input.probe.exists(resolvedInput)
|
||||
val privileged = privilegedReal.any { resolvedReal.startsWith(it) }
|
||||
|
||||
+7
-1
@@ -26,7 +26,13 @@ class ReferenceExistsRuleTest {
|
||||
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
|
||||
|
||||
private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput(
|
||||
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)),
|
||||
request = ToolRequest(
|
||||
ToolInvocationId("i"),
|
||||
SessionId("s"),
|
||||
StageId("st"),
|
||||
"file_read",
|
||||
mapOf("path" to pathArg),
|
||||
),
|
||||
capabilities = setOf(ToolCapability.FILE_READ),
|
||||
workspace = WorkspacePolicy(workspace, emptyList()),
|
||||
probe = probe,
|
||||
|
||||
@@ -27,7 +27,13 @@ class StaleWriteRuleTest {
|
||||
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
|
||||
|
||||
private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput(
|
||||
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_edit", mapOf("path" to target)),
|
||||
request = ToolRequest(
|
||||
ToolInvocationId("i"),
|
||||
SessionId("s"),
|
||||
StageId("st"),
|
||||
"file_edit",
|
||||
mapOf("path" to target),
|
||||
),
|
||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||
workspace = WorkspacePolicy(workspace, emptyList()),
|
||||
probe = FakeProbe(mapOf(abs(target) to onDisk)),
|
||||
|
||||
@@ -23,7 +23,13 @@ class WriteScopeRuleTest {
|
||||
}
|
||||
|
||||
private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput(
|
||||
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", mapOf("path" to pathArg)),
|
||||
request = ToolRequest(
|
||||
ToolInvocationId("i"),
|
||||
SessionId("s"),
|
||||
StageId("st"),
|
||||
"file_write",
|
||||
mapOf("path" to pathArg),
|
||||
),
|
||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||
workspace = WorkspacePolicy(workspace, emptyList()),
|
||||
probe = FakeProbe(),
|
||||
@@ -34,7 +40,10 @@ class WriteScopeRuleTest {
|
||||
|
||||
@Test
|
||||
fun `no active task means nothing to enforce`() {
|
||||
assertEquals(RiskAction.PROCEED, rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition)
|
||||
assertEquals(
|
||||
RiskAction.PROCEED,
|
||||
rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -38,6 +38,14 @@ data class StageConfig(
|
||||
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
|
||||
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
|
||||
val expectedFiles: List<String> = emptyList(),
|
||||
// Declared scope for this stage (architecture-conformance, design 2026-07-14): the
|
||||
// workspace-relative globs the stage's intent is confined to (e.g. a frontend-client stage
|
||||
// declares `frontend/**`). Distinct from [writeManifest], which is DERIVED from the concrete
|
||||
// `writes` and so cannot contradict them: `touches` is the architect's separate statement of
|
||||
// intent, and the plan compiler rejects a plan whose declared `writes` escape it (scope creep
|
||||
// caught at plan time, before any code is written). Also fed to the semantic reviewer so it can
|
||||
// judge goal-conformance against the declared scope. Empty = no scope constraint (default).
|
||||
val touches: List<String> = emptyList(),
|
||||
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
|
||||
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
|
||||
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
|
||||
@@ -66,6 +74,7 @@ data class StageConfig(
|
||||
|
||||
companion object {
|
||||
/** Read-only tools every tool-granting stage may call regardless of its declared set. */
|
||||
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> = setOf("file_read", "list_dir", "glob", "grep")
|
||||
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> =
|
||||
setOf("file_read", "list_dir", "glob", "grep", "tool_output")
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ class ArtifactExtractionPipelineTest {
|
||||
)
|
||||
|
||||
private fun resolved(raw: String) =
|
||||
assertInstanceOf(ArtifactExtractionPipeline.ExtractionResult.Resolved::class.java, pipeline.run(raw, fileWritten))
|
||||
assertInstanceOf(
|
||||
ArtifactExtractionPipeline.ExtractionResult.Resolved::class.java,
|
||||
pipeline.run(raw, fileWritten),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `clean json passes through`() {
|
||||
|
||||
+4
-1
@@ -45,7 +45,10 @@ class JsonSchemaValidatorTest {
|
||||
@Test
|
||||
fun `unknown property is allowed when additionalProperties is true`() {
|
||||
val open = schema.copy(additionalProperties = true)
|
||||
val result = JsonSchemaValidator.validate(open, Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""))
|
||||
val result = JsonSchemaValidator.validate(
|
||||
open,
|
||||
Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""),
|
||||
)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
build:
|
||||
maxIssues: 120
|
||||
maxIssues: 90
|
||||
|
||||
style:
|
||||
active: true
|
||||
|
||||
@@ -28,7 +28,9 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
||||
"produces": "<unique artifact_id this stage emits, your choice of name>",
|
||||
"kind": "<one of the available artifact kinds listed in your context>",
|
||||
"needs": ["<artifact_id consumed from an earlier stage>"],
|
||||
"tools": ["file_read", "file_write", "file_edit", "shell"]
|
||||
"tools": ["file_read", "file_write", "file_edit", "shell"],
|
||||
"writes": ["<workspace-relative path or glob this stage will write>"],
|
||||
"touches": ["<glob the stage is confined to, e.g. frontend/**>"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
@@ -79,6 +81,14 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
||||
decompose tasks here — task creation is out of scope for the plan.
|
||||
- Keep stages small and single-responsibility. Prefer more stages over large monolithic
|
||||
prompts.
|
||||
- **Declare `touches` to fence a stage to its area.** When a stage's job belongs to one part of
|
||||
the tree (a frontend/client stage → `["frontend/**"]`; a stage that edits only the API layer →
|
||||
`["apps/server/**"]`), list those globs in `touches`. Every concrete path the stage `writes`
|
||||
must fall within its `touches`, or the plan is rejected at compile time. This is how you stop a
|
||||
stage from silently widening past its intent — e.g. a "build the web approval **client**" stage
|
||||
that also starts adding backend routes. Set `touches` narrowly on any stage whose scope is a
|
||||
single area; omit it only for genuinely cross-cutting stages. If a change truly needs a
|
||||
different area, give it its own stage that owns that area rather than widening an unrelated one.
|
||||
- **Prefer the real scaffolder over hand-writing config.** A stage that sets up a project should
|
||||
use the package manager's own generator — e.g. `npm create vite@latest <dir> -- --template
|
||||
react-ts` — because it produces a correct, complete, current file set (a hand-written
|
||||
|
||||
+4
-3
@@ -154,9 +154,6 @@ class DefaultModelManager(
|
||||
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
|
||||
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
|
||||
while (Clock.System.now().toEpochMilliseconds() < endTime) {
|
||||
// A crashed process (bad --model, port clash) never gets healthy; polling the full
|
||||
// timeout is pure dead time. Bail as soon as it's gone.
|
||||
if (!process.isAlive) return false
|
||||
try {
|
||||
val response = httpClient.get("http://$host:$port/health").body<String>()
|
||||
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
|
||||
@@ -165,6 +162,10 @@ class DefaultModelManager(
|
||||
} catch (_: Exception) {
|
||||
// Continue polling
|
||||
}
|
||||
// A crashed process (bad --model, port clash) never gets healthy; polling the full
|
||||
// timeout is pure dead time. Bail as soon as it's gone — but only after probing health,
|
||||
// so a server that's already answering isn't missed on a lost startup/exit race.
|
||||
if (!process.isAlive) return false
|
||||
delay(POLL_INTERVAL)
|
||||
}
|
||||
return false
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@ import kotlinx.serialization.json.Json
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private const val DEFAULT_REQUEST_TIMEOUT_MS = 60_000L
|
||||
private const val ERROR_BODY_PREVIEW_LENGTH = 200
|
||||
|
||||
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
@@ -125,9 +126,10 @@ class LlamaCppEmbedder(
|
||||
return nestedResult
|
||||
}
|
||||
|
||||
val preview = responseBody.take(ERROR_BODY_PREVIEW_LENGTH)
|
||||
throw IllegalStateException(
|
||||
"Unexpected embedding response shape from $url. " +
|
||||
"Response body (first 200 chars): ${responseBody.take(200)}"
|
||||
"Response body (first $ERROR_BODY_PREVIEW_LENGTH chars): $preview"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -260,7 +260,8 @@ class LlamaCppInferenceProvider(
|
||||
when {
|
||||
line.startsWith("id = ") -> id = line.removePrefix("id = ").trim()
|
||||
line.startsWith("function.name = ") -> name = line.removePrefix("function.name = ").trim()
|
||||
line.startsWith("function.arguments = ") -> arguments = line.removePrefix("function.arguments = ").trim()
|
||||
line.startsWith("function.arguments = ") ->
|
||||
arguments = line.removePrefix("function.arguments = ").trim()
|
||||
}
|
||||
}
|
||||
return if (name != null && arguments != null) {
|
||||
|
||||
+5
-1
@@ -41,7 +41,11 @@ class SqliteEventStoreConcurrencyTest {
|
||||
assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}")
|
||||
|
||||
val sessionSeqs = events.map { it.sessionSequence }.sorted()
|
||||
assertEquals((1L..n.toLong()).toList(), sessionSeqs, "Session sequences must be exactly 1..$n with no gaps or duplicates")
|
||||
assertEquals(
|
||||
(1L..n.toLong()).toList(),
|
||||
sessionSeqs,
|
||||
"Session sequences must be exactly 1..$n with no gaps or duplicates",
|
||||
)
|
||||
|
||||
val globalSeqs = events.map { it.sequence }
|
||||
assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique")
|
||||
|
||||
@@ -84,12 +84,17 @@ object InfrastructureModule {
|
||||
private val defaultArtifactsRoot: String =
|
||||
"${System.getProperty("user.home")}/.config/correx/artifacts"
|
||||
|
||||
private const val LLAMA_CODING_SCORE = 0.7
|
||||
private const val LLAMA_REASONING_SCORE = 0.6
|
||||
private const val LLAMA_SUMMARIZATION_SCORE = 0.8
|
||||
private const val LLAMA_TOOL_CALLING_SCORE = 0.5
|
||||
|
||||
private val DEFAULT_LLAMA_CAPABILITIES: Set<CapabilityScore> = setOf(
|
||||
CapabilityScore(ModelCapability.General, 1.0),
|
||||
CapabilityScore(ModelCapability.Coding, 0.7),
|
||||
CapabilityScore(ModelCapability.Reasoning, 0.6),
|
||||
CapabilityScore(ModelCapability.Summarization, 0.8),
|
||||
CapabilityScore(ModelCapability.ToolCalling, 0.5),
|
||||
CapabilityScore(ModelCapability.Coding, LLAMA_CODING_SCORE),
|
||||
CapabilityScore(ModelCapability.Reasoning, LLAMA_REASONING_SCORE),
|
||||
CapabilityScore(ModelCapability.Summarization, LLAMA_SUMMARIZATION_SCORE),
|
||||
CapabilityScore(ModelCapability.ToolCalling, LLAMA_TOOL_CALLING_SCORE),
|
||||
)
|
||||
|
||||
fun createEventStore(artifactStore: ArtifactStore, dbPath: String = defaultDbPath): EventStore {
|
||||
@@ -326,6 +331,17 @@ object InfrastructureModule {
|
||||
val repository = DefaultTalkieRepository(replayer)
|
||||
val ideaReader = IdeaReader(eventStore)
|
||||
val contextBuilder = DefaultTalkieContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() })
|
||||
return DefaultTalkieFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider)
|
||||
return DefaultTalkieFacade(
|
||||
repository,
|
||||
contextBuilder,
|
||||
inferenceRouter,
|
||||
eventStore,
|
||||
config,
|
||||
null,
|
||||
embedder,
|
||||
l3MemoryStore,
|
||||
workflowSummaryProvider,
|
||||
sessionProfileProvider,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ class FileWriteTool(
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_write"
|
||||
override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " +
|
||||
override val description: String =
|
||||
"Write content to a file at the specified relative path (creates or overwrites). " +
|
||||
"Writing to a nested path auto-creates any missing parent directories — there is no separate " +
|
||||
"mkdir step. Do not write shell commands into a file's content."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
|
||||
+8
-3
@@ -179,7 +179,9 @@ class ListDirTool(
|
||||
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
|
||||
val notes = buildList {
|
||||
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
|
||||
if (ignoredCount > 0) add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
if (ignoredCount > 0) {
|
||||
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
}
|
||||
}
|
||||
return buildString {
|
||||
appendLine("<path>$root</path>")
|
||||
@@ -203,14 +205,14 @@ class ListDirTool(
|
||||
.filter { it != targetName && isSimilarName(it, targetName) }
|
||||
.toList()
|
||||
.sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it })
|
||||
.take(3)
|
||||
.take(MAX_SIMILAR_SUGGESTIONS)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun isSimilarName(a: String, b: String): Boolean {
|
||||
val prefix = commonPrefixLen(a, b)
|
||||
return prefix >= 2 || levenshtein(a, b) <= 3
|
||||
return prefix >= SIMILAR_NAME_MIN_COMMON_PREFIX || levenshtein(a, b) <= SIMILAR_NAME_MAX_LEVENSHTEIN
|
||||
}
|
||||
|
||||
private fun commonPrefixLen(a: String, b: String): Int {
|
||||
@@ -238,6 +240,9 @@ class ListDirTool(
|
||||
|
||||
private companion object {
|
||||
const val MAX_ENTRIES = 400
|
||||
const val MAX_SIMILAR_SUGGESTIONS = 3
|
||||
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
|
||||
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-9
@@ -35,6 +35,7 @@ private const val MAX_RESULTS = 200
|
||||
// symbol search wants, and reading it line-by-line stalls the walk. ponytail: fixed byte cap; make it
|
||||
// a param if a real use case needs to grep large generated files.
|
||||
private const val GREP_MAX_FILE_BYTES = 2_000_000L
|
||||
private const val GREP_LINE_PREVIEW_LENGTH = 200
|
||||
|
||||
/**
|
||||
* `.gitignore`-aware path search by glob pattern — the affordance a model should reach for to answer
|
||||
@@ -58,7 +59,10 @@ class GlobTool(
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("pattern") {
|
||||
put("type", "string")
|
||||
put("description", "Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.")
|
||||
put(
|
||||
"description",
|
||||
"Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.",
|
||||
)
|
||||
}
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
@@ -80,13 +84,27 @@ class GlobTool(
|
||||
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
|
||||
}
|
||||
val pattern = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() }
|
||||
?: return@withContext ToolResult.Failure(request.invocationId, "glob requires a non-empty 'pattern'", recoverable = true)
|
||||
?: return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"glob requires a non-empty 'pattern'",
|
||||
recoverable = true,
|
||||
)
|
||||
val base = resolveSearchPath(request, workingDir)
|
||||
if (!Files.isDirectory(base)) {
|
||||
return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true)
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Not a directory: ${request.parameters["path"] ?: "."}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
val matcher = runCatching { FileSystems.getDefault().getPathMatcher("glob:$pattern") }
|
||||
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid glob pattern: ${it.message}", recoverable = true) }
|
||||
.getOrElse {
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Invalid glob pattern: ${it.message}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
|
||||
val hits = ArrayList<String>()
|
||||
var truncated = false
|
||||
@@ -147,16 +165,36 @@ class GrepTool(
|
||||
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
|
||||
}
|
||||
val patternStr = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() }
|
||||
?: return@withContext ToolResult.Failure(request.invocationId, "grep requires a non-empty 'pattern'", recoverable = true)
|
||||
?: return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"grep requires a non-empty 'pattern'",
|
||||
recoverable = true,
|
||||
)
|
||||
val regex = runCatching { Regex(patternStr) }
|
||||
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid regex: ${it.message}", recoverable = true) }
|
||||
.getOrElse {
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Invalid regex: ${it.message}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
val base = resolveSearchPath(request, workingDir)
|
||||
if (!Files.isDirectory(base)) {
|
||||
return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true)
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Not a directory: ${request.parameters["path"] ?: "."}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
val fileFilter = (request.parameters["glob"] as? String)?.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||
.getOrElse { e -> return@withContext ToolResult.Failure(request.invocationId, "Invalid glob: ${e.message}", recoverable = true) }
|
||||
.getOrElse { e ->
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Invalid glob: ${e.message}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val hits = ArrayList<String>()
|
||||
@@ -169,7 +207,7 @@ class GrepTool(
|
||||
if (text != null && !text.contains('\u0000')) {
|
||||
text.lineSequence().forEachIndexed { idx, line ->
|
||||
if (!truncated && regex.containsMatchIn(line)) {
|
||||
hits += "$rel:${idx + 1}: ${line.trim().take(200)}"
|
||||
hits += "$rel:${idx + 1}: ${line.trim().take(GREP_LINE_PREVIEW_LENGTH)}"
|
||||
if (hits.size >= MAX_RESULTS) truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@ class WorkspaceSearchToolsTest {
|
||||
fun `grep respects an optional file glob and prunes gitignore`(): Unit = runBlocking {
|
||||
val root = fixture()
|
||||
val tool = GrepTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))) as ToolResult.Success).output
|
||||
val req = request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))
|
||||
val out = (tool.execute(req) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/hooks/useAuth.ts:"), out)
|
||||
assertTrue(out.contains("src/main.ts:"), out)
|
||||
assertFalse(out.contains("node_modules"), out) // gitignored
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
/**
|
||||
* Retrieves the full text of an earlier tool result that was truncated for model context. When a
|
||||
* Success output exceeds the context floor, the kernel spills the full raw output to the artifact
|
||||
* store and shows a marker containing its `ref` (the content hash, also recorded on the
|
||||
* ToolExecutionCompleted receipt in the event log). This tool resolves that `ref` back to the full
|
||||
* content. Read-only (Tier T1, no filesystem capability — it reads the content-addressed store by a
|
||||
* hash the model was explicitly handed, never arbitrary paths).
|
||||
*/
|
||||
class ToolOutputTool(private val artifactStore: ArtifactStore) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "tool_output"
|
||||
override val description: String =
|
||||
"Retrieve the FULL output of an earlier tool call that was truncated. Pass the ref shown in " +
|
||||
"the truncation marker (e.g. tool_output(ref=\"<hash>\"))."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("ref") {
|
||||
put("type", "string")
|
||||
put("description", "The ref/hash from a '… output truncated …' marker.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("ref")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult =
|
||||
if ((request.parameters["ref"] as? String).isNullOrBlank()) {
|
||||
ValidationResult.Invalid("tool_output requires a non-empty 'ref'")
|
||||
} else {
|
||||
ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val ref = (request.parameters["ref"] as? String)?.takeIf { it.isNotBlank() }
|
||||
?: return ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"tool_output requires a non-empty 'ref'",
|
||||
recoverable = true,
|
||||
)
|
||||
return artifactStore.get(ArtifactId(ref))
|
||||
?.let { ToolResult.Success(request.invocationId, output = it.toString(Charsets.UTF_8)) }
|
||||
?: ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"No stored output for ref '$ref' — it may have expired or the ref is wrong.",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -119,10 +119,19 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
}
|
||||
return null
|
||||
}
|
||||
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||
val duplicates = service.findDuplicates(
|
||||
ProjectId(request.stringParam("project")!!),
|
||||
request.stringParam("title")!!,
|
||||
)
|
||||
return duplicates.takeIf { it.isNotEmpty() }?.let {
|
||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
|
||||
val listed = it.joinToString(", ") { d ->
|
||||
"${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]"
|
||||
}
|
||||
fail(
|
||||
request,
|
||||
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), " +
|
||||
"or force=true with force_reason.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-11
@@ -75,7 +75,10 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
putJsonObject("depends_on") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.")
|
||||
put(
|
||||
"description",
|
||||
"refs (or 0-based indices) of tasks in THIS batch that must finish first.",
|
||||
)
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) })
|
||||
@@ -123,7 +126,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
dedupFailure(request, project, specs, parent)?.let { return it }
|
||||
|
||||
val pid = ProjectId(project)
|
||||
val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
|
||||
val parentTask = parent?.let {
|
||||
service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths)
|
||||
}
|
||||
val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
|
||||
wireLinks(adjacency, children, parentTask)
|
||||
recordProvenance(request, listOfNotNull(parentTask) + children)
|
||||
@@ -140,7 +145,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
/** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */
|
||||
private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) {
|
||||
adjacency.forEachIndexed { i, deps ->
|
||||
deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) }
|
||||
deps.forEach { j ->
|
||||
service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
|
||||
}
|
||||
}
|
||||
if (parentTask != null) {
|
||||
children.forEach { child ->
|
||||
@@ -156,15 +163,26 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
if (!request.boolParam("force")) return
|
||||
val reason = request.stringParam("force_reason") ?: return
|
||||
created.forEach {
|
||||
service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason")
|
||||
service.addNote(
|
||||
it.taskId,
|
||||
TaskNoteAuthor.AGENT,
|
||||
"[force] created via decompose despite the duplicate guard: $reason",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */
|
||||
private fun dedupFailure(request: ToolRequest, project: String, specs: List<TaskSpec>, parent: TaskSpec?): ToolResult.Failure? {
|
||||
private fun dedupFailure(
|
||||
request: ToolRequest,
|
||||
project: String,
|
||||
specs: List<TaskSpec>,
|
||||
parent: TaskSpec?,
|
||||
): ToolResult.Failure? {
|
||||
val titles = (listOfNotNull(parent?.title) + specs.map { it.title })
|
||||
val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys
|
||||
if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
|
||||
if (within.isNotEmpty()) {
|
||||
return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
|
||||
}
|
||||
if (request.boolParam("force")) {
|
||||
return if (request.stringParam("force_reason") == null) {
|
||||
fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
|
||||
@@ -175,8 +193,14 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
val pid = ProjectId(project)
|
||||
val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value }
|
||||
return clashes.takeIf { it.isNotEmpty() }?.let {
|
||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
|
||||
val listed = it.joinToString(", ") { d ->
|
||||
"${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]"
|
||||
}
|
||||
fail(
|
||||
request,
|
||||
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), " +
|
||||
"or force=true with force_reason.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +218,8 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
val readyLine = if (readyIds.isEmpty()) {
|
||||
"Nothing is ready yet — check the dependency graph."
|
||||
} else {
|
||||
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete."
|
||||
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; " +
|
||||
"the rest unblock as their dependencies complete."
|
||||
}
|
||||
return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" +
|
||||
lines.joinToString("\n") + "\n" + readyLine
|
||||
@@ -230,7 +255,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
(obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
private fun strList(obj: JsonObject, key: String): List<String> =
|
||||
(obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList()
|
||||
(obj[key] as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) }
|
||||
?: emptyList()
|
||||
|
||||
/** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */
|
||||
private fun elementOf(request: ToolRequest, name: String) =
|
||||
@@ -253,7 +280,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
val deps = ArrayList<Int>()
|
||||
for (d in s.deps) {
|
||||
val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices }
|
||||
?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.")
|
||||
?: return Edges.Err(
|
||||
"task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.",
|
||||
)
|
||||
if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.")
|
||||
deps.add(j)
|
||||
}
|
||||
|
||||
+7
-2
@@ -35,7 +35,10 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
put("type", "string")
|
||||
put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.")
|
||||
}
|
||||
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
|
||||
putJsonObject("limit") {
|
||||
put("type", "integer")
|
||||
put("description", "Max results (default $DEFAULT_LIMIT).")
|
||||
}
|
||||
}
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
@@ -50,7 +53,9 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
val output = if (ready.isEmpty()) {
|
||||
"no ready tasks"
|
||||
} else {
|
||||
ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
|
||||
ready.joinToString("\n") {
|
||||
"${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd()
|
||||
}
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
|
||||
+11
-3
@@ -35,12 +35,18 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") }
|
||||
putJsonObject("query") {
|
||||
put("type", "string")
|
||||
put("description", "Free-text query; all terms must match.")
|
||||
}
|
||||
putJsonObject("project") {
|
||||
put("type", "string")
|
||||
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.")
|
||||
}
|
||||
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
|
||||
putJsonObject("limit") {
|
||||
put("type", "integer")
|
||||
put("description", "Max results (default $DEFAULT_LIMIT).")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("query")) })
|
||||
}
|
||||
@@ -61,7 +67,9 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
val output = if (hits.isEmpty()) {
|
||||
"no tasks match \"$query\""
|
||||
} else {
|
||||
hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
|
||||
hits.joinToString("\n") {
|
||||
"${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd()
|
||||
}
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
|
||||
+20
-5
@@ -69,23 +69,35 @@ class TaskUpdateTool(
|
||||
}
|
||||
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") }
|
||||
putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") }
|
||||
putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") }
|
||||
putJsonObject("link_target") {
|
||||
put("type", "string")
|
||||
put("description", "Id to link to (task/artifact/session).")
|
||||
}
|
||||
putJsonObject("link_type") {
|
||||
put("type", "string")
|
||||
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
|
||||
}
|
||||
putJsonObject("link_kind") {
|
||||
put("type", "string")
|
||||
put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.")
|
||||
put(
|
||||
"description",
|
||||
"What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.",
|
||||
)
|
||||
}
|
||||
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
|
||||
putJsonObject("force") {
|
||||
put("type", "boolean")
|
||||
put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.")
|
||||
put(
|
||||
"description",
|
||||
"Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.",
|
||||
)
|
||||
}
|
||||
putJsonObject("force_reason") {
|
||||
put("type", "string")
|
||||
put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.")
|
||||
put(
|
||||
"description",
|
||||
"Required when force=true: why the override is justified. Recorded as a note on the task.",
|
||||
)
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
@@ -159,7 +171,10 @@ class TaskUpdateTool(
|
||||
val unmet = service.blockers(taskId)
|
||||
if (unmet.isEmpty()) return null
|
||||
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
|
||||
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.")
|
||||
return fail(
|
||||
request,
|
||||
"Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -88,7 +88,7 @@ class WebFetchTool(
|
||||
val contentType = response.headers[HttpHeaders.ContentType]
|
||||
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
|
||||
when {
|
||||
response.status.value in 300..399 ->
|
||||
response.status.value in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX ->
|
||||
fail(
|
||||
invocationId,
|
||||
"URL redirected (HTTP ${response.status.value}) to " +
|
||||
@@ -159,5 +159,7 @@ class WebFetchTool(
|
||||
companion object {
|
||||
const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake
|
||||
private const val READ_CHUNK = 8_192
|
||||
private const val HTTP_REDIRECT_MIN = 300
|
||||
private const val HTTP_REDIRECT_MAX = 399
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.*
|
||||
|
||||
class ToolOutputToolTest {
|
||||
|
||||
private class FakeStore(private val entries: Map<String, ByteArray>) : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = error("unused")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = entries[id.value]
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
private fun request(ref: String?) = ToolRequest(
|
||||
invocationId = ToolInvocationId(UUID.randomUUID().toString()),
|
||||
sessionId = SessionId(UUID.randomUUID().toString()),
|
||||
stageId = StageId(UUID.randomUUID().toString()),
|
||||
toolName = "tool_output",
|
||||
parameters = ref?.let { mapOf("ref" to it) } ?: emptyMap(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `returns the full stored output for a known ref`(): Unit = runBlocking {
|
||||
val tool = ToolOutputTool(FakeStore(mapOf("hash1" to "the full output".toByteArray())))
|
||||
val out = (tool.execute(request("hash1")) as ToolResult.Success).output
|
||||
assertEquals("the full output", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown ref fails recoverably`(): Unit = runBlocking {
|
||||
val tool = ToolOutputTool(FakeStore(emptyMap()))
|
||||
val result = tool.execute(request("missing"))
|
||||
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing ref fails recoverably`(): Unit = runBlocking {
|
||||
val tool = ToolOutputTool(FakeStore(emptyMap()))
|
||||
val result = tool.execute(request(null))
|
||||
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user