merge: land vikunja-tasks audit fixes + tool-output/sampling/config work

This commit is contained in:
2026-07-12 22:46:02 +04:00
56 changed files with 2260 additions and 806 deletions
@@ -34,6 +34,7 @@ import com.correx.core.inference.ModelCapability
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationTuning
import com.correx.core.kernel.orchestration.OrchestrationProjector import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines import com.correx.core.kernel.orchestration.OrchestratorEngines
@@ -109,9 +110,10 @@ import java.nio.file.Paths
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main") private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
private val loopbackHosts = setOf("localhost", "127.0.0.1", "::1")
fun main() { fun main() {
log.info("=== correx server starting ===") log.info("=== correx server starting ===")
log.info(" port : 8080")
val artifactStore = InfrastructureModule.createArtifactStore() val artifactStore = InfrastructureModule.createArtifactStore()
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore)) val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
@@ -218,16 +220,19 @@ fun main() {
val workingDir = explicitWorkingDir ?: workspaceRoot val workingDir = explicitWorkingDir ?: workspaceRoot
// One shared HTTP client backs both the default and per-workspace registries' research tools // One shared HTTP client backs both the default and per-workspace registries' research tools
// (web_search/web_fetch). Built only when research is enabled, so the static path stays offline. // (web_search/web_fetch). Built only when research is enabled, so the static path stays offline.
// Lives for the process lifetime (shared across requests), so it's closed via shutdown hook
// below rather than `.use { }`.
val researchHttpClient = if (toolsConfig.research.enabled) {
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
} else {
null
}
val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig( val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig(
enabled = toolsConfig.research.enabled, enabled = toolsConfig.research.enabled,
searxngUrl = toolsConfig.research.searxngUrl, searxngUrl = toolsConfig.research.searxngUrl,
maxResults = toolsConfig.research.maxResults, maxResults = toolsConfig.research.maxResults,
maxFetchBytes = toolsConfig.research.maxFetchBytes, maxFetchBytes = toolsConfig.research.maxFetchBytes,
httpClient = if (toolsConfig.research.enabled) { httpClient = researchHttpClient,
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
} else {
null
},
) )
// Agents create/update/delete tasks through the tool system (tier-gated like any tool); // Agents create/update/delete tasks through the tool system (tier-gated like any tool);
// the service appends to the same event log. Project is derived from the task id prefix. // the service appends to the same event log. Project is derived from the task id prefix.
@@ -255,6 +260,9 @@ fun main() {
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore), com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
com.correx.apps.server.tasks.EventStoreSessionWrites(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( val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig( buildToolConfig(
workspaceRoot, workspaceRoot,
@@ -263,7 +271,7 @@ fun main() {
toolsConfig, toolsConfig,
researchToolConfig, researchToolConfig,
), ),
extraTools = taskTools, extraTools = extraTools,
) )
val toolExecutor = InfrastructureModule.createToolExecutor( val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry, registry = toolRegistry,
@@ -296,7 +304,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace -> val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry( val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig), buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
extraTools = taskTools, extraTools = extraTools,
) )
val wsExecutor = DispatchingToolExecutor(wsRegistry) val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor) WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -380,11 +388,45 @@ fun main() {
// Plan-compile gate: wrap the ExecutionPlanCompiler as a post-stage check so a compile-invalid // Plan-compile gate: wrap the ExecutionPlanCompiler as a post-stage check so a compile-invalid
// architect plan is handed back through the retry-feedback loop instead of parking the session in // architect plan is handed back through the retry-feedback loop instead of parking the session in
// ACTIVE (post-planning compile dead-end). Returns null on success, the compiler message on failure. // ACTIVE (post-planning compile dead-end). Returns null on success, the compiler message on failure.
// Operator sampling defaults ([sampling] config) sent on every stage inference request. maxTokens=0
// is a placeholder; the loader/compiler pin it per-stage to the token budget via copy().
val stageSamplingDefaults = with(correxConfig.sampling) {
GenerationConfig(
temperature = temperature,
topP = topP,
maxTokens = 0,
topK = topK,
minP = minP,
repeatPenalty = repeatPenalty,
)
}
val planCompiler = ExecutionPlanCompiler( val planCompiler = ExecutionPlanCompiler(
artifactKindRegistry, artifactKindRegistry,
toolRegistry.all().map { it.name }.toSet(), toolRegistry.all().map { it.name }.toSet(),
injectRecovery = true, injectRecovery = true,
samplingDefaults = stageSamplingDefaults,
) )
// Startup-load orchestration tuning from the [orchestration] config section into the kernel's
// OrchestrationTuning. ponytail: read once at boot — a config edit needs a restart to take effect
// (unlike stage_timeout_ms, which ServerModule re-reads per session). Add a supplier if these ever
// need hot-reload.
val orchestrationTuning = with(correxConfig.orchestration) {
OrchestrationTuning(
maxToolRounds = maxToolRounds,
readLoopNudgeThreshold = readLoopNudgeThreshold,
rejectionLoopNudgeThreshold = rejectionLoopNudgeThreshold,
maxFeedbackIssues = maxFeedbackIssues,
repoMapInjectTopK = repoMapInjectTopK,
repoMapFilesPerDir = repoMapFilesPerDir,
docsCatalogMax = docsCatalogMax,
maxClarificationRounds = maxClarificationRounds,
reviewBlockMinConfidence = reviewBlockMinConfidence,
reviewBlockRetryCap = reviewBlockRetryCap,
defaultMaxRefinement = defaultMaxRefinement,
recoveryRouteBudget = recoveryRouteBudget,
intentRouteBudget = intentRouteBudget,
)
}
val orchestrator = DefaultSessionOrchestrator( val orchestrator = DefaultSessionOrchestrator(
repositories = repositories, repositories = repositories,
engines = engines.copy( engines = engines.copy(
@@ -420,9 +462,10 @@ fun main() {
taskSessionResolver, taskSessionResolver,
), ),
), ),
tuning = orchestrationTuning,
) )
val workflowRegistry = FileSystemWorkflowRegistry( val workflowRegistry = FileSystemWorkflowRegistry(
InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly), InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly, stageSamplingDefaults),
) )
// Builds the router facade from a config snapshot, mapping the [router] block onto the domain // Builds the router facade from a config snapshot, mapping the [router] block onto the domain
// TalkieConfig. Reused by ConfigService's rebuild hook so router knob edits apply live (the // TalkieConfig. Reused by ConfigService's rebuild hook so router knob edits apply live (the
@@ -534,6 +577,8 @@ fun main() {
) )
// observability-spec §4: continuous health watch. Seed the monitor's last-status from the // observability-spec §4: continuous health watch. Seed the monitor's last-status from the
// recorded system-session events so a restart doesn't re-emit a degraded already in the log. // recorded system-session events so a restart doesn't re-emit a degraded already in the log.
// healthProbeHttpClient lives for the process lifetime; closed via shutdown hook below.
var healthProbeHttpClient: io.ktor.client.HttpClient? = null
val healthMonitor = correxConfig.health.let { hc -> val healthMonitor = correxConfig.health.let { hc ->
if (!hc.enabled) { if (!hc.enabled) {
null null
@@ -542,6 +587,7 @@ fun main() {
val seeded = DefaultEventReplayer(eventStore, com.correx.apps.server.health.HealthProjection()) val seeded = DefaultEventReplayer(eventStore, com.correx.apps.server.health.HealthProjection())
.rebuild(com.correx.apps.server.health.SYSTEM_SESSION) .rebuild(com.correx.apps.server.health.SYSTEM_SESSION)
.subjects.mapValues { it.value.status } .subjects.mapValues { it.value.status }
healthProbeHttpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
com.correx.apps.server.health.HealthMonitor( com.correx.apps.server.health.HealthMonitor(
eventStore = eventStore, eventStore = eventStore,
probes = listOfNotNull( probes = listOfNotNull(
@@ -552,7 +598,7 @@ fun main() {
), ),
com.correx.apps.server.health.LlamaServerHealthProbe( com.correx.apps.server.health.LlamaServerHealthProbe(
llamaBaseUrl = llamaBaseUrl, llamaBaseUrl = llamaBaseUrl,
httpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO), httpClient = healthProbeHttpClient!!,
eventStore = eventStore, eventStore = eventStore,
livenessTimeoutMs = hc.llamaLivenessTimeoutMs, livenessTimeoutMs = hc.llamaLivenessTimeoutMs,
tpsWarnBelow = hc.llamaTpsWarnBelow, tpsWarnBelow = hc.llamaTpsWarnBelow,
@@ -567,6 +613,12 @@ fun main() {
) )
} }
} }
Runtime.getRuntime().addShutdownHook(
Thread {
researchHttpClient?.close()
healthProbeHttpClient?.close()
},
)
val module = ServerModule( val module = ServerModule(
orchestrator = orchestrator, orchestrator = orchestrator,
eventStore = eventStore, eventStore = eventStore,
@@ -609,7 +661,16 @@ fun main() {
module.start() module.start()
log.info("==============================") log.info("==============================")
embeddedServer(Netty, port = correxConfig.server.port, host = correxConfig.server.host) { val serverConfig = correxConfig.server
log.info(" host:port : {}:{}", serverConfig.host, serverConfig.port)
if (serverConfig.host !in loopbackHosts) {
log.warn(
"Server host '{}' is not loopback — the unauthenticated HTTP/WS surface is exposed to the network",
serverConfig.host,
)
}
embeddedServer(Netty, host = serverConfig.host, port = serverConfig.port) {
configureServer(module) configureServer(module)
}.start(wait = true) }.start(wait = true)
} }
@@ -674,9 +735,7 @@ private fun loadConfigArtifactKinds(config: CorrexConfig): List<ArtifactKind> {
val schema = runCatching { val schema = runCatching {
Json.decodeFromString(JsonSchema.serializer(), Files.readString(schemaPath)) Json.decodeFromString(JsonSchema.serializer(), Files.readString(schemaPath))
}.getOrElse { e -> }.getOrElse { e ->
System.err.println( log.warn("artifact kind '{}' schema '{}' failed to load: {}", decl.id, schemaPath, e.message)
"Warning: artifact kind '${decl.id}' schema '$schemaPath' failed to load: ${e.message}",
)
return@mapNotNull null return@mapNotNull null
} }
ConfigArtifactKind(id = decl.id, schema = schema, llmEmitted = decl.llmEmitted) ConfigArtifactKind(id = decl.id, schema = schema, llmEmitted = decl.llmEmitted)
@@ -7,6 +7,9 @@ import com.correx.apps.server.narration.NarrationSubscriber
import com.correx.apps.server.registry.ProviderRegistry import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.workspace.WorkspaceResolver import com.correx.apps.server.workspace.WorkspaceResolver
import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.approvals.DefaultApprovalRepository
@@ -183,10 +186,13 @@ class ServerModule(
fun start() { fun start() {
if (subscriptionJob != null) return if (subscriptionJob != null) return
preRegisterPendingApprovals() preRegisterPendingApprovals()
repairStuckApprovalPauses()
resumeAbandonedSessions() resumeAbandonedSessions()
subscriptionJob = eventStore.subscribeAll() subscriptionJob = eventStore.subscribeAll()
.filter { it.payload is ApprovalRequestedEvent } .filter { it.payload is ApprovalRequestedEvent }
.onEach { approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent) } .onEach {
approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent, it.sessionSequence)
}
.launchIn(moduleScope) .launchIn(moduleScope)
// When a PAUSED session's approval is resolved without a live orchestrator coroutine // When a PAUSED session's approval is resolved without a live orchestrator coroutine
@@ -294,6 +300,48 @@ class ServerModule(
* run job is invisible to the guard and a second `resume()` is spuriously launched, * run job is invisible to the guard and a second `resume()` is spuriously launched,
* duplicating inference calls (the root cause of the healthcheck script corruption). * duplicating inference calls (the root cause of the healthcheck script corruption).
*/ */
/**
* Resolves [workingDir] into a trusted workspace and records the decision as a
* [SessionWorkspaceBoundEvent] (invariant #9), returning the bound context or null when no
* resolver is configured. Shared by the WS StartSession path and the REST POST /sessions
* launcher so both anchor a session's workspace identically — the REST route previously skipped
* this entirely, leaving sessions with no bound workspace.
*/
suspend fun bindWorkspace(sessionId: SessionId, workingDir: String?): WorkspaceContext? {
val resolver = workspaceResolver ?: return null
val workspace = when (val resolution = withContext(Dispatchers.IO) { resolver.resolve(workingDir) }) {
is WorkspaceResolution.Bound -> {
log.info("workspace bound: session={} root={}", sessionId.value, resolution.workspace.workspaceRoot)
resolution.workspace
}
is WorkspaceResolution.Rejected -> {
log.warn(
"workspace rejected: session={} reason={} fallback={}",
sessionId.value, resolution.reason, resolution.fallback.workspaceRoot,
)
resolution.fallback
}
}
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SessionWorkspaceBoundEvent(
sessionId = sessionId,
workspaceRoot = workspace.workspaceRoot.toString(),
allowedPaths = workspace.allowedPaths.map { it.toString() },
),
),
)
return workspace
}
fun launchSessionRun( fun launchSessionRun(
sessionId: SessionId, sessionId: SessionId,
graph: com.correx.core.transitions.graph.WorkflowGraph, graph: com.correx.core.transitions.graph.WorkflowGraph,
@@ -576,13 +624,25 @@ class ServerModule(
private val sessionSummaryProjector = SessionSummaryProjector() private val sessionSummaryProjector = SessionSummaryProjector()
fun listSessionSummaries(): List<SessionSummary> = // ponytail: memoized on lastGlobalSequence — GET /sessions was re-reading + re-projecting every
eventStore.allSessionIds() // session's full log on every call (S5). Any new event anywhere bumps the global sequence, so a
// stale cache is impossible: a mismatch always forces a full recompute. Volatile pair-swap is
// enough here (no lock) — worst case under a race is one redundant recompute, never a stale read.
@Volatile
private var sessionSummaryCache: Pair<Long, List<SessionSummary>>? = null
suspend fun listSessionSummaries(): List<SessionSummary> {
val currentSeq = eventStore.lastGlobalSequence()
sessionSummaryCache?.let { (seq, summaries) -> if (seq == currentSeq) return summaries }
val summaries = eventStore.allSessionIds()
// The system session carries global health events, not a user workflow — hide it. // The system session carries global health events, not a user workflow — hide it.
.filter { it != com.correx.apps.server.health.SYSTEM_SESSION } .filter { it != com.correx.apps.server.health.SYSTEM_SESSION }
.map { sessionId -> .map { sessionId ->
sessionSummaryProjector.project(sessionId, eventStore.readFrom(sessionId, fromSequence = 0L)) sessionSummaryProjector.project(sessionId, eventStore.readFrom(sessionId, fromSequence = 0L))
} }
sessionSummaryCache = currentSeq to summaries
return summaries
}
private fun preRegisterPendingApprovals() { private fun preRegisterPendingApprovals() {
val projector = ApprovalProjector(DefaultApprovalReducer()) val projector = ApprovalProjector(DefaultApprovalReducer())
@@ -595,7 +655,54 @@ class ServerModule(
} }
approvalState.requests.values approvalState.requests.values
.filter { req -> approvalState.decisions.values.none { it.requestId == req.id } } .filter { req -> approvalState.decisions.values.none { it.requestId == req.id } }
.forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId) } .forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId, req.tier) }
}
}
/**
* One-shot boot repair for sessions stuck PAUSED + pendingApproval with no actual unresolved
* approval request — the symptom of a pre-Feb-13 orchestrator bug where approval was resolved
* but OrchestrationResumedEvent was never emitted. Appends the missing resume so the projection
* corrects permanently. Previously this ran per WS connection inside SessionEventBridge.
* replaySnapshot, which put a racy write on a pure read path (two concurrent clients could
* double-append). Running it once at boot removes that hazard; the bug that creates the state is
* fixed, so no session newly enters it at runtime.
*
* Guard: skip when there are more OrchestrationPausedEvents than ApprovalRequestedEvents — the
* session just entered the gate and its ApprovalRequestedEvent isn't stored yet; a spurious
* resume would hide the pending approval.
*/
private fun repairStuckApprovalPauses() {
val projector = ApprovalProjector(DefaultApprovalReducer())
eventStore.allSessionIds().forEach { sessionId ->
val orchState = orchestrationRepository.getState(sessionId)
if (!orchState.pendingApproval) return@forEach
val events = eventStore.read(sessionId)
val approvalState = events.fold(projector.initial()) { state, event -> projector.apply(state, event) }
val hasUnresolved = approvalState.requests.values
.any { req -> approvalState.decisions.values.none { it.requestId == req.id } }
val pauseCount = events.count { it.payload is OrchestrationPausedEvent }
val approvalRequestCount = events.count { it.payload is ApprovalRequestedEvent }
val hasUnpairedPause = pauseCount > approvalRequestCount
val alreadyResumed = events.any { it.payload is OrchestrationResumedEvent }
val stageId = orchState.currentStageId
if (hasUnresolved || hasUnpairedPause || alreadyResumed || stageId == null) return@forEach
log.info("repairStuckApprovalPauses: emitting missing resume for stuck session={}", sessionId.value)
moduleScope.launch {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = OrchestrationResumedEvent(sessionId = sessionId, stageId = stageId),
),
)
}
} }
} }
@@ -13,6 +13,7 @@ import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.ApprovalGateway import com.correx.core.kernel.orchestration.ApprovalGateway
import io.ktor.server.websocket.DefaultWebSocketServerSession import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
open class ApprovalCoordinator( open class ApprovalCoordinator(
@@ -23,6 +24,7 @@ open class ApprovalCoordinator(
private val globalClients: MutableSet<DefaultWebSocketServerSession> = ConcurrentHashMap.newKeySet() private val globalClients: MutableSet<DefaultWebSocketServerSession> = ConcurrentHashMap.newKeySet()
private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap() private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap()
private val requestSessions: ConcurrentHashMap<ApprovalRequestId, SessionId> = ConcurrentHashMap() private val requestSessions: ConcurrentHashMap<ApprovalRequestId, SessionId> = ConcurrentHashMap()
private val requestTiers: ConcurrentHashMap<ApprovalRequestId, Tier> = ConcurrentHashMap()
fun registerClient(sessionId: SessionId, session: DefaultWebSocketServerSession) { fun registerClient(sessionId: SessionId, session: DefaultWebSocketServerSession) {
sessionClients.getOrPut(sessionId) { ConcurrentHashMap.newKeySet() }.add(session) sessionClients.getOrPut(sessionId) { ConcurrentHashMap.newKeySet() }.add(session)
@@ -40,8 +42,9 @@ open class ApprovalCoordinator(
globalClients.remove(session) globalClients.remove(session)
} }
suspend fun onApprovalRequested(event: ApprovalRequestedEvent) { suspend fun onApprovalRequested(event: ApprovalRequestedEvent, sessionSequence: Long = 0L) {
requestSessions[event.requestId] = event.sessionId requestSessions[event.requestId] = event.sessionId
requestTiers[event.requestId] = event.tier
val msg = ServerMessage.ApprovalRequired( val msg = ServerMessage.ApprovalRequired(
sessionId = event.sessionId, sessionId = event.sessionId,
requestId = event.requestId, requestId = event.requestId,
@@ -55,7 +58,7 @@ open class ApprovalCoordinator(
toolName = event.toolName, toolName = event.toolName,
preview = event.preview, preview = event.preview,
sequence = 0L, sequence = 0L,
sessionSequence = 0L, sessionSequence = sessionSequence,
) )
broadcast(event.sessionId, msg) broadcast(event.sessionId, msg)
} }
@@ -75,8 +78,9 @@ open class ApprovalCoordinator(
* during snapshot replay. This makes [lookupSession] work for approvals that were created * during snapshot replay. This makes [lookupSession] work for approvals that were created
* before the current connection's live stream started. * before the current connection's live stream started.
*/ */
fun registerPendingRequest(requestId: ApprovalRequestId, sessionId: SessionId) { fun registerPendingRequest(requestId: ApprovalRequestId, sessionId: SessionId, tier: Tier) {
requestSessions[requestId] = sessionId requestSessions[requestId] = sessionId
requestTiers[requestId] = tier
} }
open suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? { open suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
@@ -87,12 +91,20 @@ open class ApprovalCoordinator(
sessionSequence = null, sessionSequence = null,
) )
} }
requestSessions.remove(msg.requestId) val tier = requestTiers[msg.requestId] ?: Tier.T2
val domain = msg.toDomain(sessionId, null, Tier.T2) val domain = msg.toDomain(sessionId, null, tier)
return runCatching { orchestrator.submitApprovalDecision(msg.requestId, domain) } return runCatching { orchestrator.submitApprovalDecision(msg.requestId, domain) }
.fold( .fold(
onSuccess = { null }, onSuccess = {
// Only retire the request once the decision was actually recorded. If submit
// throws, we clear the resolved flag so the client can retry instead of the
// request being permanently unanswerable.
requestSessions.remove(msg.requestId)
requestTiers.remove(msg.requestId)
null
},
onFailure = { onFailure = {
resolved.remove(msg.requestId)
ServerMessage.ProtocolError( ServerMessage.ProtocolError(
message = it.message ?: "Unknown error", message = it.message ?: "Unknown error",
sequence = null, sequence = null,
@@ -106,8 +118,29 @@ open class ApprovalCoordinator(
val encoded = ProtocolSerializer.encodeServerMessage(msg) val encoded = ProtocolSerializer.encodeServerMessage(msg)
val sessionSubs = sessionClients[sessionId].orEmpty() val sessionSubs = sessionClients[sessionId].orEmpty()
val recipients: Set<DefaultWebSocketServerSession> = sessionSubs + globalClients val recipients: Set<DefaultWebSocketServerSession> = sessionSubs + globalClients
var delivered = 0
recipients.forEach { client -> recipients.forEach { client ->
runCatching { client.send(Frame.Text(encoded)) } runCatching { client.send(Frame.Text(encoded)) }
.onSuccess { delivered++ }
.onFailure { err ->
// A dead socket must not silently swallow an approval prompt. Log it and evict
// the client from both registries so we don't keep sending into a closed socket
// (the client re-registers and gets the pending approval from the snapshot on
// reconnect).
log.warn("approval broadcast to a client failed, evicting: {}", err.message)
sessionClients[sessionId]?.remove(client)
globalClients.remove(client)
}
}
if (delivered == 0) {
log.error(
"approval prompt for session={} reached 0 live clients; session stays paused until a client reconnects",
sessionId.value,
)
} }
} }
private companion object {
private val log = LoggerFactory.getLogger(ApprovalCoordinator::class.java)
}
} }
@@ -1,55 +1,12 @@
package com.correx.apps.server.bridge 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.ServerMessage
import com.correx.apps.server.protocol.toDto
import com.correx.core.artifactstore.ArtifactStore 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.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 com.correx.core.events.types.ArtifactId
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger("DomainEventMapper") internal val log = LoggerFactory.getLogger("DomainEventMapper")
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) { class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
suspend fun map(event: StoredEvent, sessionSequence: Long = 0L): ServerMessage? = suspend fun map(event: StoredEvent, sessionSequence: Long = 0L): ServerMessage? =
@@ -62,374 +19,44 @@ private object NoopArtifactStore : ArtifactStore {
override suspend fun flushBefore(commit: suspend () -> Unit) = commit() 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( suspend fun domainEventToServerMessage(
event: StoredEvent, event: StoredEvent,
artifactStore: ArtifactStore, artifactStore: ArtifactStore,
sessionSequence: Long = 0L, sessionSequence: Long = 0L,
): ServerMessage? { ): ServerMessage? {
val seq = event.sequence for (mapper in domainMappers) {
return when (val p = event.payload) { when (val outcome = mapper(event, artifactStore, sessionSequence)) {
is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced( is MapOutcome.Emit -> return outcome.message
sessionId = p.sessionId, MapOutcome.Skip -> Unit
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 -> {
log.debug(
"DomainEventMapper: unmapped payload type={} sessionId={} sequence={}",
p::class.simpleName,
event.metadata.sessionId,
event.sequence,
)
null
} }
} }
} log.debug(
"DomainEventMapper: unmapped payload type={} sessionId={} sequence={}",
private fun mapOrchestrationPaused( event.payload::class.simpleName,
p: OrchestrationPausedEvent, event.metadata.sessionId,
seq: Long, event.sequence,
sessionSequence: Long,
): ServerMessage {
val reason = if (p.reason == "APPROVAL_PENDING") PauseReason.APPROVAL_PENDING else PauseReason.USER_REQUESTED
return ServerMessage.SessionPaused(
sessionId = p.sessionId,
reason = reason,
sequence = seq,
sessionSequence = sessionSequence,
) )
return null
} }
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,
)
@@ -14,18 +14,15 @@ import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.model.ApprovalState import com.correx.core.approvals.model.ApprovalState
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
@@ -40,10 +37,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.types.EventId
import kotlinx.datetime.Clock
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.tools.registry.ToolRegistry import com.correx.core.tools.registry.ToolRegistry
@@ -109,39 +103,11 @@ class SessionEventBridge(
.sortedWith(compareBy({ it.timestamp }, { it.id.value })) .sortedWith(compareBy({ it.timestamp }, { it.id.value }))
} ?: emptyList() } ?: emptyList()
// Fix stuck sessions: orchestration says PAUSED + pendingApproval but there are no // NOTE: repairing stuck approval-pauses (PAUSED + pendingApproval but no unresolved
// actual unresolved approval requests. This happens when approval was resolved but // request, from a pre-Feb-13 orchestrator bug) used to happen HERE, per connection —
// OrchestrationResumedEvent was not emitted (pre-Feb-13 orchestrator bug). Append the // a write side effect on a pure read path that two concurrent clients could double-fire.
// missing event permanently so the session state corrects on all future replays. // It now runs once at boot in ServerModule.repairStuckApprovalPauses(). replaySnapshot is
// // read-only again.
// Guard: if there are more OrchestrationPausedEvents than ApprovalRequestedEvents the
// session just entered the approval gate and ApprovalRequestedEvent has not been stored
// yet (tiny race window between two sequential emits). Do NOT fire in that case —
// appending a spurious resume would hide the pending approval from the TUI.
val pauseCount = events.count { it.payload is OrchestrationPausedEvent }
val approvalRequestCount = events.count { it.payload is ApprovalRequestedEvent }
val hasUnpairedPause = pauseCount > approvalRequestCount
if (orchState.pendingApproval && pendingApprovalRequests.isEmpty() && !hasUnpairedPause) {
val alreadyResumed = events.any { it.payload is OrchestrationResumedEvent }
val stageId = orchState.currentStageId
if (!alreadyResumed && stageId != null) {
val resumeEvent = NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = OrchestrationResumedEvent(
sessionId = sessionId,
stageId = stageId,
),
)
eventStore.append(resumeEvent)
}
}
val pendingApprovals = pendingApprovalRequests.map { val pendingApprovals = pendingApprovalRequests.map {
ApprovalDto( ApprovalDto(
@@ -164,8 +130,8 @@ class SessionEventBridge(
// Re-register pending approvals so the ApprovalCoordinator can route responses // Re-register pending approvals so the ApprovalCoordinator can route responses
// from clients that connected after the ApprovalRequestedEvent was emitted. // from clients that connected after the ApprovalRequestedEvent was emitted.
pendingApprovals.forEach { dto -> pendingApprovalRequests.forEach { req ->
approvalCoordinator?.registerPendingRequest(ApprovalRequestId(dto.requestId), sessionId) approvalCoordinator?.registerPendingRequest(req.id, sessionId, req.tier)
} }
val toolRecords = rebuildTools(events) val toolRecords = rebuildTools(events)
@@ -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)
}
@@ -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
@@ -104,22 +104,28 @@ class NarrationSubscriber(
stageId = p.stageId.value, stageId = p.stageId.value,
), ),
) )
is WorkflowCompletedEvent -> enqueue( is WorkflowCompletedEvent -> {
sid, enqueue(
NarrationTrigger( sid,
kind = "workflow_completed", NarrationTrigger(
instruction = "The workflow finished successfully. Provide a brief summary.", kind = "workflow_completed",
stageId = p.terminalStageId.value, instruction = "The workflow finished successfully. Provide a brief summary.",
), stageId = p.terminalStageId.value,
) ),
is WorkflowFailedEvent -> enqueue( )
sid, closeLane(sid)
NarrationTrigger( }
kind = "workflow_failed", is WorkflowFailedEvent -> {
instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. Explain the failure to the user.", enqueue(
stageId = p.stageId.value, sid,
), NarrationTrigger(
) kind = "workflow_failed",
instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. Explain the failure to the user.",
stageId = p.stageId.value,
),
)
closeLane(sid)
}
// Surface the semantic reviewer's verdict conversationally instead of a raw findings // Surface the semantic reviewer's verdict conversationally instead of a raw findings
// dump: the narrator turns "FAIL, 2 findings" into a plain-language explanation the // dump: the narrator turns "FAIL, 2 findings" into a plain-language explanation the
// operator can act on. Only narrate when there's something to say (non-PASS or findings). // operator can act on. Only narrate when there's something to say (non-PASS or findings).
@@ -221,6 +227,13 @@ class NarrationSubscriber(
lanes[sessionId.value]?.pendingPauses?.clear() lanes[sessionId.value]?.pendingPauses?.clear()
} }
/** Closes a terminated session's lane so its worker drains any buffered narration (the terminal
* one enqueued just before this) and then exits, self-removing the lane from [lanes]. Without
* this, every session leaks its channel + worker coroutine + map entry forever. */
private fun closeLane(sessionId: SessionId) {
lanes[sessionId.value]?.channel?.close()
}
private fun startLane(sessionId: SessionId): SessionLane { private fun startLane(sessionId: SessionId): SessionLane {
val channel = Channel<QueuedNarration>(capacity = Channel.UNLIMITED) val channel = Channel<QueuedNarration>(capacity = Channel.UNLIMITED)
val lane = SessionLane(channel, used = 0) val lane = SessionLane(channel, used = 0)
@@ -249,6 +262,9 @@ class NarrationSubscriber(
) )
} }
} }
// Channel closed on workflow termination: drop the lane so it stops leaking. A later event
// for the same session (none expected post-terminal) would lazily start a fresh lane.
lanes.remove(sessionId.value)
} }
return lane return lane
} }
@@ -61,6 +61,8 @@ data class ApprovalDto(
@Serializable @Serializable
enum class PauseReason { enum class PauseReason {
APPROVAL_PENDING, APPROVAL_PENDING,
CLARIFICATION_PENDING,
ABANDONED_STALE,
USER_REQUESTED, USER_REQUESTED,
} }
@@ -121,10 +121,14 @@ private fun Route.startSessionRoute(module: ServerModule) {
val graph = module.workflowRegistry.find(body.workflowId) val graph = module.workflowRegistry.find(body.workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}") ?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
// Anchor the session's workspace (invariant #9) so path containment, project profile and
// grants resolve against a bound root. REST carries no cwd, so the resolver's default/
// fallback root is bound — parity with the WS StartSession path.
val workspace = module.bindWorkspace(sessionId, null)
body.intent?.takeIf { it.isNotBlank() }?.let { intent -> body.intent?.takeIf { it.isNotBlank() }?.let { intent ->
EventDispatcher(module.eventStore).emit(InitialIntentEvent(sessionId, intent), sessionId) EventDispatcher(module.eventStore).emit(InitialIntentEvent(sessionId, intent), sessionId)
} }
module.launchSessionRun(sessionId, graph) module.launchSessionRun(sessionId, graph, module.orchestrationConfig().copy(workspace = workspace))
call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value)) call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value))
} }
} }
@@ -12,7 +12,6 @@ import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.WorkflowDto import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope import com.correx.core.approvals.GrantScope
import com.correx.core.events.events.ApprovalGrantCreatedEvent import com.correx.core.events.events.ApprovalGrantCreatedEvent
@@ -66,6 +65,9 @@ import java.util.UUID
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java) private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
private const val BUFFER_CAPACITY = 1024 private const val BUFFER_CAPACITY = 1024
/** Raised to tear down a WS connection whose forward buffer overflowed, so the client reconnects. */
private class SlowClientException : RuntimeException("global WS stream buffer overflow")
private const val RESOURCE_PUSH_INTERVAL_MS = 2500L private const val RESOURCE_PUSH_INTERVAL_MS = 2500L
private const val BYTES_PER_MB = 1024L * 1024L private const val BYTES_PER_MB = 1024L * 1024L
@@ -716,47 +718,10 @@ class GlobalStreamHandler(private val module: ServerModule) {
val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId) log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)
// Resolve workspace from the Hello-frame working directory (invariant #9: record the // Resolve + record the workspace from the Hello-frame working directory (invariant #9:
// resolver's decision at handshake time as an event; replay reads the recorded fact). // record the resolver's decision as an event; replay reads the recorded fact). Shared with
val resolvedWorkspace: WorkspaceContext? = module.workspaceResolver?.let { resolver -> // the REST launcher via module.bindWorkspace.
val resolution = withContext(Dispatchers.IO) { resolver.resolve(workingDir) } val resolvedWorkspace: WorkspaceContext? = module.bindWorkspace(sessionId, workingDir)
when (resolution) {
is WorkspaceResolution.Bound -> {
log.info("workspace bound: root={}", resolution.workspace.workspaceRoot)
resolution.workspace
}
is WorkspaceResolution.Rejected -> {
// Deliberate: a rejected client path still binds the resolver's fallback
// workspace. The fallback root is recorded in SessionWorkspaceBoundEvent so
// replay reproduces the same binding; the rejection reason is audit-logged
// here and does NOT become a field on the event (YAGNI).
log.warn(
"workspace rejected: session={} reason={} fallback={}",
sessionId.value, resolution.reason, resolution.fallback.workspaceRoot,
)
resolution.fallback
}
}
}
// Emit SessionWorkspaceBoundEvent when a workspace was resolved (invariant #9).
resolvedWorkspace?.let { ws ->
module.eventStore.append(NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SessionWorkspaceBoundEvent(
sessionId = sessionId,
workspaceRoot = ws.workspaceRoot.toString(),
allowedPaths = ws.allowedPaths.map { it.toString() },
),
))
}
// Send StageToolManifest FIRST. If the WS is already closed, this throws and the // Send StageToolManifest FIRST. If the WS is already closed, this throws and the
// orchestrator never launches — no silent data loss. The exception propagates to the // orchestrator never launches — no silent data loss. The exception propagates to the
@@ -826,22 +791,28 @@ internal suspend fun streamGlobal(
is SharedFlow<StoredEvent> -> source.onSubscription { subscribed.complete(Unit) } is SharedFlow<StoredEvent> -> source.onSubscription { subscribed.complete(Unit) }
else -> source.onStart { subscribed.complete(Unit) } else -> source.onStart { subscribed.complete(Unit) }
} }
// trySend, never send: a blocking send here would back-pressure globalFlow (SUSPEND overflow),
// suspending append() and stalling the whole kernel on one wedged client. On overflow we instead
// drop the connection (close the buffer); the client reconnects and re-syncs via replaySnapshot.
val subscription = launch { val subscription = launch {
signaled.collect { buffer.send(it) } signaled.collect {
val result = buffer.trySend(it)
if (result.isFailure && !result.isClosed) {
log.warn("global WS stream buffer overflow (slow client); dropping connection to re-sync")
buffer.close(SlowClientException())
}
}
} }
// Per-session sequence counters so live events carry the correct sessionSequence
// and are not silently dropped by the TUI's SnapshotPhaseReducer dedup filter.
val sessionSequences = mutableMapOf<String, Long>()
try { try {
subscribed.await() subscribed.await()
bridge.replaySnapshot() bridge.replaySnapshot()
for (event in buffer) { for (event in buffer) {
val sid = event.metadata.sessionId.value // Use the event's own persisted sessionSequence, not a per-connection counter. The
val seq = sessionSequences.getOrDefault(sid, 0L) + 1 // snapshot advertises lastSessionSequence = MAX(session_sequence); a counter restarting
sessionSequences[sid] = seq // at 1 per connection would collide with that after reconnect and the TUI's dedup/
val msg = mapper.map(event, sessionSequence = seq) ?: continue // ordering filter (keyed on sessionSequence) would silently drop or misorder frames.
val msg = mapper.map(event, sessionSequence = event.sessionSequence) ?: continue
sendFrame(msg) sendFrame(msg)
} }
} finally { } finally {
@@ -60,13 +60,56 @@ class ApprovalCoordinatorWiringTest {
scope.cancel() scope.cancel()
} }
private class RecordingGateway : ApprovalGateway { private class RecordingGateway(@Volatile var failNext: Boolean = false) : ApprovalGateway {
val submissions = CopyOnWriteArrayList<Pair<ApprovalRequestId, DomainApprovalDecision>>() val submissions = CopyOnWriteArrayList<Pair<ApprovalRequestId, DomainApprovalDecision>>()
override suspend fun submitApprovalDecision(requestId: ApprovalRequestId, decision: DomainApprovalDecision) { override suspend fun submitApprovalDecision(requestId: ApprovalRequestId, decision: DomainApprovalDecision) {
if (failNext) {
failNext = false
error("submit boom")
}
submissions.add(requestId to decision) submissions.add(requestId to decision)
} }
} }
private fun approvalEvent(tier: Tier) = ApprovalRequestedEvent(
requestId = requestId,
tier = tier,
validationReportId = ValidationReportId("vr-1"),
riskSummaryId = null,
sessionId = sessionId,
stageId = null,
projectId = null,
)
@Test
fun `submit failure keeps request answerable and retry succeeds`(): Unit = runBlocking {
val gateway = RecordingGateway(failNext = true)
val coord = ApprovalCoordinator(gateway)
coord.onApprovalRequested(approvalEvent(Tier.T2))
val msg = ClientMessage.ApprovalResponse(requestId, ApprovalDecision.APPROVE, steeringNote = null)
val first = coord.handleResponse(msg, sessionId)
assertInstanceOf(ServerMessage.ProtocolError::class.java, first)
assertTrue(gateway.submissions.isEmpty())
// Retry must not be blocked by a stale resolved flag.
val second = coord.handleResponse(msg, sessionId)
assertNull(second)
assertEquals(1, gateway.submissions.size)
}
@Test
fun `recorded decision carries the request's real tier`(): Unit = runBlocking {
val gateway = RecordingGateway()
val coord = ApprovalCoordinator(gateway)
coord.onApprovalRequested(approvalEvent(Tier.T3))
val msg = ClientMessage.ApprovalResponse(requestId, ApprovalDecision.APPROVE, steeringNote = null)
coord.handleResponse(msg, sessionId)
assertEquals(Tier.T3, gateway.submissions[0].second.tier)
}
private fun storedEvent(payload: EventPayload, seq: Long): StoredEvent = StoredEvent( private fun storedEvent(payload: EventPayload, seq: Long): StoredEvent = StoredEvent(
metadata = EventMetadata( metadata = EventMetadata(
eventId = EventId("evt-$seq"), eventId = EventId("evt-$seq"),
+6 -1
View File
@@ -120,8 +120,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
} }
case protocol.TypeSessionPaused: case protocol.TypeSessionPaused:
label := "PAUSED" label := "PAUSED"
if msg.Reason == "APPROVAL_PENDING" { switch msg.Reason {
case "APPROVAL_PENDING":
label = "PAUSED awaiting approval" label = "PAUSED awaiting approval"
case "CLARIFICATION_PENDING":
label = "PAUSED awaiting answer"
case "ABANDONED_STALE":
label = "PAUSED (stale)"
} }
m.touch(msg.SessionID, label) m.touch(msg.SessionID, label)
if s := m.session(msg.SessionID); s != nil { if s := m.session(msg.SessionID); s != nil {
@@ -630,6 +630,19 @@ object ConfigLoader {
compressionLevel = asInt(orchestrationSection["compression_level"], 4), compressionLevel = asInt(orchestrationSection["compression_level"], 4),
tokenPrunerUrl = tokenPrunerUrl =
asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"), asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"),
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], 30),
readLoopNudgeThreshold = asInt(orchestrationSection["read_loop_nudge_threshold"], 3),
rejectionLoopNudgeThreshold = asInt(orchestrationSection["rejection_loop_nudge_threshold"], 3),
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], 3),
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], 30),
repoMapFilesPerDir = asInt(orchestrationSection["repo_map_files_per_dir"], 8),
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], 20),
maxClarificationRounds = asInt(orchestrationSection["max_clarification_rounds"], 3),
reviewBlockMinConfidence = asDouble(orchestrationSection["review_block_min_confidence"], 0.7),
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], 20),
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], 3),
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], 2),
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], 2),
) )
val modelsSettings = ModelsSettings( val modelsSettings = ModelsSettings(
@@ -649,6 +662,15 @@ object ConfigLoader {
) )
} }
val samplingSection = sections["sampling"] ?: emptyMap()
val sampling = SamplingConfig(
temperature = asDouble(samplingSection["temperature"], 0.7),
topP = asDouble(samplingSection["top_p"], 1.0),
topK = samplingSection["top_k"]?.let { asInt(it) },
minP = samplingSection["min_p"]?.let { asDouble(it) },
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
)
return CorrexConfig( return CorrexConfig(
server = server, server = server,
tui = tui, tui = tui,
@@ -662,6 +684,7 @@ object ConfigLoader {
project = project, project = project,
personalization = personalization, personalization = personalization,
orchestration = orchestration, orchestration = orchestration,
sampling = sampling,
) )
} }
@@ -16,9 +16,26 @@ data class CorrexConfig(
val project: ProjectConfig = ProjectConfig(), val project: ProjectConfig = ProjectConfig(),
val personalization: PersonalizationConfig = PersonalizationConfig(), val personalization: PersonalizationConfig = PersonalizationConfig(),
val orchestration: OrchestrationKnobs = OrchestrationKnobs(), val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
val sampling: SamplingConfig = SamplingConfig(),
val health: HealthConfig = HealthConfig(), val health: HealthConfig = HealthConfig(),
) )
/**
* Sampling knobs sent to the llama-server on every *stage* inference request (the main agentic
* loop). [temperature] and [topP] default to the former hardcoded stage values. [topK], [minP] and
* [repeatPenalty] are null by default, meaning the request omits them and the model keeps its own
* default — set them to tighten a rambling local model. maxTokens is not here: it is pinned per-stage
* to the token budget. Talkie chat/narration have their own [GenerationSettings]/[NarrationSettings].
*/
@Serializable
data class SamplingConfig(
val temperature: Double = 0.7,
val topP: Double = 1.0,
val topK: Int? = null,
val minP: Double? = null,
val repeatPenalty: Double? = null,
)
/** /**
* Continuous health watch (observability-spec §4). When [enabled], a background monitor polls * Continuous health watch (observability-spec §4). When [enabled], a background monitor polls
* the on-disk footprint every [intervalMs] and records a degraded/restored event on the edge. * the on-disk footprint every [intervalMs] and records a degraded/restored event on the edge.
@@ -69,6 +86,25 @@ data class OrchestrationKnobs(
*/ */
val compressionLevel: Int = 4, val compressionLevel: Int = 4,
val tokenPrunerUrl: String = "http://127.0.0.1:8199", val tokenPrunerUrl: String = "http://127.0.0.1:8199",
/**
* Orchestration loop/threshold/budget tuning, read once at server startup. Defaults equal the
* kernel's former hardcoded constants — an absent section reproduces prior behavior. Reach for
* these when a local model misbehaves (thrashes a read loop, re-asks clarifications, gets trapped
* by a stubborn reviewer). Mirrors kernel OrchestrationTuning.
*/
val maxToolRounds: Int = 30,
val readLoopNudgeThreshold: Int = 3,
val rejectionLoopNudgeThreshold: Int = 3,
val maxFeedbackIssues: Int = 3,
val repoMapInjectTopK: Int = 30,
val repoMapFilesPerDir: Int = 8,
val docsCatalogMax: Int = 20,
val maxClarificationRounds: Int = 3,
val reviewBlockMinConfidence: Double = 0.7,
val reviewBlockRetryCap: Int = 20,
val defaultMaxRefinement: Int = 3,
val recoveryRouteBudget: Int = 2,
val intentRouteBudget: Int = 2,
) )
@Serializable @Serializable
@@ -86,6 +86,28 @@ object CorrexConfigWriter {
b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs) b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs)
b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold) b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold)
b.kv("resume_abandoned_max_age_minutes", cfg.orchestration.resumeAbandonedMaxAgeMinutes) b.kv("resume_abandoned_max_age_minutes", cfg.orchestration.resumeAbandonedMaxAgeMinutes)
b.kv("compression_level", cfg.orchestration.compressionLevel)
b.kv("token_pruner_url", cfg.orchestration.tokenPrunerUrl)
b.kv("max_tool_rounds", cfg.orchestration.maxToolRounds)
b.kv("read_loop_nudge_threshold", cfg.orchestration.readLoopNudgeThreshold)
b.kv("rejection_loop_nudge_threshold", cfg.orchestration.rejectionLoopNudgeThreshold)
b.kv("max_feedback_issues", cfg.orchestration.maxFeedbackIssues)
b.kv("repo_map_inject_top_k", cfg.orchestration.repoMapInjectTopK)
b.kv("repo_map_files_per_dir", cfg.orchestration.repoMapFilesPerDir)
b.kv("docs_catalog_max", cfg.orchestration.docsCatalogMax)
b.kv("max_clarification_rounds", cfg.orchestration.maxClarificationRounds)
b.kv("review_block_min_confidence", cfg.orchestration.reviewBlockMinConfidence)
b.kv("review_block_retry_cap", cfg.orchestration.reviewBlockRetryCap)
b.kv("default_max_refinement", cfg.orchestration.defaultMaxRefinement)
b.kv("recovery_route_budget", cfg.orchestration.recoveryRouteBudget)
b.kv("intent_route_budget", cfg.orchestration.intentRouteBudget)
b.section("sampling")
b.kv("temperature", cfg.sampling.temperature)
b.kv("top_p", cfg.sampling.topP)
cfg.sampling.topK?.let { b.kv("top_k", it) }
cfg.sampling.minP?.let { b.kv("min_p", it) }
cfg.sampling.repeatPenalty?.let { b.kv("repeat_penalty", it) }
b.section("personalization") b.section("personalization")
b.kv("enabled", cfg.personalization.enabled) b.kv("enabled", cfg.personalization.enabled)
@@ -19,4 +19,8 @@ data class ToolReceipt(
val tier: Tier, val tier: Tier,
val timestamp: Instant, val timestamp: Instant,
val diff: String? = null, 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,
) )
@@ -13,4 +13,9 @@ data class GenerationConfig(
val maxTokens: Int, val maxTokens: Int,
val stopSequences: List<String> = emptyList(), val stopSequences: List<String> = emptyList(),
val seed: Long? = null, // null = non-deterministic; set for replay val seed: Long? = null, // null = non-deterministic; set for replay
// Sampling knobs. null = omit from the request so the provider/model keeps its own default,
// preserving prior behavior. Serialized only when set (top_k / min_p / repeat_penalty).
val topK: Int? = null,
val minP: Double? = null,
val repeatPenalty: Double? = null,
) )
@@ -57,18 +57,14 @@ import java.util.concurrent.atomic.*
) )
private val log = LoggerFactory.getLogger(DefaultSessionOrchestrator::class.java) private val log = LoggerFactory.getLogger(DefaultSessionOrchestrator::class.java)
// Fallback refinement-loop cap when a stage does not declare maxRetries.
private const val DEFAULT_MAX_REFINEMENT = 3
// Own, small budget for the retry-agency recovery route: how many times a single stage may be // Own, small budget for the retry-agency recovery route: how many times a single stage may be
// routed to a recovery stage before the run fails terminally. Deliberately smaller than the // routed to a recovery stage before the run fails terminally. Deliberately smaller than the
// per-gate retry budget — recovery is a purpose-built repair-then-reverify loop, not open-ended. // per-gate retry budget — recovery is a purpose-built repair-then-reverify loop, not open-ended.
private const val RECOVERY_ROUTE_BUDGET = 2 // RECOVERY_ROUTE_BUDGET / INTENT_ROUTE_BUDGET now in OrchestrationTuning.
// Tier-2 escalation budget: how many NO-PROGRESS routes the arbiter (recovery-stage-as-intent-holder) // Tier-2 escalation budget: how many NO-PROGRESS routes the arbiter (recovery-stage-as-intent-holder)
// gets after the tier-1 owner loop exhausts, before the run fails terminally. Independent of the owner // gets after the tier-1 owner loop exhausts, before the run fails terminally. Independent of the owner
// budget (keyed with INTENT_BUDGET_SUFFIX) so escalation is a genuine second chance, not a shared pool. // budget (keyed with INTENT_BUDGET_SUFFIX) so escalation is a genuine second chance, not a shared pool.
private const val INTENT_ROUTE_BUDGET = 2
// Transition ids for the failure-ticket loop. A ticket routes the failing gate's control to the // Transition ids for the failure-ticket loop. A ticket routes the failing gate's control to the
// repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that // repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that
@@ -109,7 +105,8 @@ class DefaultSessionOrchestrator(
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
readyTaskCounter: ReadyTaskCounter? = null, readyTaskCounter: ReadyTaskCounter? = null,
taskClaimCoordinator: TaskClaimCoordinator? = null, taskClaimCoordinator: TaskClaimCoordinator? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator), ApprovalGateway { tuning: OrchestrationTuning = OrchestrationTuning(),
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> = override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>() ConcurrentHashMap<SessionId, AtomicBoolean>()
@@ -340,10 +337,10 @@ class DefaultSessionOrchestrator(
// (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to // (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to
// the arbiter tier once the owner loop is spent; null = no tier available now. // the arbiter tier once the owner loop is spent; null = no tier available now.
val route = when { val route = when {
owner != null && !budgetExhausted(state, ownerKey, fingerprint, RECOVERY_ROUTE_BUDGET) -> owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
RouteTier(owner, ownerKey, RECOVERY_ROUTE_BUDGET, escalated = false) RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, INTENT_ROUTE_BUDGET) -> arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
RouteTier(arbiter, intentKey, INTENT_ROUTE_BUDGET, escalated = true) RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
else -> null else -> null
} }
if (route == null) { if (route == null) {
@@ -621,7 +618,7 @@ class DefaultSessionOrchestrator(
// terminal failure instead of looping forever. // terminal failure instead of looping forever.
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) { if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}" val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: DEFAULT_MAX_REFINEMENT val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1 val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations)) emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
if (iteration > maxIterations) { if (iteration > maxIterations) {
@@ -661,8 +658,11 @@ class DefaultSessionOrchestrator(
.map { it.requestId } .map { it.requestId }
.toSet() .toSet()
stageRequestIds.isNotEmpty() && events.any { stageRequestIds.isNotEmpty() && events.any {
(it.payload as? ApprovalDecisionResolvedEvent) val decision = it.payload as? ApprovalDecisionResolvedEvent
?.requestId in stageRequestIds // 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) { if (!alreadyApproved) {
@@ -696,7 +696,7 @@ class DefaultSessionOrchestrator(
compactionService?.let { svc -> compactionService?.let { svc ->
val journalState = decisionJournalRepository.getJournal(ctx.sessionId) val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
val journalText = DecisionJournalRenderer().render(journalState) val journalText = DecisionJournalRenderer().render(journalState)
val tokenEstimate = journalText.length / 4 val tokenEstimate = estimateTokens(journalText)
svc.compactIfNeeded( svc.compactIfNeeded(
sessionId = ctx.sessionId, sessionId = ctx.sessionId,
state = journalState, state = journalState,
@@ -0,0 +1,44 @@
package com.correx.core.kernel.orchestration
/**
* Operator-tunable orchestration loop/threshold/budget knobs, loaded once at server startup from
* the `[orchestration]` config section and threaded into the orchestrator. Defaults equal the
* former hardcoded constants, so an absent config section reproduces prior behavior exactly.
*
* These are the knobs an operator reaches for when a *local* model misbehaves (thrashes a read
* loop, re-asks the same clarification, gets trapped by a stubborn reviewer). Pure output-shaping
* truncation caps (summary/evidence length limits) are intentionally left as constants — they don't
* change orchestration behavior, only display width.
*
* ponytail: startup-load, not hot-reload — orchestrator holds this by value. If per-session
* live-tuning is ever wanted, pass a `() -> OrchestrationTuning` supplier like the journal-compaction
* threshold does.
*/
data class OrchestrationTuning(
/** Max inference+tool rounds in a single stage's ReAct loop before it's forced to conclude. */
val maxToolRounds: Int = 30,
/** Consecutive read-only rounds after which the model is nudged to write. */
val readLoopNudgeThreshold: Int = 3,
/** Consecutive rejected-tool rounds after which the model is nudged off the blocked path. */
val rejectionLoopNudgeThreshold: Int = 3,
/** Max validation issues surfaced back to the model as feedback. */
val maxFeedbackIssues: Int = 3,
/** Top-K repo-map hits injected into a stage's context. */
val repoMapInjectTopK: Int = 30,
/** Files listed per directory in the what-exists repo map. */
val repoMapFilesPerDir: Int = 8,
/** Max docs listed in the always-on "docs available" catalog. */
val docsCatalogMax: Int = 20,
/** Max times a single stage may re-ask clarification before it must proceed. */
val maxClarificationRounds: Int = 3,
/** Minimum reviewer confidence for a correctness finding to block a stage. */
val reviewBlockMinConfidence: Double = 0.7,
/** Pathological backstop: max review-driven retries before the stage is let through. */
val reviewBlockRetryCap: Int = 20,
/** Max review→refine cycles for a stage (freestyle default refinement budget). */
val defaultMaxRefinement: Int = 3,
/** Budget for routing a failed write-less stage to a recovery stage. */
val recoveryRouteBudget: Int = 2,
/** Budget for tier-2 intent-holder arbiter re-routing. */
val intentRouteBudget: Int = 2,
)
@@ -181,14 +181,13 @@ import kotlin.coroutines.cancellation.CancellationException
// re-investigating (list_dir/read/rebuild) before it reaches the write, and got bounced out before // re-investigating (list_dir/read/rebuild) before it reaches the write, and got bounced out before
// acting on a correct diagnosis. The real runaway guards are the read-loop and rejection-loop // acting on a correct diagnosis. The real runaway guards are the read-loop and rejection-loop
// nudges (both fire after 3 unproductive rounds), so this ceiling only needs to be generous enough // nudges (both fire after 3 unproductive rounds), so this ceiling only needs to be generous enough
// that productive stages aren't cut off mid-work. // that productive stages aren't cut off mid-work. Value now lives in OrchestrationTuning.maxToolRounds.
private const val MAX_TOOL_ROUNDS = 30
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written // Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
// artifact before we force the write nudge. A model that keeps calling read tools every round // artifact before we force the write nudge. A model that keeps calling read tools every round
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns // trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed). // every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
private const val READ_LOOP_NUDGE_THRESHOLD = 3 // Value now lives in OrchestrationTuning.readLoopNudgeThreshold.
// Consecutive rounds in which EVERY tool call was rejected/denied (plane-2 BLOCKED or a stage/policy // Consecutive rounds in which EVERY tool call was rejected/denied (plane-2 BLOCKED or a stage/policy
// ERROR) with nothing succeeding. Distinct from the read-loop breaker, which only fires for stages // ERROR) with nothing succeeding. Distinct from the read-loop breaker, which only fires for stages
@@ -196,22 +195,44 @@ private const val READ_LOOP_NUDGE_THRESHOLD = 3
// rejected path (e.g. list/read of a not-yet-created dir) would otherwise thrash to MAX_TOOL_ROUNDS // rejected path (e.g. list/read of a not-yet-created dir) would otherwise thrash to MAX_TOOL_ROUNDS
// with no progress. After this many all-rejected rounds we force the model to stop retrying and // with no progress. After this many all-rejected rounds we force the model to stop retrying and
// produce its output; a single successful tool call resets the counter. // produce its output; a single successful tool call resets the counter.
private const val REJECTION_LOOP_NUDGE_THRESHOLD = 3 // Value now lives in OrchestrationTuning.rejectionLoopNudgeThreshold.
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit") private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
private const val MAX_FEEDBACK_ISSUES = 3
private const val STAGE_COMPLETE_TOOL = "stage_complete" private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact" private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
private const val SCOPE_PROPOSAL_TOOL = "propose_scope" private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE" private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS" private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
private const val OUTPUT_SUMMARY_LIMIT = 500 private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30
private const val REPO_MAP_FILES_PER_DIR = 8 // Global floor on tool-result text entering model context, applied beneath each tool's own
// outputCompressor. A tool without a compressor (or one whose output survives compression) can still
// flood a small model's window, so any Success body over this many chars is shown head+tail with a
// marker and the FULL raw output spilled to the artifact store for retrieval via tool_output.
// ponytail: fixed constants; lift to OrchestrationTuning if operators need to tune per-deployment.
private const val TOOL_RESULT_MAX_CHARS = 8_000
private const val TOOL_RESULT_HEAD_LINES = 60
private const val TOOL_RESULT_TAIL_LINES = 60
private const val TOOL_OUTPUT_TOOL = "tool_output"
/**
* Frame an over-cap tool output as `header` + head lines + a truncation marker (naming the
* [tool_output] ref that retrieves the full text) + tail lines. Head and tail are each char-capped
* so a single pathological long line can't defeat the line-count bound. Pure — the caller spills the
* full output and supplies its [ref].
*/
internal fun frameTruncatedToolResult(header: String, compressed: String, ref: String): String {
val lines = compressed.split("\n")
val head = lines.take(TOOL_RESULT_HEAD_LINES).joinToString("\n").take(TOOL_RESULT_MAX_CHARS / 2)
val tail = lines.takeLast(TOOL_RESULT_TAIL_LINES).joinToString("\n").takeLast(TOOL_RESULT_MAX_CHARS / 2)
val marker = "… output truncated (${lines.size} lines); " +
"call $TOOL_OUTPUT_TOOL(ref=\"$ref\") for the full output …"
return "$header\n$head\n$marker\n$tail"
}
// Read-on-demand doc catalog: the top-N docs (by repo-map recency score) surfaced as // Read-on-demand doc catalog: the top-N docs (by repo-map recency score) surfaced as
// `path — descriptor` so a stage learns which docs exist and can file_read one when relevant, // `path — descriptor` so a stage learns which docs exist and can file_read one when relevant,
// instead of docs being force-fed (or excluded outright). One line each, hard-capped, so it can // instead of docs being force-fed (or excluded outright). One line each, hard-capped, so it can
// stay always-on without re-poisoning context (2026-07-07 doc-injection rework). // stay always-on without re-poisoning context (2026-07-07 doc-injection rework).
private const val DOCS_CATALOG_MAX = 20 // Value now lives in OrchestrationTuning.docsCatalogMax.
// A stage prompt must explicitly ask about documentation for .md/docs paths to enter the repo // A stage prompt must explicitly ask about documentation for .md/docs paths to enter the repo
// layout listing; otherwise docs only reach context through a real retrieval hit. // layout listing; otherwise docs only reach context through a real retrieval hit.
@@ -241,8 +262,7 @@ private val REQUIRED_SOURCE_TYPES = setOf(
private const val HTTP_TIMEOUT = 408 private const val HTTP_TIMEOUT = 408
private const val HTTP_TOO_MANY_REQUESTS = 429 private const val HTTP_TOO_MANY_REQUESTS = 429
// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds. // Cap on clarification rounds per stage — OrchestrationTuning.maxClarificationRounds.
private const val MAX_CLARIFICATION_ROUNDS = 3
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed // Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end. // back to the model so it can fix the failure. Tails, because the error summary sits at the end.
@@ -255,8 +275,7 @@ private const val CONTRACT_EVIDENCE_CAP = 400
// every attempt (so the budget never charges, i.e. the run looks like perpetual "progress"): after // every attempt (so the budget never charges, i.e. the run looks like perpetual "progress"): after
// this many blocks, findings surface without blocking so a stuck reviewer can never trap a stage // this many blocks, findings surface without blocking so a stuck reviewer can never trap a stage
// forever. Set well above any legitimate budget+salvage sequence. Objective text is capped too. // forever. Set well above any legitimate budget+salvage sequence. Objective text is capped too.
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7 // REVIEW_BLOCK_MIN_CONFIDENCE / REVIEW_BLOCK_RETRY_CAP now in OrchestrationTuning.
private const val REVIEW_BLOCK_RETRY_CAP = 20
private const val REVIEW_OBJECTIVE_CAP = 4_000 private const val REVIEW_OBJECTIVE_CAP = 4_000
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000 private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000 private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@@ -287,6 +306,7 @@ abstract class SessionOrchestrator(
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
private val readyTaskCounter: ReadyTaskCounter? = null, private val readyTaskCounter: ReadyTaskCounter? = null,
private val taskClaimCoordinator: TaskClaimCoordinator? = null, private val taskClaimCoordinator: TaskClaimCoordinator? = null,
protected val tuning: OrchestrationTuning = OrchestrationTuning(),
) { ) {
private val log = LoggerFactory.getLogger(this::class.java) private val log = LoggerFactory.getLogger(this::class.java)
private val eventStore: EventStore = repositories.eventStore private val eventStore: EventStore = repositories.eventStore
@@ -338,6 +358,14 @@ abstract class SessionOrchestrator(
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */ * Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
protected val artifactContentCache: ConcurrentHashMap<String, String> = ConcurrentHashMap() protected val artifactContentCache: ConcurrentHashMap<String, String> = ConcurrentHashMap()
/** Drops a terminated session's cached artifact contents (the heaviest per-session state — full
* file/JSON payloads). Safe: rehydrateArtifactContentCache rebuilds it from durable events if the
* session is ever resumed. Called on WorkflowCompleted/WorkflowFailed. */
private fun 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, /** Deterministic extraction/repair ladder for near-miss LLM artifact text (prose-wrapped JSON,
* code fences, trailing commas). Pure — recomputes on replay, records no events. */ * code fences, trailing commas). Pure — recomputes on replay, records no events. */
private val artifactExtractionPipeline = ArtifactExtractionPipeline() private val artifactExtractionPipeline = ArtifactExtractionPipeline()
@@ -511,7 +539,7 @@ abstract class SessionOrchestrator(
content = journalText, content = journalText,
sourceType = "decisionJournal", sourceType = "decisionJournal",
sourceId = "decision-journal", sourceId = "decision-journal",
tokenEstimate = journalText.length / 4, tokenEstimate = estimateTokens(journalText),
role = EntryRole.SYSTEM, role = EntryRole.SYSTEM,
), ),
) )
@@ -682,7 +710,7 @@ abstract class SessionOrchestrator(
while ( while (
inferenceResult is InferenceResult.Success && inferenceResult is InferenceResult.Success &&
toolRounds < MAX_TOOL_ROUNDS && toolRounds < tuning.maxToolRounds &&
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite()) (inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
) { ) {
// Content turn (no tool call) but the stage still owes a file_written artifact: the // Content turn (no tool call) but the stage still owes a file_written artifact: the
@@ -761,7 +789,7 @@ abstract class SessionOrchestrator(
consecutiveReadOnlyRounds = 0 consecutiveReadOnlyRounds = 0
} else { } else {
consecutiveReadOnlyRounds++ consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) { if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
consecutiveReadOnlyRounds = 0 consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true) inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue continue
@@ -781,7 +809,7 @@ abstract class SessionOrchestrator(
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") } toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
if (allRejected) { if (allRejected) {
consecutiveRejectedRounds++ consecutiveRejectedRounds++
if (consecutiveRejectedRounds >= REJECTION_LOOP_NUDGE_THRESHOLD) { if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
consecutiveRejectedRounds = 0 consecutiveRejectedRounds = 0
inferenceResult = pushBack( inferenceResult = pushBack(
"STOP. Your last tool calls were all rejected and retrying the same paths will " + "STOP. Your last tool calls were all rejected and retrying the same paths will " +
@@ -1098,6 +1126,38 @@ abstract class SessionOrchestrator(
"\nAccepted parameters for '${it.name}': ${it.parametersSchema}" "\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
}.orEmpty() }.orEmpty()
private data class RenderedToolResult(val content: String, val fullOutputHash: String?)
/**
* 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.
*/
private suspend fun 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)
}
}
}
private suspend fun dispatchToolCalls( private suspend fun dispatchToolCalls(
sessionId: SessionId, sessionId: SessionId,
stageId: StageId, stageId: StageId,
@@ -1275,7 +1335,9 @@ abstract class SessionOrchestrator(
mode = approvalMode, mode = approvalMode,
) )
val requestId = ApprovalRequestId(UUID.randomUUID().toString()) val requestId = ApprovalRequestId(UUID.randomUUID().toString())
val toolPreview = computeToolPreview(toolCall.function.name, parameters) val toolPreview = computeToolPreview(
toolCall.function.name, parameters, effectives.policy?.workspaceRoot,
)
val domainRequest = DomainApprovalRequest( val domainRequest = DomainApprovalRequest(
id = requestId, id = requestId,
tier = tier, tier = tier,
@@ -1405,6 +1467,9 @@ abstract class SessionOrchestrator(
?.let { request.copy(grantedPaths = grantedOutside + it.toString()) } ?.let { request.copy(grantedPaths = grantedOutside + it.toString()) }
?: request ?: request
val result = executor.execute(executedRequest) 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 // Store ProcessResult artifact for every shell execution outcome
for (slot in processResultSlots) { for (slot in processResultSlots) {
@@ -1447,7 +1512,7 @@ abstract class SessionOrchestrator(
// artifact carries the diff as evidence of the change. // artifact carries the diff as evidence of the change.
recordToolExecution( recordToolExecution(
sessionId, stageId, toolCall, invocationId, tier, result, sessionId, stageId, toolCall, invocationId, tier, result,
tool as? FileAffectingTool, request, fileWrittenSlots, tool as? FileAffectingTool, request, fileWrittenSlots, rendered.fullOutputHash,
) )
val sourceId = toolCall.id ?: invocationId.value val sourceId = toolCall.id ?: invocationId.value
@@ -1461,22 +1526,13 @@ abstract class SessionOrchestrator(
role = EntryRole.ASSISTANT, role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning, reasoning = toolCallReasoning,
) )
val resultContent = when (result) {
is ToolResult.Success ->
tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode))
?: result.output
is ToolResult.Failure -> {
if (!result.recoverable) "FATAL: ${result.reason}"
else "ERROR: ${result.reason}${toolArgsHint(tool)}"
}
}
val resultEntry = ContextEntry( val resultEntry = ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()), id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2, layer = ContextLayer.L2,
sourceType = "toolResult", sourceType = "toolResult",
sourceId = sourceId, sourceId = sourceId,
content = resultContent, content = rendered.content,
tokenEstimate = estimateTokens(resultContent), tokenEstimate = estimateTokens(rendered.content),
role = EntryRole.TOOL, role = EntryRole.TOOL,
) )
val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let { val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
@@ -1764,7 +1820,7 @@ abstract class SessionOrchestrator(
val priorRounds = eventStore.read(sessionId) val priorRounds = eventStore.read(sessionId)
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId } .count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
if (priorRounds >= MAX_CLARIFICATION_ROUNDS) return false if (priorRounds >= tuning.maxClarificationRounds) return false
val requestId = ClarificationRequestId(UUID.randomUUID().toString()) val requestId = ClarificationRequestId(UUID.randomUUID().toString())
val deferred = CompletableDeferred<List<ClarificationAnswer>>() val deferred = CompletableDeferred<List<ClarificationAnswer>>()
@@ -1804,7 +1860,7 @@ abstract class SessionOrchestrator(
appendLine("## Repo layout (what exists — use file_read for content)") appendLine("## Repo layout (what exists — use file_read for content)")
byDir.entries.sortedBy { it.key }.forEach { (dir, files) -> byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
val names = files.map { it.substringAfterLast('/') } val names = files.map { it.substringAfterLast('/') }
val shown = names.take(REPO_MAP_FILES_PER_DIR) val shown = names.take(tuning.repoMapFilesPerDir)
val more = names.size - shown.size val more = names.size - shown.size
val suffix = if (more > 0) ", …(+$more more)" else "" val suffix = if (more > 0) ", …(+$more more)" else ""
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix") appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
@@ -1905,7 +1961,7 @@ abstract class SessionOrchestrator(
val retriever = repoKnowledgeRetriever val retriever = repoKnowledgeRetriever
?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId) ?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId)
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, REPO_MAP_INJECT_TOP_K) } val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, tuning.repoMapInjectTopK) }
.getOrElse { e -> .getOrElse { e ->
if (e is CancellationException) throw e if (e is CancellationException) throw e
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message) log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
@@ -1932,7 +1988,7 @@ abstract class SessionOrchestrator(
val docs = map.entries val docs = map.entries
.filter { isDocPath(it.path) } .filter { isDocPath(it.path) }
.sortedByDescending { it.score } .sortedByDescending { it.score }
.take(DOCS_CATALOG_MAX) .take(tuning.docsCatalogMax)
if (docs.isEmpty()) return emptyList() if (docs.isEmpty()) return emptyList()
val content = buildString { val content = buildString {
appendLine("## Docs available (file_read to open — do not assume contents)") appendLine("## Docs available (file_read to open — do not assume contents)")
@@ -2072,9 +2128,14 @@ abstract class SessionOrchestrator(
) { ) {
return@forEach return@forEach
} }
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1)) emitAll(
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId)) sessionId,
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId)) listOf(
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
ArtifactValidatingEvent(slot.name, sessionId, stageId),
ArtifactValidatedEvent(slot.name, sessionId, stageId),
),
)
} }
} }
@@ -2088,6 +2149,7 @@ abstract class SessionOrchestrator(
fileTool: FileAffectingTool?, fileTool: FileAffectingTool?,
request: ToolRequest, request: ToolRequest,
fileWrittenSlots: List<TypedArtifactSlot>, fileWrittenSlots: List<TypedArtifactSlot>,
fullOutputHash: String? = null,
) { ) {
// Invariant #5: every tool side effect is captured. This is the single authoritative // 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. // ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
@@ -2126,6 +2188,7 @@ abstract class SessionOrchestrator(
tier = tier, tier = tier,
timestamp = Clock.System.now(), timestamp = Clock.System.now(),
diff = diff, diff = diff,
fullOutputHash = fullOutputHash,
), ),
), ),
) )
@@ -2196,9 +2259,14 @@ abstract class SessionOrchestrator(
) )
return@forEach return@forEach
} }
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1)) emitAll(
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId)) sessionId,
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId)) listOf(
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
ArtifactValidatingEvent(slot.name, sessionId, stageId),
ArtifactValidatedEvent(slot.name, sessionId, stageId),
),
)
} }
} }
@@ -2767,11 +2835,11 @@ abstract class SessionOrchestrator(
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent } .mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
.count { it.stageId == stageId && it.blocked } .count { it.stageId == stageId && it.blocked }
val blockingFinding = outcome.findings.firstOrNull { val blockingFinding = outcome.findings.firstOrNull {
it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE it.correctness && it.confidence >= tuning.reviewBlockMinConfidence
} }
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL && val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
blockingFinding != null && blockingFinding != null &&
priorBlocks < REVIEW_BLOCK_RETRY_CAP priorBlocks < tuning.reviewBlockRetryCap
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock)) emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
@@ -2808,9 +2876,14 @@ abstract class SessionOrchestrator(
// Emit artifact lifecycle events for process_result slots on failure branches. // Emit artifact lifecycle events for process_result slots on failure branches.
// Content was already stored in CAS + cache by dispatchToolCalls for every outcome. // Content was already stored in CAS + cache by dispatchToolCalls for every outcome.
stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot -> stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot ->
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1)) emitAll(
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId)) sessionId,
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId)) listOf(
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
ArtifactValidatingEvent(slot.name, sessionId, stageId),
ArtifactValidatedEvent(slot.name, sessionId, stageId),
),
)
} }
} }
@@ -2859,7 +2932,7 @@ abstract class SessionOrchestrator(
return if (errors.isEmpty()) { return if (errors.isEmpty()) {
"validation failed" "validation failed"
} else { } else {
"validation failed: " + errors.take(MAX_FEEDBACK_ISSUES).joinToString("; ") "validation failed: " + errors.take(tuning.maxFeedbackIssues).joinToString("; ")
} }
} }
@@ -2904,11 +2977,14 @@ abstract class SessionOrchestrator(
tools = if (!withTools) { tools = if (!withTools) {
emptyList() emptyList()
} else { } else {
// Read the log ONCE for the read-only check instead of once per tool inside the filter
// (the flag is tool-independent) — this filter runs per tool per inference round.
val readOnlyMode = isReadOnlyMode(sessionId)
stageConfig.effectiveAllowedTools stageConfig.effectiveAllowedTools
.mapNotNull { effectives.registry?.resolve(it) } .mapNotNull { effectives.registry?.resolve(it) }
.filter { tool -> .filter { tool ->
// ponytail: filter write tools while read-before-write block is active; restored once a read completes // ponytail: filter write tools while read-before-write block is active; restored once a read completes
!isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities !readOnlyMode || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
} }
.filter { tool -> .filter { tool ->
// Read-loop break: keep only write tools (drop file_read/list_dir/shell) so // Read-loop break: keep only write tools (drop file_read/list_dir/shell) so
@@ -3071,6 +3147,7 @@ abstract class SessionOrchestrator(
correlateCritiqueOutcomes(sessionId) correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId)) emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
cancellations.remove(sessionId) cancellations.remove(sessionId)
evictArtifactContentCache(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId) return WorkflowResult.Completed(sessionId, terminalStageId)
} }
@@ -3114,6 +3191,7 @@ abstract class SessionOrchestrator(
) )
} }
cancellations.remove(sessionId) cancellations.remove(sessionId)
evictArtifactContentCache(sessionId)
return WorkflowResult.Failed(sessionId, reason, retryExhausted) return WorkflowResult.Failed(sessionId, reason, retryExhausted)
} }
@@ -3187,6 +3265,29 @@ abstract class SessionOrchestrator(
) )
} }
/** Appends [payloads] as one transaction (one flow-publish pass) for events that belong together. */
internal suspend fun 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,
)
},
)
}
// --- token estimation --- // --- token estimation ---
protected open suspend fun estimateTokens(content: String): Int { protected open suspend fun estimateTokens(content: String): Int {
@@ -3382,10 +3483,14 @@ abstract class SessionOrchestrator(
* This function performs blocking I/O (file reads) and must be called from a suspend context * This function performs blocking I/O (file reads) and must be called from a suspend context
* that will dispatch it on [Dispatchers.IO]. * that will dispatch it on [Dispatchers.IO].
*/ */
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? { private suspend fun computeToolPreview(
toolName: String,
parameters: Map<String, Any>,
workspaceRoot: java.nio.file.Path?,
): String? {
if (toolName == "shell") return shellCommandPreview(parameters) if (toolName == "shell") return shellCommandPreview(parameters)
if (toolName == "task_decompose") return renderDecomposePreview(parameters) if (toolName == "task_decompose") return renderDecomposePreview(parameters)
if (toolName == "file_edit") return computeFileEditPreview(parameters) if (toolName == "file_edit") return computeFileEditPreview(parameters, workspaceRoot)
if (toolName != "file_write") return null if (toolName != "file_write") return null
val path = parameters["path"] as? String ?: 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 // file_write no longer carries an `operation` param (delete was split into file_delete), so the
@@ -3393,18 +3498,23 @@ private suspend fun computeToolPreview(toolName: String, parameters: Map<String,
// silently bailed to the raw-JSON args fallback for every file_write. // silently bailed to the raw-JSON args fallback for every file_write.
val proposedContent = parameters["content"] as? String ?: return null val proposedContent = parameters["content"] as? String ?: return null
val existingContent = readFileIfExists(path) val existingContent = readFileIfExists(path, workspaceRoot)
return buildDiffString(path, existingContent, proposedContent) return buildDiffString(path, existingContent, proposedContent)
} }
private suspend fun readFileIfExists(path: String): String? = withContext(Dispatchers.IO) { private suspend fun readFileIfExists(path: String, workspaceRoot: java.nio.file.Path?): String? =
runCatching { withContext(Dispatchers.IO) {
val filePath = java.nio.file.Paths.get(path) runCatching {
if (java.nio.file.Files.exists(filePath)) { // Resolve relative paths against the session's workspace root, same as the tools do —
java.nio.file.Files.readString(filePath) // resolving against the daemon CWD showed the operator the wrong file (or nothing) when
} else null // server CWD ≠ workspace_root.
}.getOrNull() 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 * Preview for `file_edit`'s "replace" and "append" operations: reads the existing file and
@@ -3412,10 +3522,13 @@ private suspend fun readFileIfExists(path: String): String? = withContext(Dispat
* to the raw-JSON-args fallback — simulating `patch -p1` application here isn't worth the risk of * 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. * the preview silently disagreeing with what the tool actually does.
*/ */
private suspend fun computeFileEditPreview(parameters: Map<String, Any>): String? { private suspend fun computeFileEditPreview(
parameters: Map<String, Any>,
workspaceRoot: java.nio.file.Path?,
): String? {
val path = parameters["path"] as? String ?: return null val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String ?: return null val operation = parameters["operation"] as? String ?: return null
val existingContent = readFileIfExists(path) ?: return null val existingContent = readFileIfExists(path, workspaceRoot) ?: return null
val proposedContent = when (operation) { val proposedContent = when (operation) {
"append" -> existingContent + (parameters["content"] as? String ?: return null) "append" -> existingContent + (parameters["content"] as? String ?: return null)
@@ -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)
}
}
@@ -1,46 +1,61 @@
package com.correx.core.talkie package com.correx.core.talkie
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaCapturedEvent import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.talkie.model.Idea import com.correx.core.talkie.model.Idea
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/** /**
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over * Holds the operator's idea board in memory. Cross-session by design: it folds over the whole event
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session * log (not a single session's replay), so an idea captured in one session shows on the board (and
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a * feeds the router) in every session. A captured idea is dropped once a matching [IdeaDiscardedEvent]
* matching [IdeaDiscardedEvent] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture * (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture stays in the log).
* stays in the log). *
* The board is folded ONCE at construction from [EventStore.allEvents] (a full, eager table load) and
* then kept current incrementally via [EventStore.subscribeAll]. This replaces the previous
* fold-the-entire-log-per-call design, which ran a full deserializing scan on every CHAT context
* build. The fold is idempotent (map put / set add), so the seed⇄live overlap needs no dedupe.
*/ */
class IdeaReader(private val eventStore: EventStore) { class IdeaReader(
eventStore: EventStore,
scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
) {
// id -> its capture event (source for text/sessionId); tombstoned = discarded/promoted ids.
private val captures = ConcurrentHashMap<String, IdeaCapturedEvent>()
private val tombstoned: MutableSet<String> = ConcurrentHashMap.newKeySet()
/** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */ init {
fun activeIdeas(): List<Idea> { eventStore.allEvents().forEach { apply(it.payload) }
val tombstoned = mutableSetOf<String>() scope.launch { eventStore.subscribeAll().collect { apply(it.payload) } }
val captured = mutableListOf<Idea>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
is IdeaPromotedEvent -> tombstoned += payload.ideaId
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
else -> Unit
}
}
return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs }
} }
private fun apply(payload: EventPayload) {
when (payload) {
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
is IdeaPromotedEvent -> tombstoned += payload.ideaId
is IdeaCapturedEvent -> captures[payload.ideaId] = payload
else -> Unit
}
}
/** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */
fun activeIdeas(): List<Idea> =
captures.values
.filterNot { it.ideaId in tombstoned }
.map { Idea(it.ideaId, it.text, it.timestampMs) }
.sortedByDescending { it.capturedAtMs }
/** The session that captured [ideaId], so a tombstone lands alongside its capture. */ /** The session that captured [ideaId], so a tombstone lands alongside its capture. */
fun sessionOf(ideaId: String): SessionId? = fun sessionOf(ideaId: String): SessionId? = captures[ideaId]?.sessionId
capturedOf(ideaId)?.sessionId
/** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */ /** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */
fun textOf(ideaId: String): String? = fun textOf(ideaId: String): String? = captures[ideaId]?.text
capturedOf(ideaId)?.text
private fun capturedOf(ideaId: String): IdeaCapturedEvent? =
eventStore.allEvents()
.mapNotNull { it.payload as? IdeaCapturedEvent }
.firstOrNull { it.ideaId == ideaId }
} }
@@ -66,6 +66,7 @@ data class StageConfig(
companion object { companion object {
/** Read-only tools every tool-granting stage may call regardless of its declared set. */ /** 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") val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> =
setOf("file_read", "list_dir", "glob", "grep", "tool_output")
} }
} }
@@ -151,10 +151,12 @@ class DefaultModelManager(
currentDescriptor = null currentDescriptor = null
} }
@Suppress("UnusedParameter")
private suspend fun waitForHealthy(process: LlamaProcess): Boolean { private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
while (Clock.System.now().toEpochMilliseconds() < endTime) { 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 { try {
val response = httpClient.get("http://$host:$port/health").body<String>() val response = httpClient.get("http://$host:$port/health").body<String>()
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) { if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
@@ -3,6 +3,7 @@ package com.correx.infrastructure.inference.llama.cpp
import com.correx.core.inference.ChatMessage import com.correx.core.inference.ChatMessage
import com.correx.core.inference.ToolCallRequest import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.ToolDefinition import com.correx.core.inference.ToolDefinition
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -15,6 +16,12 @@ data class ChatCompletionRequest(
@SerialName("max_tokens") val maxTokens: Int, @SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String> = emptyList(), @SerialName("stop") val stopSequences: List<String> = emptyList(),
val seed: Long? = null, val seed: Long? = null,
// EncodeDefault.NEVER overrides the class-level encodeDefaults=true so an unset (null) sampling
// knob is omitted from the JSON entirely, letting the model keep its own default (rather than
// sending "top_k": null, which llama.cpp may reject or misread).
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("top_k") val topK: Int? = null,
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("min_p") val minP: Double? = null,
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("repeat_penalty") val repeatPenalty: Double? = null,
val stream: Boolean = false, val stream: Boolean = false,
val grammar: String? = null, val grammar: String? = null,
val tools: List<ToolDefinition>? = null, val tools: List<ToolDefinition>? = null,
@@ -42,22 +42,14 @@ private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
Json { Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true isLenient = true
encodeDefaults = false explicitNulls = false
encodeDefaults = true
}, },
) )
} }
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS
} }
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
},
)
}
} }
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -178,6 +170,9 @@ class LlamaCppInferenceProvider(
maxTokens = request.generationConfig.maxTokens, maxTokens = request.generationConfig.maxTokens,
stopSequences = request.generationConfig.stopSequences, stopSequences = request.generationConfig.stopSequences,
seed = request.generationConfig.seed, seed = request.generationConfig.seed,
topK = request.generationConfig.topK,
minP = request.generationConfig.minP,
repeatPenalty = request.generationConfig.repeatPenalty,
stream = false, stream = false,
grammar = grammar, grammar = grammar,
tools = tools, tools = tools,
@@ -199,7 +194,9 @@ class LlamaCppInferenceProvider(
log.debug("got response from llm: {}", response) log.debug("got response from llm: {}", response)
val message = response.choices.first().message val choice = response.choices.firstOrNull()
?: error("llama-server returned no choices in the completion response")
val message = choice.message
// Some local models emit the tool call as a JSON blob in `content` instead of the native // Some local models emit the tool call as a JSON blob in `content` instead of the native
// tool_calls array; salvage it so the orchestrator gets a real call rather than treating // tool_calls array; salvage it so the orchestrator gets a real call rather than treating
// the blob as a (failing) artifact and retry-looping to exhaustion. // the blob as a (failing) artifact and retry-looping to exhaustion.
@@ -207,7 +204,7 @@ class LlamaCppInferenceProvider(
val toolCalls = message.toolCalls.ifEmpty { salvaged } val toolCalls = message.toolCalls.ifEmpty { salvaged }
val finishReason = when { val finishReason = when {
toolCalls.isNotEmpty() -> FinishReason.ToolCall toolCalls.isNotEmpty() -> FinishReason.ToolCall
response.choices.first().finishReason.lowercase() == "length" -> FinishReason.Length choice.finishReason.lowercase() == "length" -> FinishReason.Length
else -> FinishReason.Stop else -> FinishReason.Stop
} }
@@ -0,0 +1,37 @@
package com.correx.infrastructure.inference.llama.cpp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
// Guards the EncodeDefault.NEVER behavior on the sampling knobs: with encodeDefaults=true (matching
// the provider's Json), an unset (null) top_k/min_p/repeat_penalty must be OMITTED from the body so
// the model keeps its own default; a set value must appear.
class SamplingRequestSerializationTest {
private val json = Json { encodeDefaults = true }
@Test
fun `unset sampling knobs are omitted from the request body`() {
val body = ChatCompletionRequest(
model = "m", messages = emptyList(), temperature = 0.7, topP = 1.0, maxTokens = 16,
)
val out = json.encodeToString(body)
assertFalse("top_k" in out, out)
assertFalse("min_p" in out, out)
assertFalse("repeat_penalty" in out, out)
}
@Test
fun `set sampling knobs are serialized`() {
val body = ChatCompletionRequest(
model = "m", messages = emptyList(), temperature = 0.7, topP = 1.0, maxTokens = 16,
topK = 40, minP = 0.05, repeatPenalty = 1.1,
)
val out = json.encodeToString(body)
assertTrue("\"top_k\":40" in out, out)
assertTrue("\"min_p\":0.05" in out, out)
assertTrue("\"repeat_penalty\":1.1" in out, out)
}
}
@@ -2,6 +2,7 @@ package com.correx.infrastructure.inference.openai
import com.correx.core.inference.ToolCallRequest import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.ToolDefinition import com.correx.core.inference.ToolDefinition
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -20,6 +21,11 @@ data class OpenAiChatCompletionRequest(
@SerialName("max_tokens") val maxTokens: Int, @SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String>? = null, @SerialName("stop") val stopSequences: List<String>? = null,
val seed: Long? = null, val seed: Long? = null,
// Non-standard OpenAI params accepted by local backends (vLLM, llama.cpp --api). EncodeDefault.NEVER
// omits them when unset so a strict endpoint never sees an unknown key unless the operator opts in.
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("top_k") val topK: Int? = null,
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("min_p") val minP: Double? = null,
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("repeat_penalty") val repeatPenalty: Double? = null,
val stream: Boolean = false, val stream: Boolean = false,
val tools: List<ToolDefinition>? = null, val tools: List<ToolDefinition>? = null,
) )
@@ -104,6 +104,9 @@ class OpenAiCompatInferenceProvider(
maxTokens = request.generationConfig.maxTokens, maxTokens = request.generationConfig.maxTokens,
stopSequences = request.generationConfig.stopSequences.ifEmpty { null }, stopSequences = request.generationConfig.stopSequences.ifEmpty { null },
seed = request.generationConfig.seed, seed = request.generationConfig.seed,
topK = request.generationConfig.topK,
minP = request.generationConfig.minP,
repeatPenalty = request.generationConfig.repeatPenalty,
stream = false, stream = false,
tools = tools, tools = tools,
) )
+1
View File
@@ -11,6 +11,7 @@ dependencies {
implementation(project(":core:artifacts")) implementation(project(":core:artifacts"))
implementation(project(":core:artifacts-store")) implementation(project(":core:artifacts-store"))
implementation "org.xerial:sqlite-jdbc" implementation "org.xerial:sqlite-jdbc"
implementation "org.slf4j:slf4j-api:2.0.16"
testImplementation(testFixtures(project(":testing:contracts"))) testImplementation(testFixtures(project(":testing:contracts")))
testImplementation(project(":testing:fixtures")) testImplementation(project(":testing:fixtures"))
testImplementation "org.junit.jupiter:junit-jupiter" testImplementation "org.junit.jupiter:junit-jupiter"
@@ -9,9 +9,12 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import org.slf4j.LoggerFactory
import java.util.concurrent.* import java.util.concurrent.*
import java.util.concurrent.atomic.* import java.util.concurrent.atomic.*
private val log = LoggerFactory.getLogger(InMemoryEventStore::class.java)
class InMemoryEventStore : EventStore { class InMemoryEventStore : EventStore {
private val streams = ConcurrentHashMap<SessionId, MutableList<StoredEvent>>() private val streams = ConcurrentHashMap<SessionId, MutableList<StoredEvent>>()
private val sequences = ConcurrentHashMap<SessionId, AtomicLong>() private val sequences = ConcurrentHashMap<SessionId, AtomicLong>()
@@ -33,7 +36,7 @@ class InMemoryEventStore : EventStore {
} }
stored = doAppend(event, stream) stored = doAppend(event, stream)
} }
subscriptions[event.metadata.sessionId]?.tryEmit(stored) subscriptions[event.metadata.sessionId]?.let { warnIfDropped(it.tryEmit(stored), stored) }
globalFlow.emit(stored) globalFlow.emit(stored)
return stored return stored
} }
@@ -47,7 +50,7 @@ class InMemoryEventStore : EventStore {
stored = events.map { doAppend(it, stream) } stored = events.map { doAppend(it, stream) }
} }
val flow = subscriptions[sessionId] val flow = subscriptions[sessionId]
if (flow != null) stored.forEach { flow.tryEmit(it) } if (flow != null) stored.forEach { warnIfDropped(flow.tryEmit(it), it) }
stored.forEach { globalFlow.emit(it) } stored.forEach { globalFlow.emit(it) }
return stored return stored
} }
@@ -77,6 +80,19 @@ class InMemoryEventStore : EventStore {
override fun allSessionIds(): Set<SessionId> = sequences.keys override fun allSessionIds(): Set<SessionId> = sequences.keys
/** A lagging per-session collector's buffer (extraBufferCapacity 64) is full; the event is durably
* stored but dropped from the live SharedFlow. Surface it rather than swallow the false return. */
private fun warnIfDropped(emitted: Boolean, event: StoredEvent) {
if (!emitted) {
log.warn(
"dropped live subscription emit for session {} seq {} ({}): buffer full, collector lagging",
event.metadata.sessionId.value,
event.sequence,
event.payload::class.simpleName,
)
}
}
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent { private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
val sessionSeq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) } val sessionSeq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
.incrementAndGet() .incrementAndGet()
@@ -21,10 +21,13 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.slf4j.LoggerFactory
import java.sql.Connection import java.sql.Connection
import java.sql.ResultSet import java.sql.ResultSet
import java.util.concurrent.* import java.util.concurrent.*
private val log = LoggerFactory.getLogger(SqliteEventStore::class.java)
class SqliteEventStore( class SqliteEventStore(
private val connection: Connection, private val connection: Connection,
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson), private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson),
@@ -72,25 +75,24 @@ class SqliteEventStore(
appendMutex.withLock { appendMutex.withLock {
artifactStore.flushBefore { artifactStore.flushBefore {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
stored = connection.transaction { stored = withConnection {
val existing = findByEventId(event.metadata.eventId) connection.transaction {
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } val existing = findByEventId(event.metadata.eventId)
val globalSeqVal = nextGlobalSequence() check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val sessionSeqVal = nextSessionSequence(event.metadata.sessionId) val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event)
val s = StoredEvent( StoredEvent(
metadata = event.metadata, metadata = event.metadata,
sequence = globalSeqVal, sequence = globalSeqVal,
sessionSequence = sessionSeqVal, sessionSequence = sessionSeqVal,
payload = event.payload, payload = event.payload,
) )
insert(s) }
s
} }
} }
} }
} }
val result = checkNotNull(stored) val result = checkNotNull(stored)
subscriptions[event.metadata.sessionId]?.tryEmit(result) subscriptions[event.metadata.sessionId]?.let { warnIfDropped(it.tryEmit(result), result) }
globalFlow.emit(result) globalFlow.emit(result)
return result return result
} }
@@ -102,32 +104,31 @@ class SqliteEventStore(
appendMutex.withLock { appendMutex.withLock {
artifactStore.flushBefore { artifactStore.flushBefore {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
stored = connection.transaction { stored = withConnection {
events.map { event -> connection.transaction {
val existing = findByEventId(event.metadata.eventId) events.map { event ->
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } val existing = findByEventId(event.metadata.eventId)
val globalSeqVal = nextGlobalSequence() check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val sessionSeqVal = nextSessionSequence(sessionId) val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event)
val s = StoredEvent( StoredEvent(
metadata = event.metadata, metadata = event.metadata,
sequence = globalSeqVal, sequence = globalSeqVal,
sessionSequence = sessionSeqVal, sessionSequence = sessionSeqVal,
payload = event.payload, payload = event.payload,
) )
insert(s) }
s
} }
} }
} }
} }
} }
val flow = subscriptions[sessionId] val flow = subscriptions[sessionId]
if (flow != null) stored.forEach { flow.tryEmit(it) } if (flow != null) stored.forEach { warnIfDropped(flow.tryEmit(it), it) }
stored.forEach { globalFlow.emit(it) } stored.forEach { globalFlow.emit(it) }
return stored return stored
} }
override fun read(sessionId: SessionId): List<StoredEvent> = override fun read(sessionId: SessionId): List<StoredEvent> = withConnection {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT * FROM events SELECT * FROM events
@@ -144,8 +145,9 @@ class SqliteEventStore(
} }
} }
} }
}
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = withConnection {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT * FROM events SELECT * FROM events
@@ -164,8 +166,9 @@ class SqliteEventStore(
} }
} }
} }
}
override fun lastSequence(sessionId: SessionId): Long? = override fun lastSequence(sessionId: SessionId): Long? = withConnection {
connection.prepareStatement( connection.prepareStatement(
"SELECT MAX(session_sequence) FROM events WHERE session_id = ?" "SELECT MAX(session_sequence) FROM events WHERE session_id = ?"
).use { ps -> ).use { ps ->
@@ -175,6 +178,7 @@ class SqliteEventStore(
else null else null
} }
} }
}
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) } subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) }
@@ -183,17 +187,19 @@ class SqliteEventStore(
override suspend fun lastGlobalSequence(): Long = override suspend fun lastGlobalSequence(): Long =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
connection.prepareStatement( withConnection {
"SELECT COALESCE(MAX(sequence), 0) FROM events" connection.prepareStatement(
).use { ps -> "SELECT COALESCE(MAX(sequence), 0) FROM events"
ps.executeQuery().use { rs -> ).use { ps ->
rs.next() ps.executeQuery().use { rs ->
rs.getLong(1) rs.next()
rs.getLong(1)
}
} }
} }
} }
override fun allEvents(): Sequence<StoredEvent> = override fun allEvents(): Sequence<StoredEvent> = withConnection {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT * FROM events SELECT * FROM events
@@ -208,8 +214,9 @@ class SqliteEventStore(
} }
} }
}.asSequence() }.asSequence()
}
override fun allSessionIds(): Set<SessionId> = override fun allSessionIds(): Set<SessionId> = withConnection {
mutableSetOf<SessionId>().apply { mutableSetOf<SessionId>().apply {
connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps -> connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps ->
ps.executeQuery().use { rs -> ps.executeQuery().use { rs ->
@@ -217,32 +224,47 @@ class SqliteEventStore(
} }
} }
} }
}
// ---------- helpers ---------- // ---------- helpers ----------
private fun nextGlobalSequence(): Long = /**
connection.prepareStatement( * Serializes all JDBC access to the shared [connection] on a single JVM monitor. Reads run on the
"SELECT COALESCE(MAX(sequence), 0) FROM events" * caller thread while append's transaction runs on Dispatchers.IO; the coroutine [appendMutex] only
).use { ps -> * excludes append-vs-append, so without this a read could hit the connection mid-transaction
ps.executeQuery().use { rs -> * (SQLite JDBC is not thread-safe → corrupted tx state). ponytail: store-wide lock, one session at a
rs.next() * time is fine here; shard per-connection only if read throughput ever matters.
rs.getLong(1) + 1 */
} private inline fun <T> withConnection(block: () -> T): T = synchronized(connection) { block() }
}
private fun nextSessionSequence(sessionId: SessionId): Long = /**
connection.prepareStatement( * A per-session subscription's buffer (extraBufferCapacity 64) is full and this event was dropped.
"SELECT COALESCE(MAX(session_sequence), 0) FROM events WHERE session_id = ?" * The event is durably persisted (the drop is only on the live SharedFlow), but a lagging in-process
).use { ps -> * collector — e.g. LiveArtifactRepository — silently diverges from the log. Surface it instead of
ps.setString(1, sessionId.value) * swallowing the false return. ponytail: log-only; give that collector a bounded rebuild-on-lag if
ps.executeQuery().use { rs -> * divergence ever bites in practice.
rs.next() */
rs.getLong(1) + 1 private fun warnIfDropped(emitted: Boolean, event: StoredEvent) {
} if (!emitted) {
log.warn(
"dropped live subscription emit for session {} seq {} ({}): buffer full, collector lagging",
event.metadata.sessionId.value,
event.sequence,
event.payload::class.simpleName,
)
} }
}
/**
* Inserts [event], letting SQLite assign both the global `sequence` and the per-session
* `session_sequence` as `MAX+1` subqueries evaluated inside the INSERT itself, and RETURNs the
* assigned values. SQLite serializes writers (one write transaction at a time), so the subqueries
* are atomic against other processes on the same DB — closing the CLI+server MAX+1 race that the
* old read-then-insert had (the unique index on `sequence` is the backstop). Within one appendAll
* transaction, each INSERT sees the prior rows, so a batch increments correctly.
*/
@Suppress("MagicNumber") @Suppress("MagicNumber")
private fun insert(event: StoredEvent) { private fun insertAssigningSequences(event: NewEvent): Pair<Long, Long> =
connection.prepareStatement( connection.prepareStatement(
""" """
INSERT INTO events ( INSERT INTO events (
@@ -255,21 +277,28 @@ class SqliteEventStore(
causation_id, causation_id,
correlation_id, correlation_id,
payload payload
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (
?, ?,
(SELECT COALESCE(MAX(sequence), 0) + 1 FROM events),
(SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM events WHERE session_id = ?),
?, ?, ?, ?, ?
)
RETURNING sequence, session_sequence
""".trimIndent(), """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, event.metadata.eventId.value) ps.setString(1, event.metadata.eventId.value)
ps.setString(2, event.metadata.sessionId.value) ps.setString(2, event.metadata.sessionId.value)
ps.setLong(3, event.sequence) ps.setString(3, event.metadata.sessionId.value)
ps.setLong(4, event.sessionSequence) ps.setString(4, event.metadata.timestamp.toString())
ps.setString(5, event.metadata.timestamp.toString()) ps.setInt(5, event.metadata.schemaVersion)
ps.setInt(6, event.metadata.schemaVersion) ps.setString(6, event.metadata.causationId?.value)
ps.setString(7, event.metadata.causationId?.value) ps.setString(7, event.metadata.correlationId?.value)
ps.setString(8, event.metadata.correlationId?.value) ps.setString(8, jsonSerializer.serialize(event.payload))
ps.setString(9, jsonSerializer.serialize(event.payload)) ps.executeQuery().use { rs ->
ps.executeUpdate() rs.next()
rs.getLong("sequence") to rs.getLong("session_sequence")
}
} }
}
private fun findByEventId(eventId: EventId): StoredEvent? = private fun findByEventId(eventId: EventId): StoredEvent? =
connection.prepareStatement( connection.prepareStatement(
@@ -12,6 +12,7 @@ import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -55,11 +56,27 @@ class LiveArtifactRepository(
private fun ensureSubscribed(sessionId: SessionId) { private fun ensureSubscribed(sessionId: SessionId) {
subscriptions.computeIfAbsent(sessionId) { subscriptions.computeIfAbsent(sessionId) {
scope.launch { // Rebuild from persisted history so a cold (post-restart) or first mid-session consumer
// sees prior artifact state immediately, not empty (invariant #1). The reducer's phase
// transitions are strictly ordered, so application must stay single-threaded and in
// sequence order — hence the gate below rather than a concurrent replay.
val rebuildWatermark = CompletableDeferred<Long>()
val job = scope.launch {
// Start collecting immediately so events appended during the rebuild are captured (flow
// back-pressure buffers them), but hold each until the rebuild's high-water mark is
// known, then drop anything already folded by the rebuild (sequence <= watermark).
// Live delivery is in append order, so post-watermark events fold in order too.
eventStore.subscribe(sessionId).collect { event -> eventStore.subscribe(sessionId).collect { event ->
processEvent(sessionId, event) if (event.sequence > rebuildWatermark.await()) processEvent(sessionId, event)
} }
} }
var watermark = 0L
eventStore.read(sessionId).forEach { event ->
watermark = maxOf(watermark, event.sequence)
processEvent(sessionId, event)
}
rebuildWatermark.complete(watermark)
job
} }
} }
@@ -0,0 +1,41 @@
package com.correx.infrastructure.persistence.artifact
import com.correx.core.artifacts.DefaultArtifactReducer
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.types.ArtifactId
import com.correx.core.events.types.ArtifactLifecyclePhase
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.testing.fixtures.EventFixtures
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class LiveArtifactRepositoryTest {
@Test
fun `cold access rebuilds artifact state from the persisted log, in order`(): Unit = runBlocking {
val store = InMemoryEventStore()
val session = SessionId("s1")
val artifact = ArtifactId("a1")
val stage = StageId("stage1")
// Persist the full lifecycle BEFORE any repository/consumer exists — the pre-fix repo
// subscribed with replay=0 and never read() this history, so a cold consumer saw null.
store.append(EventFixtures.newEvent(EventId("e1"), session, ArtifactCreatedEvent(artifact, session, stage, 1)))
store.append(EventFixtures.newEvent(EventId("e2"), session, ArtifactValidatingEvent(artifact, session, stage)))
store.append(EventFixtures.newEvent(EventId("e3"), session, ArtifactValidatedEvent(artifact, session, stage)))
// Fresh repository = cold consumer (mimics a restart / first mid-session access).
val repo = LiveArtifactRepository(store, DefaultArtifactReducer())
// Rebuilt, and folded in sequence order — VALIDATED only reachable via CREATED→VALIDATING→VALIDATED.
assertEquals(ArtifactLifecyclePhase.VALIDATED, repo.getById(session, artifact)?.phase)
assertNull(repo.getById(session, ArtifactId("does-not-exist")))
}
}
@@ -8,8 +8,10 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.BufferedReader
import java.nio.file.Paths import java.nio.file.Paths
import kotlin.io.path.createDirectories import kotlin.io.path.createDirectories
import kotlin.io.path.exists
/** /**
* Adapter to TurboVec sidecar process for cross-session vector-based memory. * Adapter to TurboVec sidecar process for cross-session vector-based memory.
@@ -24,6 +26,9 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex() private val mutex = Mutex()
private var process: Process? = null private var process: Process? = null
// One reader owned by the store for the process's lifetime. A fresh bufferedReader() per request
// would strand read-ahead bytes in the discarded reader's buffer, desyncing the stream.
private var reader: BufferedReader? = null
private val metadata = java.util.concurrent.ConcurrentHashMap<String, L3MemoryEntry>() private val metadata = java.util.concurrent.ConcurrentHashMap<String, L3MemoryEntry>()
override suspend fun rehydrateMetadata(entries: List<L3MemoryEntry>) { override suspend fun rehydrateMetadata(entries: List<L3MemoryEntry>) {
@@ -72,10 +77,12 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
override suspend fun close() { override suspend fun close() {
mutex.withLock { mutex.withLock {
process?.let { p -> process?.let { p ->
val shutdownRequest = SidecarRequest(op = "shutdown") // Persist vectors before exit so the next process can `load` them (only save() puts
runCatching { sendRequest(shutdownRequest) } // the quantized index on disk — init alone loses everything between runs).
config.persistPath?.let { runCatching { sendRequestLocked(SidecarRequest(op = "save", path = it.toString())) } }
runCatching { sendRequestLocked(SidecarRequest(op = "shutdown")) }
try { try {
withTimeout(2000) { withTimeout(SHUTDOWN_TIMEOUT_MS) {
p.waitFor() p.waitFor()
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -85,27 +92,41 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
throw e throw e
} }
} }
reader = null
process = null
} }
} }
} }
private suspend fun sendRequest(request: SidecarRequest): SidecarResponse { private suspend fun sendRequest(request: SidecarRequest): SidecarResponse =
mutex.withLock { mutex.withLock { sendRequestLocked(request) }
ensureProcessStarted()
val process = process ?: throw RuntimeException("Process failed to start")
val requestLine = json.encodeToString(SidecarRequest.serializer(), request) + "\n" /** Caller must hold [mutex]. */
process.outputStream.write(requestLine.toByteArray()) private fun sendRequestLocked(request: SidecarRequest): SidecarResponse {
process.outputStream.flush() ensureProcessStarted()
val process = process ?: throw RuntimeException("Process failed to start")
val reader = reader ?: throw RuntimeException("Sidecar reader not initialized")
val responseLine = runCatching { val requestLine = json.encodeToString(SidecarRequest.serializer(), request) + "\n"
withTimeout(config.requestTimeoutMs) { process.outputStream.write(requestLine.toByteArray())
process.inputStream.bufferedReader().readLine() process.outputStream.flush()
}
}.getOrNull() ?: throw RuntimeException("No response from sidecar")
return json.decodeFromString(SidecarResponse.serializer(), responseLine) // A blocking readLine can't be cancelled by a coroutine timeout, so a slow/late response
// would land in the NEXT request's read and desync the stream permanently. We don't wrap it
// in withTimeout at all: instead the null/failure path below tears the process down so the
// next request restarts from a clean, empty stream rather than reading a stale line.
val responseLine = runCatching { reader.readLine() }.getOrNull()
if (responseLine == null) {
killProcess()
throw RuntimeException("No response from sidecar (process reset)")
} }
return json.decodeFromString(SidecarResponse.serializer(), responseLine)
}
private fun killProcess() {
runCatching { process?.destroyForcibly() }
reader = null
process = null
} }
private fun ensureProcessStarted() { private fun ensureProcessStarted() {
@@ -122,19 +143,24 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
val started = pb.start() val started = pb.start()
process = started process = started
reader = started.inputStream.bufferedReader()
// The sidecar rejects add/search until the index is created. Send init once on startup; // Reload a persisted index if one exists, else create a fresh one. Either way the sidecar
// without this every store/query fails with "Index not initialized". // rejects add/search until initialized; without this every store/query fails "not initialized".
val initLine = json.encodeToString( val bootstrap = config.persistPath
SidecarRequest.serializer(), ?.takeIf { it.exists() }
SidecarRequest(op = "init", dim = config.dim, bitWidth = config.bitWidth) ?.let { SidecarRequest(op = "load", path = it.toString()) }
) + "\n" ?: SidecarRequest(op = "init", dim = config.dim, bitWidth = config.bitWidth)
started.outputStream.write(initLine.toByteArray())
started.outputStream.write((json.encodeToString(SidecarRequest.serializer(), bootstrap) + "\n").toByteArray())
started.outputStream.flush() started.outputStream.flush()
val response = started.inputStream.bufferedReader().readLine() val response = reader!!.readLine()?.let { json.decodeFromString(SidecarResponse.serializer(), it) }
?.let { json.decodeFromString(SidecarResponse.serializer(), it) }
if (response?.ok != true) { if (response?.ok != true) {
throw RuntimeException("TurboVec init failed: ${response?.error ?: "no response"}") throw RuntimeException("TurboVec ${bootstrap.op} failed: ${response?.error ?: "no response"}")
} }
} }
private companion object {
const val SHUTDOWN_TIMEOUT_MS = 2000L
}
} }
@@ -17,6 +17,7 @@ import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.inference.CapabilityScore import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.Embedder import com.correx.core.inference.Embedder
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability import com.correx.core.inference.ModelCapability
@@ -210,10 +211,13 @@ object InfrastructureModule {
), ),
) )
fun createWorkflowLoader(extraKinds: List<ArtifactKind> = emptyList()): WorkflowLoader { fun createWorkflowLoader(
extraKinds: List<ArtifactKind> = emptyList(),
samplingDefaults: GenerationConfig = GenerationConfig(temperature = 0.7, topP = 1.0, maxTokens = 0),
): WorkflowLoader {
val registry = DefaultArtifactKindRegistry() val registry = DefaultArtifactKindRegistry()
extraKinds.forEach { registry.register(it) } extraKinds.forEach { registry.register(it) }
return TomlWorkflowLoader(registry) return TomlWorkflowLoader(registry, samplingDefaults)
} }
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader() fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
@@ -24,7 +24,6 @@ import java.nio.file.Files
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.nio.file.StandardOpenOption
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
class FileEditTool( class FileEditTool(
@@ -242,7 +241,9 @@ class FileEditTool(
private fun append(path: Path, request: ToolRequest): ToolResult { private fun append(path: Path, request: ToolRequest): ToolResult {
val content = request.parameters["content"] as String val content = request.parameters["content"] as String
Files.writeString(path, content, StandardOpenOption.APPEND) // Read-modify-atomic-swap instead of an in-place APPEND: a crash mid-append leaves the file
// whole (old content), never truncated. Same crash-safety FileWriteTool gets.
AtomicFileWriter.write(path, (Files.readString(path) + content).toByteArray(Charsets.UTF_8))
return ToolResult.Success( return ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = "Content appended to ${path.toAbsolutePath()}", output = "Content appended to ${path.toAbsolutePath()}",
@@ -258,7 +259,7 @@ class FileEditTool(
return when (occurrences) { return when (occurrences) {
1 -> { 1 -> {
val newContent = currentContent.replace(target, replacement) val newContent = currentContent.replace(target, replacement)
Files.writeString(path, newContent) AtomicFileWriter.write(path, newContent.toByteArray(Charsets.UTF_8))
ToolResult.Success( ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = "Target replaced in $pathString", output = "Target replaced in $pathString",
@@ -338,7 +339,18 @@ class FileEditTool(
return try { return try {
process.outputStream.use { it.write(patchContent.toByteArray()) } process.outputStream.use { it.write(patchContent.toByteArray()) }
val output = process.inputStream.bufferedReader().use { it.readText() } val output = process.inputStream.bufferedReader().use { it.readText() }
val exitCode = process.waitFor() if (!process.waitFor(PATCH_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)) {
// A hung `patch` (e.g. prompting for a reject-file name it never gets) would block the
// stage forever. Kill it and let the model fall back to replace/file_write.
process.destroyForcibly()
return ToolResult.Failure(
invocationId = request.invocationId,
reason = "Patch timed out after ${PATCH_TIMEOUT_SECONDS}s. " +
"Consider using operation 'replace' (exact string swap) or file_write instead.",
recoverable = true,
)
}
val exitCode = process.exitValue()
if (exitCode == 0) { if (exitCode == 0) {
ToolResult.Success( ToolResult.Success(
@@ -389,5 +401,6 @@ class FileEditTool(
const val MIN_ANCHOR = 3 const val MIN_ANCHOR = 3
const val MAX_CMP = 400 const val MAX_CMP = 400
const val MIN_SIMILARITY = 0.5 const val MIN_SIMILARITY = 0.5
const val PATCH_TIMEOUT_SECONDS = 30L
} }
} }
@@ -141,7 +141,10 @@ class FileReadTool(
} }
private fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching { private fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching {
val lines = Files.readAllLines(path) // Read the file once. Lines and the (whole-read) content hash both derive from these bytes,
// so a full read no longer hits the disk twice (readAllLines + readAllBytes for the hash).
val bytes = Files.readAllBytes(path)
val lines = bytes.inputStream().bufferedReader().readLines()
val start = ((startLine ?: 1) - 1).coerceAtLeast(0) val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size) val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val selected = lines.subList(start, end) val selected = lines.subList(start, end)
@@ -166,7 +169,7 @@ class FileReadTool(
// against what the agent actually saw. A partial or truncated view establishes no baseline — // against what the agent actually saw. A partial or truncated view establishes no baseline —
// the agent must read the relevant range before editing. // the agent must read the relevant range before editing.
val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped
val metadata = if (sawWholeFile) mapOf("contentHash" to sha256(path)) else emptyMap() val metadata = if (sawWholeFile) mapOf("contentHash" to sha256(bytes)) else emptyMap()
ToolResult.Success( ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = content + note, output = content + note,
@@ -180,8 +183,8 @@ class FileReadTool(
) )
} }
private fun sha256(path: Path): String = private fun sha256(bytes: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path)) java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) } .joinToString("") { "%02x".format(it) }
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching { private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
@@ -111,7 +111,15 @@ class ListDirTool(
ToolResult.Failure(request.invocationId, msg, recoverable = true) ToolResult.Failure(request.invocationId, msg, recoverable = true)
} }
!Files.isDirectory(root) -> !Files.isDirectory(root) ->
ToolResult.Failure(request.invocationId, "Not a directory: $pathString", recoverable = true) // Explicit file-vs-dir mismatch: a bare "Not a directory" left models re-issuing the
// identical list_dir until cancel (session a60c54b0). Name the remedy so the next
// action is unambiguous — file_read it, or delete/convert it first.
ToolResult.Failure(
request.invocationId,
"Not a directory: $pathString is a FILE, not a directory. " +
"Use file_read to read it, or delete/convert it before listing it as a directory.",
recoverable = true,
)
else -> runCatching { walk(root, recursive, request) }.getOrElse { else -> runCatching { walk(root, recursive, request) }.getOrElse {
ToolResult.Failure(request.invocationId, "Failed to list dir: ${it.message}", recoverable = false) ToolResult.Failure(request.invocationId, "Failed to list dir: ${it.message}", recoverable = false)
} }
@@ -0,0 +1,246 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ParamRole
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.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.FileSystems
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import kotlin.io.path.name
import kotlin.io.path.readText
// Shared read-only search cap. The list_dir incident (a 400-entry dump burying the answer) is the
// cautionary tale: a search that floods the context is worse than useless to a small model, so cap
// aggressively and tell it to narrow rather than returning everything.
private const val MAX_RESULTS = 200
// Skip files larger than this when grepping — a multi-MB minified bundle or lockfile is never what a
// 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
/**
* `.gitignore`-aware path search by glob pattern — the affordance a model should reach for to answer
* "does `frontend/` exist?" / "where are the `.tsx` files?" instead of `shell find`, which is unjailed
* and dumps ignored trees. Read-only (Tier T1, FILE_READ + DIRECTORY_LIST — like [ListDirTool], a glob
* is a survey, so anti-hallucination read gates exempt it: globbing a not-yet-created path truthfully
* reports "no matches"). Shares [FileReadTool]'s path jail + workspace anchor. Results are relative
* paths, sorted, capped at [MAX_RESULTS].
*/
class GlobTool(
private val allowedPaths: Set<Path> = emptySet(),
private val workingDir: Path? = null,
) : Tool, ToolExecutor {
override val name: String = "glob"
override val description: String =
"Find files by glob pattern (e.g. '**/*.tsx', 'src/**/use*.ts'), skipping .gitignore'd paths. " +
"Prefer this over shell 'find' to locate files. Returns matching relative paths."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("pattern") {
put("type", "string")
put("description", "Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.")
}
putJsonObject("path") {
put("type", "string")
put("description", "Relative directory to search under. Omit or '.' for the workspace root.")
}
}
put("required", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("pattern")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> =
setOf(ToolCapability.FILE_READ, ToolCapability.DIRECTORY_LIST)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult =
validateSearchPath(request, allowedPaths, workingDir)
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
(validateRequest(request) as? ValidationResult.Invalid)?.let {
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)
val base = resolveSearchPath(request, workingDir)
if (!Files.isDirectory(base)) {
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) }
val hits = ArrayList<String>()
var truncated = false
walkGitignored(base) { rel, _ ->
if (matcher.matches(Paths.get(rel))) {
hits += rel
if (hits.size >= MAX_RESULTS) truncated = true
}
!truncated
}
hits.sort()
ToolResult.Success(request.invocationId, output = renderResults("glob '$pattern'", hits, truncated))
}
}
/**
* `.gitignore`-aware content search — find lines matching a regex across the workspace, so a model can
* locate a symbol/string ("where's `web-ui-design-spec`?") in one call instead of `shell grep -r`.
* Read-only (Tier T1, FILE_READ). Shares the reader's jail + anchor. Returns `path:line: text`, capped
* at [MAX_RESULTS] total matches; an optional `glob` narrows which files are scanned.
*/
class GrepTool(
private val allowedPaths: Set<Path> = emptySet(),
private val workingDir: Path? = null,
) : Tool, ToolExecutor {
override val name: String = "grep"
override val description: String =
"Search file contents for a regex across the workspace (skips .gitignore'd paths). Prefer this " +
"over shell 'grep -r' to find a symbol or string. Returns 'path:line: matched text'."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("pattern") {
put("type", "string")
put("description", "Regular expression to search for in file contents.")
}
putJsonObject("path") {
put("type", "string")
put("description", "Relative directory to search under. Omit or '.' for the workspace root.")
}
putJsonObject("glob") {
put("type", "string")
put("description", "Optional glob to limit which files are scanned, e.g. '**/*.kt'.")
}
}
put("required", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("pattern")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult =
validateSearchPath(request, allowedPaths, workingDir)
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
(validateRequest(request) as? ValidationResult.Invalid)?.let {
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)
val regex = runCatching { Regex(patternStr) }
.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)
}
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) }
}
val hits = ArrayList<String>()
var truncated = false
walkGitignored(base) { rel, file ->
if (fileFilter == null || fileFilter.matches(Paths.get(rel))) {
if (Files.size(file) <= GREP_MAX_FILE_BYTES) {
val text = runCatching { file.readText() }.getOrNull()
// Skip apparent binaries (NUL byte) — a symbol search never wants them.
if (text != null && !text.contains('\u0000')) {
text.lineSequence().forEachIndexed { idx, line ->
if (!truncated && regex.containsMatchIn(line)) {
hits += "$rel:${idx + 1}: ${line.trim().take(200)}"
if (hits.size >= MAX_RESULTS) truncated = true
}
}
}
}
}
!truncated
}
ToolResult.Success(request.invocationId, output = renderResults("grep '$patternStr'", hits, truncated))
}
}
// ---------- shared helpers ----------
private fun resolveSearchPath(request: ToolRequest, workingDir: Path?): Path {
val pathString = (request.parameters["path"] as? String)?.takeIf { it.isNotBlank() } ?: "."
val raw = Paths.get(pathString)
return when {
raw.isAbsolute -> raw.normalize()
workingDir != null -> workingDir.resolve(raw).normalize()
else -> raw.toAbsolutePath().normalize()
}
}
private fun validateSearchPath(request: ToolRequest, allowedPaths: Set<Path>, workingDir: Path?): ValidationResult =
runCatching {
val path = resolveSearchPath(request, workingDir)
val effectiveRoots = allowedPaths + request.grantedPaths.map { Paths.get(it) }
if (!PathJail.isContained(path, effectiveRoots)) {
ValidationResult.Invalid("Path '${request.parameters["path"] ?: "."}' is not in the allowed list")
} else {
ValidationResult.Valid
}
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occurred")
}
}
/**
* Walk [base] gitignore-pruned (reusing [GitignoreMatcher] — same rules as list_dir), invoking [onFile]
* for every non-ignored regular file with its base-relative path and absolute [Path]. [onFile] returns
* false to stop the walk early (used to terminate once a result cap is hit). `.git` is always skipped.
*/
private fun walkGitignored(base: Path, onFile: (rel: String, file: Path) -> Boolean) {
val matcher = GitignoreMatcher(base, base)
Files.walkFileTree(base, object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
if (dir != base && dir.name == ".git") return FileVisitResult.SKIP_SUBTREE
if (dir != base && matcher.ignored(dir, isDir = true)) return FileVisitResult.SKIP_SUBTREE
matcher.loadFrom(dir)
return FileVisitResult.CONTINUE
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
if (attrs.isDirectory || matcher.ignored(file, isDir = false)) return FileVisitResult.CONTINUE
val rel = base.relativize(file).toString().replace('\\', '/')
return if (onFile(rel, file)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}
override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
})
}
private fun renderResults(query: String, hits: List<String>, truncated: Boolean): String = buildString {
appendLine("<search>$query</search>")
appendLine("<results>")
appendLine(hits.joinToString("\n").ifEmpty { "(no matches)" })
if (truncated) appendLine("truncated at $MAX_RESULTS matches; narrow the pattern or scope with 'path'/'glob'")
append("(${hits.size} ${if (hits.size == 1) "match" else "matches"})")
appendLine()
append("</results>")
}
@@ -58,6 +58,19 @@ class ListDirToolTest {
assertFalse(out.contains("deep"), out) assertFalse(out.contains("deep"), out)
} }
@Test
fun `listing a file gives a recoverable file-vs-dir hint, not a bare error`(): Unit = runBlocking {
// Regression (session a60c54b0): list_dir on a plain file returned a bare "Not a directory"
// and models re-issued the identical call until cancel. The failure must name the remedy.
val root = Files.createTempDirectory("listdir")
Files.writeString(root.resolve("pages"), "i am a file")
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
val result = tool.execute(request(path = "pages")) as ToolResult.Failure
assertTrue(result.recoverable, "file-vs-dir mismatch is recoverable")
assertTrue(result.reason.contains("FILE"), result.reason)
assertTrue(result.reason.contains("file_read"), result.reason)
}
@Test @Test
fun `non-recursive lists only immediate children`(): Unit = runBlocking { fun `non-recursive lists only immediate children`(): Unit = runBlocking {
val root = Files.createTempDirectory("listdir") val root = Files.createTempDirectory("listdir")
@@ -0,0 +1,94 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.events.ToolRequest
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.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.*
class WorkspaceSearchToolsTest {
private fun request(toolName: String, params: Map<String, Any>) = ToolRequest(
invocationId = ToolInvocationId(UUID.randomUUID().toString()),
sessionId = SessionId(UUID.randomUUID().toString()),
stageId = StageId(UUID.randomUUID().toString()),
toolName = toolName,
parameters = params,
)
private fun fixture(): Path {
val root = Files.createTempDirectory("search")
Files.writeString(root.resolve(".gitignore"), "node_modules\ndist\n")
Files.createDirectories(root.resolve("src/hooks"))
Files.writeString(root.resolve("src/hooks/useAuth.ts"), "export const useAuth = () => {}\n")
Files.writeString(root.resolve("src/main.ts"), "import { useAuth } from './hooks/useAuth'\n")
Files.writeString(root.resolve("README.md"), "# web-ui-design-spec lives here\n")
Files.createDirectories(root.resolve("node_modules/pkg"))
Files.writeString(root.resolve("node_modules/pkg/useAuth.ts"), "junk useAuth\n")
Files.createDirectories(root.resolve("dist"))
Files.writeString(root.resolve("dist/bundle.js"), "useAuth\n")
return root
}
@Test
fun `glob finds matching paths and prunes gitignored trees`(): Unit = runBlocking {
val root = fixture()
val tool = GlobTool(allowedPaths = setOf(root), workingDir = root)
val out = (tool.execute(request("glob", mapOf("pattern" to "**/*.ts"))) 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 subtree never matched
}
@Test
fun `glob with a leading path segment scopes the search`(): Unit = runBlocking {
val root = fixture()
val tool = GlobTool(allowedPaths = setOf(root), workingDir = root)
val out = (tool.execute(request("glob", mapOf("pattern" to "src/**/use*.ts"))) as ToolResult.Success).output
assertTrue(out.contains("src/hooks/useAuth.ts"), out)
assertFalse(out.contains("main.ts"), out)
}
@Test
fun `grep finds a literal across files, skipping gitignored ones`(): Unit = runBlocking {
val root = fixture()
val tool = GrepTool(allowedPaths = setOf(root), workingDir = root)
val out = (tool.execute(request("grep", mapOf("pattern" to "web-ui-design-spec"))) as ToolResult.Success).output
assertTrue(out.contains("README.md:1:"), out)
}
@Test
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
assertTrue(out.contains("src/hooks/useAuth.ts:"), out)
assertTrue(out.contains("src/main.ts:"), out)
assertFalse(out.contains("node_modules"), out) // gitignored
assertFalse(out.contains("bundle.js"), out) // not matched by *.ts glob (and gitignored)
}
@Test
fun `grep rejects an invalid regex recoverably`(): Unit = runBlocking {
val root = fixture()
val tool = GrepTool(allowedPaths = setOf(root), workingDir = root)
val result = tool.execute(request("grep", mapOf("pattern" to "[unterminated")))
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
}
@Test
fun `search outside the jail is rejected`(): Unit = runBlocking {
val root = fixture()
val tool = GlobTool(allowedPaths = setOf(root), workingDir = root)
val result = tool.execute(request("glob", mapOf("pattern" to "*", "path" to "/etc")))
assertTrue(result is ToolResult.Failure, result.toString())
}
}
@@ -119,7 +119,12 @@ class SandboxedToolExecutor(
for (original in affectedPaths) { for (original in affectedPaths) {
// A1: skip files that don't yet exist (new-file tools have nothing to back up) // A1: skip files that don't yet exist (new-file tools have nothing to back up)
if (!Files.exists(original)) continue if (!Files.exists(original)) continue
val backupPath = workingDir.resolve("${original.fileName}.bak") // Name the backup by the full source path, not just the basename — two affected paths
// sharing a filename in different dirs would otherwise overwrite each other's backup and
// restore the wrong content on failure. Hash keeps the name filesystem-safe and unique.
val stem = original.fileName.toString()
val hash = Integer.toHexString(original.toAbsolutePath().normalize().toString().hashCode())
val backupPath = workingDir.resolve("$stem.$hash.bak")
Files.copy(original, backupPath, StandardCopyOption.REPLACE_EXISTING) Files.copy(original, backupPath, StandardCopyOption.REPLACE_EXISTING)
put(original, backupPath) put(original, backupPath)
} }
@@ -5,6 +5,8 @@ import com.correx.infrastructure.tools.filesystem.FileDeleteTool
import com.correx.infrastructure.tools.filesystem.FileEditTool import com.correx.infrastructure.tools.filesystem.FileEditTool
import com.correx.infrastructure.tools.filesystem.FileReadTool import com.correx.infrastructure.tools.filesystem.FileReadTool
import com.correx.infrastructure.tools.filesystem.FileWriteTool import com.correx.infrastructure.tools.filesystem.FileWriteTool
import com.correx.infrastructure.tools.filesystem.GlobTool
import com.correx.infrastructure.tools.filesystem.GrepTool
import com.correx.infrastructure.tools.filesystem.ListDirTool import com.correx.infrastructure.tools.filesystem.ListDirTool
import com.correx.infrastructure.tools.shell.ShellTool import com.correx.infrastructure.tools.shell.ShellTool
import com.correx.infrastructure.tools.web.WebFetchTool import com.correx.infrastructure.tools.web.WebFetchTool
@@ -82,6 +84,10 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
workingDir = fileRead.workingDir, workingDir = fileRead.workingDir,
), ),
) )
// glob (find by pattern) + grep (search contents) — read-only search, same jail/anchor/toggle;
// the affordance weak models need instead of shell find/grep -r.
add(GlobTool(allowedPaths = fileRead.allowedPaths, workingDir = fileRead.workingDir))
add(GrepTool(allowedPaths = fileRead.allowedPaths, workingDir = fileRead.workingDir))
} }
if (fileWrite.enabled) { if (fileWrite.enabled) {
add( add(
@@ -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,
)
}
}
@@ -16,10 +16,9 @@ import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult import com.correx.core.tools.contract.ValidationResult
import com.correx.core.tools.process.ChildProcess import com.correx.core.tools.process.ChildProcess
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -28,6 +27,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import java.nio.file.Path import java.nio.file.Path
import java.util.concurrent.TimeUnit
class ShellTool( class ShellTool(
private val allowedExecutables: Set<String> = emptySet(), private val allowedExecutables: Set<String> = emptySet(),
@@ -78,8 +78,15 @@ class ShellTool(
// is not enough — an auto-approve loop waves these straight through. // is not enough — an auto-approve loop waves these straight through.
deniedReason(parsed.argv) != null -> deniedReason(parsed.argv) != null ->
ValidationResult.Invalid(deniedReason(parsed.argv)!!) ValidationResult.Invalid(deniedReason(parsed.argv)!!)
allowedExecutables.isNotEmpty() && parsed.argv[0] !in allowedExecutables -> // Check EVERY command position, not just argv[0]. An operator form runs via `sh -c`
ValidationResult.Invalid("Executable '${parsed.argv[0]}' is not in the allowed list.") // (below), so `["ls", "&&", "rm", "-rf", "/"]` would slip past an argv[0]-only check
// with allowlist {ls} and then execute `rm`. Shell builtins (cd, export…) are allowed
// implicitly — they navigate, they don't exec an external program.
allowedExecutables.isNotEmpty() ->
commandPositions(parsed.argv)
.firstOrNull { it !in SHELL_BUILTINS && it !in allowedExecutables }
?.let { ValidationResult.Invalid("Executable '$it' is not in the allowed list.") }
?: ValidationResult.Valid
else -> ValidationResult.Valid else -> ValidationResult.Valid
} }
} }
@@ -140,6 +147,19 @@ class ShellTool(
private fun looksLikeShellCommand(argv: List<String>): Boolean = private fun looksLikeShellCommand(argv: List<String>): Boolean =
argv[0] in SHELL_BUILTINS || argv.any { it in SHELL_OPERATORS } argv[0] in SHELL_BUILTINS || argv.any { it in SHELL_OPERATORS }
// The tokens that land in a command position once the argv is joined and run via `sh -c`:
// argv[0], plus the token right after each command-separator (&&, ||, |, ;, &). Redirect
// operators (>, >>, <, 2>…) are followed by a filename, not a command, so they open no new
// command position. For a plain (non-operator) argv this is just [argv[0]].
private fun commandPositions(argv: List<String>): List<String> = buildList {
var expectCommand = true
for (tok in argv) when {
tok in COMMAND_SEPARATORS -> expectCommand = true
tok in SHELL_OPERATORS -> expectCommand = false // redirect target follows, not a command
expectCommand -> { add(tok); expectCommand = false }
}
}
// argv[0] is the program name; embedded quotes/commas/whitespace mean the array collapsed into // argv[0] is the program name; embedded quotes/commas/whitespace mean the array collapsed into
// one string element and there is no real executable to run. // one string element and there is no real executable to run.
private fun checkExecutable(argv: List<String>): ArgvParse = private fun checkExecutable(argv: List<String>): ArgvParse =
@@ -175,20 +195,12 @@ class ShellTool(
runCatching { runCatching {
runCmd(request, process) runCmd(request, process)
}.getOrElse { }.getOrElse {
process.destroyForcibly() killTree(process)
if (it is TimeoutCancellationException) { ToolResult.Failure(
ToolResult.Failure( invocationId = request.invocationId,
invocationId = request.invocationId, reason = it.message ?: "Unknown error occurred during execution",
reason = "Process timed out after ${timeoutMs}ms", recoverable = false,
recoverable = false, )
)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred during execution",
recoverable = false,
)
}
} }
} ?: ToolResult.Failure( } ?: ToolResult.Failure(
invocationId = request.invocationId, invocationId = request.invocationId,
@@ -204,6 +216,9 @@ class ShellTool(
const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings." const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings."
val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit") val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit")
val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1") val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1")
// Operators that chain a new command (its following token is a command position); the rest
// of SHELL_OPERATORS are redirects whose following token is a filename.
val COMMAND_SEPARATORS = setOf("&&", "||", "|", ";", "&")
// Runners that download and execute an arbitrary remote package in one shot. // Runners that download and execute an arbitrary remote package in one shot.
val REMOTE_EXEC_RUNNERS = setOf("npx", "bunx", "pnpx") val REMOTE_EXEC_RUNNERS = setOf("npx", "bunx", "pnpx")
} }
@@ -231,11 +246,32 @@ class ShellTool(
.joinToString("\n") .joinToString("\n")
} }
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) { // Kill the whole process tree. destroyForcibly() signals only the direct child, so an `sh -c`
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } } // command's grandchildren (the actual npm/tsc/…) would survive as orphans; descendants() reaches
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } } // them. Snapshot descendants BEFORE destroying the parent — reparenting can hide them afterwards.
private fun killTree(process: Process) {
process.toHandle().descendants().forEach { it.destroyForcibly() }
process.destroyForcibly()
}
val exitCode = process.waitFor() private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = coroutineScope {
val stdoutDeferred = async(Dispatchers.IO) { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async(Dispatchers.IO) { process.errorStream.bufferedReader().use { it.readText() } }
// waitFor(timeout) enforces the deadline on the clock — the plain blocking waitFor() is not
// coroutine-cancellable, so a withTimeout around it can't actually interrupt a hung process.
val finished = withContext(Dispatchers.IO) { process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) }
if (!finished) {
killTree(process)
stdoutDeferred.cancel()
stderrDeferred.cancel()
return@coroutineScope ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms",
recoverable = false,
)
}
val exitCode = process.exitValue()
val stdout = stdoutDeferred.await() val stdout = stdoutDeferred.await()
val stderr = stderrDeferred.await() val stderr = stderrDeferred.await()
@@ -43,6 +43,13 @@ class WebFetchTool(
private val maxBytes: Long = DEFAULT_MAX_BYTES, private val maxBytes: Long = DEFAULT_MAX_BYTES,
) : Tool, ToolExecutor { ) : Tool, ToolExecutor {
// Do NOT follow redirects. Plane-2 egress (NetworkHostRule) validates the REQUESTED host before
// this tool runs; ktor's default redirect-following would then let a server 302 the fetch to an
// internal host that was never validated (SSRF). With redirects off, a 3xx never opens that
// connection — the model must re-issue web_fetch for the new URL, which re-triggers egress
// validation. Derived client shares the injected engine (no separate resource to close).
private val client: HttpClient = httpClient.config { followRedirects = false }
override val name: String = "web_fetch" override val name: String = "web_fetch"
override val description: String = "Fetch a URL and return its main content as clean markdown." override val description: String = "Fetch a URL and return its main content as clean markdown."
override val tier: Tier = Tier.T2 override val tier: Tier = Tier.T2
@@ -77,10 +84,19 @@ class WebFetchTool(
} }
private suspend fun fetch(invocationId: ToolInvocationId, url: String): ToolResult = private suspend fun fetch(invocationId: ToolInvocationId, url: String): ToolResult =
httpClient.prepareGet(url).execute { response -> client.prepareGet(url).execute { response ->
val contentType = response.headers[HttpHeaders.ContentType] val contentType = response.headers[HttpHeaders.ContentType]
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
when { when {
response.status.value in 300..399 ->
fail(
invocationId,
"URL redirected (HTTP ${response.status.value}) to " +
"${response.headers[HttpHeaders.Location] ?: "an unspecified location"}. Redirects are " +
"not followed automatically — re-issue web_fetch with that URL so egress is validated " +
"for its host.",
recoverable = true,
)
!response.status.isSuccess() -> !response.status.isSuccess() ->
fail(invocationId, "HTTP ${response.status.value} for $url", recoverable = true) fail(invocationId, "HTTP ${response.status.value} for $url", recoverable = true)
declaredLength != null && declaredLength > maxBytes -> declaredLength != null && declaredLength > maxBytes ->
@@ -123,13 +139,13 @@ class WebFetchTool(
/** Reads the channel up to [maxBytes]; returns null if the body exceeds the cap. */ /** Reads the channel up to [maxBytes]; returns null if the body exceeds the cap. */
private suspend fun readBounded(channel: io.ktor.utils.io.ByteReadChannel): ByteArray? { private suspend fun readBounded(channel: io.ktor.utils.io.ByteReadChannel): ByteArray? {
val out = ArrayList<Byte>() val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(READ_CHUNK) val buffer = ByteArray(READ_CHUNK)
while (true) { while (true) {
val read = channel.readAvailable(buffer, 0, buffer.size) val read = channel.readAvailable(buffer, 0, buffer.size)
if (read == -1) break if (read == -1) break
if (out.size + read > maxBytes) return null if (out.size() + read > maxBytes) return null
for (i in 0 until read) out.add(buffer[i]) out.write(buffer, 0, read)
} }
return out.toByteArray() return out.toByteArray()
} }
@@ -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())
}
}
@@ -46,6 +46,32 @@ class ShellToolTest {
assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason) assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
} }
@Test
fun `allowlist rejects a disallowed command hidden after a shell operator`(): Unit = runBlocking {
// Bypass (#61): ["echo","&&","rm"] joins to `sh -c "echo && rm"`; an argv[0]-only allowlist
// check would wave it through with {echo} and then run rm. Every command position is checked.
val tool = ShellTool(allowedExecutables = setOf("echo"))
val result = tool.validateRequest(createRequest(listOf("echo", "hi", "&&", "rm", "-rf", "x")))
assertTrue(result is ValidationResult.Invalid)
assertEquals("Executable 'rm' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
}
@Test
fun `allowlist allows a chain where every command is allowed, builtins implicit`(): Unit = runBlocking {
// `cd dir && echo x` — cd is a builtin (implicitly allowed), echo is allowlisted, `dir`/`x`
// are arguments (not command positions), so the chain is valid.
val tool = ShellTool(allowedExecutables = setOf("echo"))
val result = tool.validateRequest(createRequest(listOf("cd", "dir", "&&", "echo", "x")))
assertEquals(ValidationResult.Valid, result)
}
@Test
fun `allowlist treats a redirect target as a filename, not a command`(): Unit = runBlocking {
// `echo x > rm` — the token after `>` is a redirect FILE named "rm", not a command to run.
val tool = ShellTool(allowedExecutables = setOf("echo"))
assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(listOf("echo", "x", ">", "rm"))))
}
@Test @Test
fun `validateRequest returns Invalid for empty argv`(): Unit = runBlocking { fun `validateRequest returns Invalid for empty argv`(): Unit = runBlocking {
val tool = ShellTool(allowedExecutables = setOf("echo")) val tool = ShellTool(allowedExecutables = setOf("echo"))
@@ -59,12 +59,6 @@ private const val DEFAULT_STAGE_TOKEN_BUDGET = 16384
// default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local // default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local
// model burns the whole 2048 on garbage (e.g. a `<|channel>thought` repetition loop) before it can // model burns the whole 2048 on garbage (e.g. a `<|channel>thought` repetition loop) before it can
// stop. Mirror the static TomlWorkflowLoader path: pin the completion cap to the stage token budget. // stop. Mirror the static TomlWorkflowLoader path: pin the completion cap to the stage token budget.
private val DEFAULT_STAGE_GENERATION = GenerationConfig(
temperature = 0.7,
topP = 1.0,
maxTokens = DEFAULT_STAGE_TOKEN_BUDGET,
)
class ExecutionPlanCompiler( class ExecutionPlanCompiler(
private val registry: ArtifactKindRegistry, private val registry: ArtifactKindRegistry,
// Names of every registered tool. A stage that references a tool the runtime can't resolve // Names of every registered tool. A stage that references a tool the runtime can't resolve
@@ -79,7 +73,11 @@ class ExecutionPlanCompiler(
// retries (Vikunja #41). Off by default so the compiler's own unit tests see only plan stages; // retries (Vikunja #41). Off by default so the compiler's own unit tests see only plan stages;
// the server's freestyle path (Main.kt) turns it on. // the server's freestyle path (Main.kt) turns it on.
private val injectRecovery: Boolean = false, private val injectRecovery: Boolean = false,
// Operator sampling defaults for freestyle-compiled stages; maxTokens pinned to the stage budget.
// Default reproduces the former hardcoded temperature=0.7/topP=1.0.
private val samplingDefaults: GenerationConfig = GenerationConfig(temperature = 0.7, topP = 1.0, maxTokens = 0),
) { ) {
private val defaultStageGeneration = samplingDefaults.copy(maxTokens = DEFAULT_STAGE_TOKEN_BUDGET)
private val mapper = JsonMapper.builder() private val mapper = JsonMapper.builder()
.addModule(kotlinModule()) .addModule(kotlinModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
@@ -150,7 +148,7 @@ class ExecutionPlanCompiler(
autoBuildGate = s.id == autoGateStageId, autoBuildGate = s.id == autoGateStageId,
semanticReview = s.semanticReview, semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET, tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = DEFAULT_STAGE_GENERATION, generationConfig = defaultStageGeneration,
metadata = mapOf("promptInline" to s.prompt), metadata = mapOf("promptInline" to s.prompt),
) )
} }
@@ -168,7 +166,7 @@ class ExecutionPlanCompiler(
StageId(RECOVERY_STAGE) to StageConfig( StageId(RECOVERY_STAGE) to StageConfig(
allowedTools = knownTools.ifEmpty { setOf("file_write", "file_edit", "shell") }, allowedTools = knownTools.ifEmpty { setOf("file_write", "file_edit", "shell") },
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET, tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = DEFAULT_STAGE_GENERATION, generationConfig = defaultStageGeneration,
metadata = mapOf("role" to "recovery", "promptInline" to RECOVERY_PROMPT), metadata = mapOf("role" to "recovery", "promptInline" to RECOVERY_PROMPT),
) )
} }
@@ -76,6 +76,9 @@ private val mapper = TomlMapper.builder()
class TomlWorkflowLoader( class TomlWorkflowLoader(
private val registry: ArtifactKindRegistry = DefaultArtifactKindRegistry(), private val registry: ArtifactKindRegistry = DefaultArtifactKindRegistry(),
// Operator sampling defaults applied to every stage's inference request (maxTokens is still pinned
// per-stage to the token budget). Defaults reproduce the former hardcoded temperature=0.7/topP=1.0.
private val samplingDefaults: GenerationConfig = GenerationConfig(temperature = 0.7, topP = 1.0, maxTokens = 0),
) : WorkflowLoader { ) : WorkflowLoader {
override fun load(path: Path): WorkflowGraph { override fun load(path: Path): WorkflowGraph {
val raw = path.readText() val raw = path.readText()
@@ -116,11 +119,7 @@ class TomlWorkflowLoader(
// Propagate the declared token budget to the inference completion cap. // Propagate the declared token budget to the inference completion cap.
// Without this the StageConfig default (maxTokens=2048) is used, truncating // Without this the StageConfig default (maxTokens=2048) is used, truncating
// larger artifacts (finishReason=length) → invalid JSON → validation failure. // larger artifacts (finishReason=length) → invalid JSON → validation failure.
generationConfig = GenerationConfig( generationConfig = samplingDefaults.copy(maxTokens = s.tokenBudget),
temperature = 0.7,
topP = 1.0,
maxTokens = s.tokenBudget,
),
maxRetries = s.maxRetries, maxRetries = s.maxRetries,
metadata = s.toMetadata(workflowDir), metadata = s.toMetadata(workflowDir),
) )
@@ -209,6 +209,71 @@ class FreestyleApprovalGateTest {
runJob.join() runJob.join()
} }
@Test
fun `a prior REJECTED decision does not satisfy the gate — it re-prompts, not runs unapproved`(): Unit =
runBlocking {
val sessionId = SessionId("freestyle-gate-reject")
// Pre-seed the log as if the architect gate had already been prompted and REJECTED (the
// retry/resume situation): a matching request + a REJECTED decision for it. The pre-fix
// predicate matched any decision for the request and skipped the gate, running unapproved.
val requestId = com.correx.core.events.types.ApprovalRequestId("seeded-req")
eventStore.append(
com.correx.testing.fixtures.EventFixtures.newEvent(
com.correx.core.events.types.EventId("seed-req"),
sessionId,
ApprovalRequestedEvent(
requestId = requestId,
tier = Tier.T2,
validationReportId = com.correx.core.events.types.ValidationReportId("vr"),
riskSummaryId = null,
sessionId = sessionId,
stageId = architectStage,
projectId = null,
toolName = null,
),
),
)
eventStore.append(
com.correx.testing.fixtures.EventFixtures.newEvent(
com.correx.core.events.types.EventId("seed-dec"),
sessionId,
com.correx.core.events.events.ApprovalDecisionResolvedEvent(
decisionId = com.correx.core.events.types.ApprovalDecisionId("seeded-dec"),
requestId = requestId,
outcome = ApprovalOutcome.REJECTED,
status = ApprovalStatus.COMPLETED,
tier = Tier.T2,
resolutionTimestamp = Clock.System.now(),
reason = "operator said no",
),
),
)
val orchestrator = buildOrchestrator()
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
val runJob = launch { orchestrator.run(sessionId, freestyleGraph(), config) }
// The gate must re-prompt: a SECOND architect approval request appears (the seeded one is
// the first). With the bug the seeded REJECTED decision satisfied the gate and no new
// request was emitted — the architect ran and the workflow completed unapproved.
withTimeout(5_000) {
while (
eventStore.read(sessionId)
.mapNotNull { it.payload as? ApprovalRequestedEvent }
.count { it.stageId == architectStage && it.toolName == null } < 2
) {
yield()
}
}
assertTrue(
eventStore.read(sessionId).none { it.payload is WorkflowCompletedEvent },
"Workflow must not complete off a REJECTED decision",
)
runJob.cancel()
runJob.join()
}
@Test @Test
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking { fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
val sessionId = SessionId("freestyle-gate-2") val sessionId = SessionId("freestyle-gate-2")