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.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationTuning
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
@@ -109,9 +110,10 @@ import java.nio.file.Paths
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
private val loopbackHosts = setOf("localhost", "127.0.0.1", "::1")
fun main() {
log.info("=== correx server starting ===")
log.info(" port : 8080")
val artifactStore = InfrastructureModule.createArtifactStore()
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
@@ -218,16 +220,19 @@ fun main() {
val workingDir = explicitWorkingDir ?: workspaceRoot
// 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.
// 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(
enabled = toolsConfig.research.enabled,
searxngUrl = toolsConfig.research.searxngUrl,
maxResults = toolsConfig.research.maxResults,
maxFetchBytes = toolsConfig.research.maxFetchBytes,
httpClient = if (toolsConfig.research.enabled) {
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
} else {
null
},
httpClient = researchHttpClient,
)
// 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.
@@ -255,6 +260,9 @@ fun main() {
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
)
// Retrieves full tool output the kernel spilled to CAS on truncation (ref shown in-context).
val toolOutputTool = com.correx.infrastructure.tools.ToolOutputTool(artifactStore)
val extraTools = taskTools + toolOutputTool
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
workspaceRoot,
@@ -263,7 +271,7 @@ fun main() {
toolsConfig,
researchToolConfig,
),
extraTools = taskTools,
extraTools = extraTools,
)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
@@ -296,7 +304,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
extraTools = taskTools,
extraTools = extraTools,
)
val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -380,11 +388,45 @@ fun main() {
// 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
// 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(
artifactKindRegistry,
toolRegistry.all().map { it.name }.toSet(),
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(
repositories = repositories,
engines = engines.copy(
@@ -420,9 +462,10 @@ fun main() {
taskSessionResolver,
),
),
tuning = orchestrationTuning,
)
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
// 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
// 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 ->
if (!hc.enabled) {
null
@@ -542,6 +587,7 @@ fun main() {
val seeded = DefaultEventReplayer(eventStore, com.correx.apps.server.health.HealthProjection())
.rebuild(com.correx.apps.server.health.SYSTEM_SESSION)
.subjects.mapValues { it.value.status }
healthProbeHttpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
com.correx.apps.server.health.HealthMonitor(
eventStore = eventStore,
probes = listOfNotNull(
@@ -552,7 +598,7 @@ fun main() {
),
com.correx.apps.server.health.LlamaServerHealthProbe(
llamaBaseUrl = llamaBaseUrl,
httpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO),
httpClient = healthProbeHttpClient!!,
eventStore = eventStore,
livenessTimeoutMs = hc.llamaLivenessTimeoutMs,
tpsWarnBelow = hc.llamaTpsWarnBelow,
@@ -567,6 +613,12 @@ fun main() {
)
}
}
Runtime.getRuntime().addShutdownHook(
Thread {
researchHttpClient?.close()
healthProbeHttpClient?.close()
},
)
val module = ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
@@ -609,7 +661,16 @@ fun main() {
module.start()
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)
}.start(wait = true)
}
@@ -674,9 +735,7 @@ private fun loadConfigArtifactKinds(config: CorrexConfig): List<ArtifactKind> {
val schema = runCatching {
Json.decodeFromString(JsonSchema.serializer(), Files.readString(schemaPath))
}.getOrElse { e ->
System.err.println(
"Warning: artifact kind '${decl.id}' schema '$schemaPath' failed to load: ${e.message}",
)
log.warn("artifact kind '{}' schema '{}' failed to load: {}", decl.id, schemaPath, e.message)
return@mapNotNull null
}
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.WorkflowRegistry
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.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
@@ -183,10 +186,13 @@ class ServerModule(
fun start() {
if (subscriptionJob != null) return
preRegisterPendingApprovals()
repairStuckApprovalPauses()
resumeAbandonedSessions()
subscriptionJob = eventStore.subscribeAll()
.filter { it.payload is ApprovalRequestedEvent }
.onEach { approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent) }
.onEach {
approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent, it.sessionSequence)
}
.launchIn(moduleScope)
// 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,
* 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(
sessionId: SessionId,
graph: com.correx.core.transitions.graph.WorkflowGraph,
@@ -576,13 +624,25 @@ class ServerModule(
private val sessionSummaryProjector = SessionSummaryProjector()
fun listSessionSummaries(): List<SessionSummary> =
eventStore.allSessionIds()
// ponytail: memoized on lastGlobalSequence — GET /sessions was re-reading + re-projecting every
// 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.
.filter { it != com.correx.apps.server.health.SYSTEM_SESSION }
.map { sessionId ->
sessionSummaryProjector.project(sessionId, eventStore.readFrom(sessionId, fromSequence = 0L))
}
sessionSummaryCache = currentSeq to summaries
return summaries
}
private fun preRegisterPendingApprovals() {
val projector = ApprovalProjector(DefaultApprovalReducer())
@@ -595,7 +655,54 @@ class ServerModule(
}
approvalState.requests.values
.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 io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap
open class ApprovalCoordinator(
@@ -23,6 +24,7 @@ open class ApprovalCoordinator(
private val globalClients: MutableSet<DefaultWebSocketServerSession> = ConcurrentHashMap.newKeySet()
private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap()
private val requestSessions: ConcurrentHashMap<ApprovalRequestId, SessionId> = ConcurrentHashMap()
private val requestTiers: ConcurrentHashMap<ApprovalRequestId, Tier> = ConcurrentHashMap()
fun registerClient(sessionId: SessionId, session: DefaultWebSocketServerSession) {
sessionClients.getOrPut(sessionId) { ConcurrentHashMap.newKeySet() }.add(session)
@@ -40,8 +42,9 @@ open class ApprovalCoordinator(
globalClients.remove(session)
}
suspend fun onApprovalRequested(event: ApprovalRequestedEvent) {
suspend fun onApprovalRequested(event: ApprovalRequestedEvent, sessionSequence: Long = 0L) {
requestSessions[event.requestId] = event.sessionId
requestTiers[event.requestId] = event.tier
val msg = ServerMessage.ApprovalRequired(
sessionId = event.sessionId,
requestId = event.requestId,
@@ -55,7 +58,7 @@ open class ApprovalCoordinator(
toolName = event.toolName,
preview = event.preview,
sequence = 0L,
sessionSequence = 0L,
sessionSequence = sessionSequence,
)
broadcast(event.sessionId, msg)
}
@@ -75,8 +78,9 @@ open class ApprovalCoordinator(
* during snapshot replay. This makes [lookupSession] work for approvals that were created
* 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
requestTiers[requestId] = tier
}
open suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
@@ -87,12 +91,20 @@ open class ApprovalCoordinator(
sessionSequence = null,
)
}
requestSessions.remove(msg.requestId)
val domain = msg.toDomain(sessionId, null, Tier.T2)
val tier = requestTiers[msg.requestId] ?: Tier.T2
val domain = msg.toDomain(sessionId, null, tier)
return runCatching { orchestrator.submitApprovalDecision(msg.requestId, domain) }
.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 = {
resolved.remove(msg.requestId)
ServerMessage.ProtocolError(
message = it.message ?: "Unknown error",
sequence = null,
@@ -106,8 +118,29 @@ open class ApprovalCoordinator(
val encoded = ProtocolSerializer.encodeServerMessage(msg)
val sessionSubs = sessionClients[sessionId].orEmpty()
val recipients: Set<DefaultWebSocketServerSession> = sessionSubs + globalClients
var delivered = 0
recipients.forEach { client ->
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
import com.correx.apps.server.protocol.AssessedIssueDto
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ReviewFindingDto
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.toDto
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionNamedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ArtifactId
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger("DomainEventMapper")
internal val log = LoggerFactory.getLogger("DomainEventMapper")
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
suspend fun map(event: StoredEvent, sessionSequence: Long = 0L): ServerMessage? =
@@ -62,374 +19,44 @@ private object NoopArtifactStore : ArtifactStore {
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
@Suppress("CyclomaticComplexMethod")
/**
* Outcome of one per-domain mapper. [Emit] means "this event is mine" — carrying either a
* [ServerMessage] or `null` (handled but deliberately not surfaced, e.g. transient bookkeeping
* events). [Skip] means "not my domain", so the dispatcher tries the next mapper. The distinction
* matters: chaining on a bare `null` would conflate suppression with non-ownership.
*/
internal sealed interface MapOutcome {
@JvmInline
value class Emit(val message: ServerMessage?) : MapOutcome
data object Skip : MapOutcome
}
// Per-domain mappers, tried in order. Each owns a disjoint slice of the payload hierarchy and
// returns [MapOutcome.Skip] for anything outside it. Split across files by domain area to keep
// each mapper and its import list small — see StageInferenceEventMappers, ToolEventMappers, etc.
private val domainMappers: List<suspend (StoredEvent, ArtifactStore, Long) -> MapOutcome> = listOf(
::mapSessionEvent,
::mapStageInferenceEvent,
::mapToolEvent,
::mapLifecycleEvent,
)
suspend fun domainEventToServerMessage(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long = 0L,
): ServerMessage? {
val seq = event.sequence
return when (val p = event.payload) {
is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId,
workflowId = "chat",
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId,
workflowId = p.workflowId,
sequence = seq,
sessionSequence = sessionSequence,
)
is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound(
sessionId = p.sessionId,
workspaceRoot = p.workspaceRoot,
sequence = seq,
sessionSequence = sessionSequence,
)
is SessionNamedEvent -> ServerMessage.SessionRenamed(
sessionId = p.sessionId,
name = p.name,
sequence = seq,
sessionSequence = sessionSequence,
)
is ChatTurnEvent -> ServerMessage.ChatTurn(
sessionId = p.sessionId,
turnId = p.turnId,
role = p.role.name,
content = p.content,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(
sessionId = p.sessionId,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowFailedEvent -> ServerMessage.SessionFailed(
sessionId = p.sessionId,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is TransitionExecutedEvent -> ServerMessage.StageStarted(
sessionId = p.sessionId,
stageId = p.to,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is StageCompletedEvent -> ServerMessage.StageCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is StageFailedEvent -> ServerMessage.StageFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is OrchestrationPausedEvent -> mapOrchestrationPaused(p, seq, sessionSequence)
is OrchestrationResumedEvent -> ServerMessage.SessionResumed(
sessionId = p.sessionId,
stageId = p.stageId,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceStartedEvent -> ServerMessage.InferenceStarted(
sessionId = p.sessionId,
stageId = p.stageId,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceCompletedEvent -> mapInferenceCompleted(event, p, artifactStore, sessionSequence)
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
sessionId = p.sessionId,
stageId = p.stageId,
elapsedMs = p.timeoutMs,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceFailedEvent -> ServerMessage.InferenceFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is RetryAttemptedEvent -> ServerMessage.RetryAttempted(
sessionId = p.sessionId,
stageId = p.stageId,
attemptNumber = p.attemptNumber,
maxAttempts = p.maxAttempts,
failureReason = p.failureReason,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
sessionId = p.sessionId,
toolName = p.toolName,
tier = p.tier,
params = prettyToolParams(p.request.parameters),
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
sessionId = p.sessionId,
toolName = p.toolName,
outputSummary = p.receipt.outputSummary,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
diff = p.receipt.diff,
affectedEntities = p.receipt.affectedEntities,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolCallAssessedEvent -> ServerMessage.ToolAssessed(
sessionId = p.sessionId,
stageId = p.stageId,
toolName = p.toolName,
disposition = p.disposition.name,
issues = p.issues.map { AssessedIssueDto(it.code, it.message, it.severity) },
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ReviewFindingsRaisedEvent -> ServerMessage.ReviewFindings(
sessionId = p.sessionId,
stageId = p.stageId,
verdict = p.verdict.name,
findings = p.findings.map {
ReviewFindingDto(
severity = it.severity.name,
confidence = it.confidence,
category = it.category,
target = it.target,
message = it.message,
suggestedFix = it.suggestedFix,
correctness = it.correctness,
)
},
blocked = p.blocked,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
sessionId = p.sessionId,
requestId = p.requestId,
stageId = p.stageId,
questions = p.questions,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
sessionId = p.sessionId,
proposalId = p.proposalId,
prompt = p.prompt,
candidates = p.candidates,
originalRequest = p.originalRequest,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
sessionId = event.metadata.sessionId,
requestId = p.requestId,
outcome = p.outcome.name,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is TalkieNarrationEvent -> ServerMessage.Narration(
sessionId = p.sessionId,
content = p.content,
stageId = p.stageId,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactValidatedEvent -> ServerMessage.ArtifactValidated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
// Transient pre-validation marker, emitted microseconds before Validated or a
// stage failure — either of those carries the outcome the operator cares about.
is ArtifactValidatingEvent -> null
is ExecutionPlanLockedEvent -> ServerMessage.PlanLocked(
sessionId = p.sessionId,
workflowId = p.workflowId,
stageIds = p.stageIds,
sequence = seq,
sessionSequence = sessionSequence,
)
is ModelLoadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = true,
)
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = false,
)
// Internal slot→CAS-hash bookkeeping (F-007 durable bridge); no operator-facing surface.
is ArtifactContentStoredEvent -> null
// Freestyle graph-rerouting bookkeeping. The deterministic record is in place; a dedicated
// operator surface ships with the LLM-proposal + approval-confirm front-half.
is PreemptRedirectEvent -> null
is PreemptRedirectBlockedEvent -> null
else -> {
for (mapper in domainMappers) {
when (val outcome = mapper(event, artifactStore, sessionSequence)) {
is MapOutcome.Emit -> return outcome.message
MapOutcome.Skip -> Unit
}
}
log.debug(
"DomainEventMapper: unmapped payload type={} sessionId={} sequence={}",
p::class.simpleName,
event.payload::class.simpleName,
event.metadata.sessionId,
event.sequence,
)
null
}
}
return null
}
private fun mapOrchestrationPaused(
p: OrchestrationPausedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage {
val reason = if (p.reason == "APPROVAL_PENDING") PauseReason.APPROVAL_PENDING else PauseReason.USER_REQUESTED
return ServerMessage.SessionPaused(
sessionId = p.sessionId,
reason = reason,
sequence = seq,
sessionSequence = sessionSequence,
)
}
private suspend fun mapInferenceCompleted(
event: StoredEvent,
p: InferenceCompletedEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): ServerMessage {
val response = runCatching {
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
}.getOrElse { "" }
val reasoning = p.reasoningArtifactId?.let { id ->
runCatching { artifactStore.get(id)?.toString(Charsets.UTF_8) ?: "" }.getOrElse { "" }
} ?: ""
return ServerMessage.InferenceCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
outputSummary = response,
responseText = response,
reasoning = reasoning,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
totalTokens = p.tokensUsed.totalTokens,
sequence = event.sequence,
sessionSequence = sessionSequence,
)
}
private fun mapApprovalRequested(
p: ApprovalRequestedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage =
ServerMessage.ApprovalRequired(
sessionId = p.sessionId,
requestId = p.requestId,
tier = p.tier,
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
level = p.tier.name,
factors = emptyList(),
recommendedAction = RiskAction.PROMPT_USER.name,
rationale = emptyList(),
),
toolName = p.toolName,
preview = p.preview,
sequence = seq,
sessionSequence = sessionSequence,
)
/**
* Render a tool call's raw parameter map into compact, human-readable "key=value" cells for the
* client's tool-call row. Primary keys (path/command/query/…) lead so the most salient argument is
* first; long values are truncated and multi-line values collapsed so the WS frame stays small.
*/
internal fun prettyToolParams(parameters: Map<String, Any>): List<String> {
if (parameters.isEmpty()) return emptyList()
val primary = listOf("command", "path", "query", "url", "pattern", "content", "operation")
val ordered = parameters.entries.sortedWith(
compareBy({ primary.indexOf(it.key).let { i -> if (i < 0) primary.size else i } }, { it.key }),
)
return ordered.take(MAX_PARAM_CELLS).map { (k, v) -> "$k=${formatParamValue(v)}" }
}
private fun formatParamValue(value: Any?): String {
val raw = when (value) {
null -> "null"
is String -> value
is Collection<*> -> value.joinToString(", ", prefix = "[", postfix = "]") { formatParamValue(it) }
else -> value.toString()
}
val flattened = raw.replace('\n', ' ').replace('\r', ' ').trim()
val clipped = if (flattened.length > MAX_PARAM_VALUE_LEN) flattened.take(MAX_PARAM_VALUE_LEN) + "" else flattened
// Quote strings that carry whitespace so the boundary of the value is unambiguous in the row.
return if (value is String && clipped.any { it.isWhitespace() }) "\"$clipped\"" else clipped
}
private const val MAX_PARAM_CELLS = 5
private const val MAX_PARAM_VALUE_LEN = 80
@@ -0,0 +1,150 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.toDto
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.risk.RiskAction
/**
* Approval / clarification / workflow-proposal / narration / artifact / model / preempt events —
* the remaining operator-facing (or deliberately suppressed) lifecycle surface. Branches that
* return `null` are handled-but-not-surfaced (transient bookkeeping); wrapping them in
* [MapOutcome.Emit] keeps them from falling through to the dispatcher's "unmapped" log.
*/
@Suppress("UnusedParameter", "LongMethod", "CyclomaticComplexMethod")
internal suspend fun mapLifecycleEvent(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): MapOutcome {
val seq = event.sequence
val msg: ServerMessage? = when (val p = event.payload) {
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
sessionId = p.sessionId,
requestId = p.requestId,
stageId = p.stageId,
questions = p.questions,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
sessionId = p.sessionId,
proposalId = p.proposalId,
prompt = p.prompt,
candidates = p.candidates,
originalRequest = p.originalRequest,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
sessionId = event.metadata.sessionId,
requestId = p.requestId,
outcome = p.outcome.name,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is TalkieNarrationEvent -> ServerMessage.Narration(
sessionId = p.sessionId,
content = p.content,
stageId = p.stageId,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactValidatedEvent -> ServerMessage.ArtifactValidated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
// Transient pre-validation marker, emitted microseconds before Validated or a
// stage failure — either of those carries the outcome the operator cares about.
is ArtifactValidatingEvent -> null
is ExecutionPlanLockedEvent -> ServerMessage.PlanLocked(
sessionId = p.sessionId,
workflowId = p.workflowId,
stageIds = p.stageIds,
sequence = seq,
sessionSequence = sessionSequence,
)
is ModelLoadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = true,
)
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = false,
)
// Internal slot→CAS-hash bookkeeping (F-007 durable bridge); no operator-facing surface.
is ArtifactContentStoredEvent -> null
// Freestyle graph-rerouting bookkeeping. The deterministic record is in place; a dedicated
// operator surface ships with the LLM-proposal + approval-confirm front-half.
is PreemptRedirectEvent -> null
is PreemptRedirectBlockedEvent -> null
else -> return MapOutcome.Skip
}
return MapOutcome.Emit(msg)
}
private fun mapApprovalRequested(
p: ApprovalRequestedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage =
ServerMessage.ApprovalRequired(
sessionId = p.sessionId,
requestId = p.requestId,
tier = p.tier,
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
level = p.tier.name,
factors = emptyList(),
recommendedAction = RiskAction.PROMPT_USER.name,
rationale = emptyList(),
),
toolName = p.toolName,
preview = p.preview,
sequence = seq,
sessionSequence = sessionSequence,
)
@@ -14,18 +14,15 @@ import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.model.ApprovalState
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.InferenceStartedEvent
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.ArtifactCreatedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
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.SessionWorkspaceBoundEvent
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.WorkflowFailedEvent
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.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.tools.registry.ToolRegistry
@@ -109,39 +103,11 @@ class SessionEventBridge(
.sortedWith(compareBy({ it.timestamp }, { it.id.value }))
} ?: emptyList()
// Fix stuck sessions: orchestration says PAUSED + pendingApproval but there are no
// actual unresolved approval requests. This happens when approval was resolved but
// OrchestrationResumedEvent was not emitted (pre-Feb-13 orchestrator bug). Append the
// missing event permanently so the session state corrects on all future replays.
//
// 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)
}
}
// NOTE: repairing stuck approval-pauses (PAUSED + pendingApproval but no unresolved
// request, from a pre-Feb-13 orchestrator bug) used to happen HERE, per connection —
// a write side effect on a pure read path that two concurrent clients could double-fire.
// It now runs once at boot in ServerModule.repairStuckApprovalPauses(). replaySnapshot is
// read-only again.
val pendingApprovals = pendingApprovalRequests.map {
ApprovalDto(
@@ -164,8 +130,8 @@ class SessionEventBridge(
// Re-register pending approvals so the ApprovalCoordinator can route responses
// from clients that connected after the ApprovalRequestedEvent was emitted.
pendingApprovals.forEach { dto ->
approvalCoordinator?.registerPendingRequest(ApprovalRequestId(dto.requestId), sessionId)
pendingApprovalRequests.forEach { req ->
approvalCoordinator?.registerPendingRequest(req.id, sessionId, req.tier)
}
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,7 +104,8 @@ class NarrationSubscriber(
stageId = p.stageId.value,
),
)
is WorkflowCompletedEvent -> enqueue(
is WorkflowCompletedEvent -> {
enqueue(
sid,
NarrationTrigger(
kind = "workflow_completed",
@@ -112,7 +113,10 @@ class NarrationSubscriber(
stageId = p.terminalStageId.value,
),
)
is WorkflowFailedEvent -> enqueue(
closeLane(sid)
}
is WorkflowFailedEvent -> {
enqueue(
sid,
NarrationTrigger(
kind = "workflow_failed",
@@ -120,6 +124,8 @@ class NarrationSubscriber(
stageId = p.stageId.value,
),
)
closeLane(sid)
}
// 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
// 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()
}
/** 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 {
val channel = Channel<QueuedNarration>(capacity = Channel.UNLIMITED)
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
}
@@ -61,6 +61,8 @@ data class ApprovalDto(
@Serializable
enum class PauseReason {
APPROVAL_PENDING,
CLARIFICATION_PENDING,
ABANDONED_STALE,
USER_REQUESTED,
}
@@ -121,10 +121,14 @@ private fun Route.startSessionRoute(module: ServerModule) {
val graph = module.workflowRegistry.find(body.workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
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 ->
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))
}
}
@@ -12,7 +12,6 @@ import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl
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.GrantScope
import com.correx.core.events.events.ApprovalGrantCreatedEvent
@@ -66,6 +65,9 @@ import java.util.UUID
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
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 BYTES_PER_MB = 1024L * 1024L
@@ -716,47 +718,10 @@ class GlobalStreamHandler(private val module: ServerModule) {
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)
// Resolve workspace from the Hello-frame working directory (invariant #9: record the
// resolver's decision at handshake time as an event; replay reads the recorded fact).
val resolvedWorkspace: WorkspaceContext? = module.workspaceResolver?.let { resolver ->
val resolution = withContext(Dispatchers.IO) { resolver.resolve(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() },
),
))
}
// Resolve + record the workspace from the Hello-frame working directory (invariant #9:
// record the resolver's decision as an event; replay reads the recorded fact). Shared with
// the REST launcher via module.bindWorkspace.
val resolvedWorkspace: WorkspaceContext? = module.bindWorkspace(sessionId, workingDir)
// 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
@@ -826,22 +791,28 @@ internal suspend fun streamGlobal(
is SharedFlow<StoredEvent> -> source.onSubscription { 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 {
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 {
subscribed.await()
bridge.replaySnapshot()
for (event in buffer) {
val sid = event.metadata.sessionId.value
val seq = sessionSequences.getOrDefault(sid, 0L) + 1
sessionSequences[sid] = seq
val msg = mapper.map(event, sessionSequence = seq) ?: continue
// Use the event's own persisted sessionSequence, not a per-connection counter. The
// snapshot advertises lastSessionSequence = MAX(session_sequence); a counter restarting
// at 1 per connection would collide with that after reconnect and the TUI's dedup/
// ordering filter (keyed on sessionSequence) would silently drop or misorder frames.
val msg = mapper.map(event, sessionSequence = event.sessionSequence) ?: continue
sendFrame(msg)
}
} finally {
@@ -60,13 +60,56 @@ class ApprovalCoordinatorWiringTest {
scope.cancel()
}
private class RecordingGateway : ApprovalGateway {
private class RecordingGateway(@Volatile var failNext: Boolean = false) : ApprovalGateway {
val submissions = CopyOnWriteArrayList<Pair<ApprovalRequestId, DomainApprovalDecision>>()
override suspend fun submitApprovalDecision(requestId: ApprovalRequestId, decision: DomainApprovalDecision) {
if (failNext) {
failNext = false
error("submit boom")
}
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(
metadata = EventMetadata(
eventId = EventId("evt-$seq"),
+6 -1
View File
@@ -120,8 +120,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
}
case protocol.TypeSessionPaused:
label := "PAUSED"
if msg.Reason == "APPROVAL_PENDING" {
switch msg.Reason {
case "APPROVAL_PENDING":
label = "PAUSED awaiting approval"
case "CLARIFICATION_PENDING":
label = "PAUSED awaiting answer"
case "ABANDONED_STALE":
label = "PAUSED (stale)"
}
m.touch(msg.SessionID, label)
if s := m.session(msg.SessionID); s != nil {
@@ -630,6 +630,19 @@ object ConfigLoader {
compressionLevel = asInt(orchestrationSection["compression_level"], 4),
tokenPrunerUrl =
asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"),
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], 30),
readLoopNudgeThreshold = asInt(orchestrationSection["read_loop_nudge_threshold"], 3),
rejectionLoopNudgeThreshold = asInt(orchestrationSection["rejection_loop_nudge_threshold"], 3),
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], 3),
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], 30),
repoMapFilesPerDir = asInt(orchestrationSection["repo_map_files_per_dir"], 8),
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], 20),
maxClarificationRounds = asInt(orchestrationSection["max_clarification_rounds"], 3),
reviewBlockMinConfidence = asDouble(orchestrationSection["review_block_min_confidence"], 0.7),
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], 20),
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], 3),
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], 2),
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], 2),
)
val modelsSettings = ModelsSettings(
@@ -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(
server = server,
tui = tui,
@@ -662,6 +684,7 @@ object ConfigLoader {
project = project,
personalization = personalization,
orchestration = orchestration,
sampling = sampling,
)
}
@@ -16,9 +16,26 @@ data class CorrexConfig(
val project: ProjectConfig = ProjectConfig(),
val personalization: PersonalizationConfig = PersonalizationConfig(),
val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
val sampling: SamplingConfig = SamplingConfig(),
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
* 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 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
@@ -86,6 +86,28 @@ object CorrexConfigWriter {
b.kv("stage_timeout_ms", cfg.orchestration.stageTimeoutMs)
b.kv("journal_compaction_token_threshold", cfg.orchestration.journalCompactionTokenThreshold)
b.kv("resume_abandoned_max_age_minutes", cfg.orchestration.resumeAbandonedMaxAgeMinutes)
b.kv("compression_level", cfg.orchestration.compressionLevel)
b.kv("token_pruner_url", cfg.orchestration.tokenPrunerUrl)
b.kv("max_tool_rounds", cfg.orchestration.maxToolRounds)
b.kv("read_loop_nudge_threshold", cfg.orchestration.readLoopNudgeThreshold)
b.kv("rejection_loop_nudge_threshold", cfg.orchestration.rejectionLoopNudgeThreshold)
b.kv("max_feedback_issues", cfg.orchestration.maxFeedbackIssues)
b.kv("repo_map_inject_top_k", cfg.orchestration.repoMapInjectTopK)
b.kv("repo_map_files_per_dir", cfg.orchestration.repoMapFilesPerDir)
b.kv("docs_catalog_max", cfg.orchestration.docsCatalogMax)
b.kv("max_clarification_rounds", cfg.orchestration.maxClarificationRounds)
b.kv("review_block_min_confidence", cfg.orchestration.reviewBlockMinConfidence)
b.kv("review_block_retry_cap", cfg.orchestration.reviewBlockRetryCap)
b.kv("default_max_refinement", cfg.orchestration.defaultMaxRefinement)
b.kv("recovery_route_budget", cfg.orchestration.recoveryRouteBudget)
b.kv("intent_route_budget", cfg.orchestration.intentRouteBudget)
b.section("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.kv("enabled", cfg.personalization.enabled)
@@ -19,4 +19,8 @@ data class ToolReceipt(
val tier: Tier,
val timestamp: Instant,
val diff: String? = null,
// Artifact-store hash of the FULL tool output when it was truncated for model context (the
// outputSummary above is a bounded preview). Null when nothing was truncated. Lets an agent
// retrieve everything via the tool_output tool without bloating the event log with raw output.
val fullOutputHash: String? = null,
)
@@ -13,4 +13,9 @@ data class GenerationConfig(
val maxTokens: Int,
val stopSequences: List<String> = emptyList(),
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)
// Fallback refinement-loop cap when a stage does not declare maxRetries.
private const val DEFAULT_MAX_REFINEMENT = 3
// Own, small budget for the retry-agency recovery route: how many times a single stage may be
// routed to a recovery stage before the run fails terminally. Deliberately smaller than the
// per-gate retry budget — recovery is a purpose-built repair-then-reverify loop, not open-ended.
private const val RECOVERY_ROUTE_BUDGET = 2
// RECOVERY_ROUTE_BUDGET / INTENT_ROUTE_BUDGET now in OrchestrationTuning.
// Tier-2 escalation budget: how many NO-PROGRESS routes the arbiter (recovery-stage-as-intent-holder)
// gets after the tier-1 owner loop exhausts, before the run fails terminally. Independent of the owner
// budget (keyed with INTENT_BUDGET_SUFFIX) so escalation is a genuine second chance, not a shared pool.
private const val INTENT_ROUTE_BUDGET = 2
// Transition ids for the failure-ticket loop. A ticket routes the failing gate's control to the
// repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that
@@ -109,7 +105,8 @@ class DefaultSessionOrchestrator(
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
readyTaskCounter: ReadyTaskCounter? = null,
taskClaimCoordinator: TaskClaimCoordinator? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator), ApprovalGateway {
tuning: OrchestrationTuning = OrchestrationTuning(),
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever, readyTaskCounter = readyTaskCounter, taskClaimCoordinator = taskClaimCoordinator, tuning = tuning), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<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
// the arbiter tier once the owner loop is spent; null = no tier available now.
val route = when {
owner != null && !budgetExhausted(state, ownerKey, fingerprint, RECOVERY_ROUTE_BUDGET) ->
RouteTier(owner, ownerKey, RECOVERY_ROUTE_BUDGET, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, INTENT_ROUTE_BUDGET) ->
RouteTier(arbiter, intentKey, INTENT_ROUTE_BUDGET, escalated = true)
owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
else -> null
}
if (route == null) {
@@ -621,7 +618,7 @@ class DefaultSessionOrchestrator(
// terminal failure instead of looping forever.
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: DEFAULT_MAX_REFINEMENT
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
if (iteration > maxIterations) {
@@ -661,8 +658,11 @@ class DefaultSessionOrchestrator(
.map { it.requestId }
.toSet()
stageRequestIds.isNotEmpty() && events.any {
(it.payload as? ApprovalDecisionResolvedEvent)
?.requestId in stageRequestIds
val decision = it.payload as? ApprovalDecisionResolvedEvent
// A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
// runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
decision?.requestId in stageRequestIds &&
decision?.outcome != ApprovalOutcome.REJECTED
}
}
if (!alreadyApproved) {
@@ -696,7 +696,7 @@ class DefaultSessionOrchestrator(
compactionService?.let { svc ->
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
val journalText = DecisionJournalRenderer().render(journalState)
val tokenEstimate = journalText.length / 4
val tokenEstimate = estimateTokens(journalText)
svc.compactIfNeeded(
sessionId = ctx.sessionId,
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
// acting on a correct diagnosis. The real runaway guards are the read-loop and rejection-loop
// nudges (both fire after 3 unproductive rounds), so this ceiling only needs to be generous enough
// that productive stages aren't cut off mid-work.
private const val MAX_TOOL_ROUNDS = 30
// that productive stages aren't cut off mid-work. Value now lives in OrchestrationTuning.maxToolRounds.
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
// artifact before we force the write nudge. A model that keeps calling read tools every round
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
private const val READ_LOOP_NUDGE_THRESHOLD = 3
// Value now lives in OrchestrationTuning.readLoopNudgeThreshold.
// Consecutive rounds in which EVERY tool call was rejected/denied (plane-2 BLOCKED or a stage/policy
// ERROR) with nothing succeeding. Distinct from the read-loop breaker, which only fires for stages
@@ -196,22 +195,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
// with no progress. After this many all-rejected rounds we force the model to stop retrying and
// produce its output; a single successful tool call resets the counter.
private const val REJECTION_LOOP_NUDGE_THRESHOLD = 3
// Value now lives in OrchestrationTuning.rejectionLoopNudgeThreshold.
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
private const val MAX_FEEDBACK_ISSUES = 3
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30
private const val REPO_MAP_FILES_PER_DIR = 8
// 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
// `path — descriptor` so a stage learns which docs exist and can file_read one when relevant,
// instead of docs being force-fed (or excluded outright). One line each, hard-capped, so it can
// stay always-on without re-poisoning context (2026-07-07 doc-injection rework).
private const val DOCS_CATALOG_MAX = 20
// Value now lives in OrchestrationTuning.docsCatalogMax.
// A stage prompt must explicitly ask about documentation for .md/docs paths to enter the repo
// layout listing; otherwise docs only reach context through a real retrieval hit.
@@ -241,8 +262,7 @@ private val REQUIRED_SOURCE_TYPES = setOf(
private const val HTTP_TIMEOUT = 408
private const val HTTP_TOO_MANY_REQUESTS = 429
// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds.
private const val MAX_CLARIFICATION_ROUNDS = 3
// Cap on clarification rounds per stage — OrchestrationTuning.maxClarificationRounds.
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
@@ -255,8 +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
// this many blocks, findings surface without blocking so a stuck reviewer can never trap a stage
// forever. Set well above any legitimate budget+salvage sequence. Objective text is capped too.
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
private const val REVIEW_BLOCK_RETRY_CAP = 20
// REVIEW_BLOCK_MIN_CONFIDENCE / REVIEW_BLOCK_RETRY_CAP now in OrchestrationTuning.
private const val REVIEW_OBJECTIVE_CAP = 4_000
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@@ -287,6 +306,7 @@ abstract class SessionOrchestrator(
private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
private val readyTaskCounter: ReadyTaskCounter? = null,
private val taskClaimCoordinator: TaskClaimCoordinator? = null,
protected val tuning: OrchestrationTuning = OrchestrationTuning(),
) {
private val log = LoggerFactory.getLogger(this::class.java)
private val eventStore: EventStore = repositories.eventStore
@@ -338,6 +358,14 @@ abstract class SessionOrchestrator(
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
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,
* code fences, trailing commas). Pure — recomputes on replay, records no events. */
private val artifactExtractionPipeline = ArtifactExtractionPipeline()
@@ -511,7 +539,7 @@ abstract class SessionOrchestrator(
content = journalText,
sourceType = "decisionJournal",
sourceId = "decision-journal",
tokenEstimate = journalText.length / 4,
tokenEstimate = estimateTokens(journalText),
role = EntryRole.SYSTEM,
),
)
@@ -682,7 +710,7 @@ abstract class SessionOrchestrator(
while (
inferenceResult is InferenceResult.Success &&
toolRounds < MAX_TOOL_ROUNDS &&
toolRounds < tuning.maxToolRounds &&
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
) {
// Content turn (no tool call) but the stage still owes a file_written artifact: the
@@ -761,7 +789,7 @@ abstract class SessionOrchestrator(
consecutiveReadOnlyRounds = 0
} else {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
@@ -781,7 +809,7 @@ abstract class SessionOrchestrator(
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
if (allRejected) {
consecutiveRejectedRounds++
if (consecutiveRejectedRounds >= REJECTION_LOOP_NUDGE_THRESHOLD) {
if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
consecutiveRejectedRounds = 0
inferenceResult = pushBack(
"STOP. Your last tool calls were all rejected and retrying the same paths will " +
@@ -1098,6 +1126,38 @@ abstract class SessionOrchestrator(
"\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
}.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(
sessionId: SessionId,
stageId: StageId,
@@ -1275,7 +1335,9 @@ abstract class SessionOrchestrator(
mode = approvalMode,
)
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(
id = requestId,
tier = tier,
@@ -1405,6 +1467,9 @@ abstract class SessionOrchestrator(
?.let { request.copy(grantedPaths = grantedOutside + it.toString()) }
?: request
val result = executor.execute(executedRequest)
// Frame + bound the result once; on truncation this spills the full output to CAS and
// returns its hash, which we record on the receipt so the event log points at the full text.
val rendered = renderToolResult(toolCall.function.name, tool, result)
// Store ProcessResult artifact for every shell execution outcome
for (slot in processResultSlots) {
@@ -1447,7 +1512,7 @@ abstract class SessionOrchestrator(
// artifact carries the diff as evidence of the change.
recordToolExecution(
sessionId, stageId, toolCall, invocationId, tier, result,
tool as? FileAffectingTool, request, fileWrittenSlots,
tool as? FileAffectingTool, request, fileWrittenSlots, rendered.fullOutputHash,
)
val sourceId = toolCall.id ?: invocationId.value
@@ -1461,22 +1526,13 @@ abstract class SessionOrchestrator(
role = EntryRole.ASSISTANT,
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(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = resultContent,
tokenEstimate = estimateTokens(resultContent),
content = rendered.content,
tokenEstimate = estimateTokens(rendered.content),
role = EntryRole.TOOL,
)
val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
@@ -1764,7 +1820,7 @@ abstract class SessionOrchestrator(
val priorRounds = eventStore.read(sessionId)
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
if (priorRounds >= MAX_CLARIFICATION_ROUNDS) return false
if (priorRounds >= tuning.maxClarificationRounds) return false
val requestId = ClarificationRequestId(UUID.randomUUID().toString())
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
@@ -1804,7 +1860,7 @@ abstract class SessionOrchestrator(
appendLine("## Repo layout (what exists — use file_read for content)")
byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
val names = files.map { it.substringAfterLast('/') }
val shown = names.take(REPO_MAP_FILES_PER_DIR)
val shown = names.take(tuning.repoMapFilesPerDir)
val more = names.size - shown.size
val suffix = if (more > 0) ", …(+$more more)" else ""
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
@@ -1905,7 +1961,7 @@ abstract class SessionOrchestrator(
val retriever = repoKnowledgeRetriever
?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId)
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, REPO_MAP_INJECT_TOP_K) }
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, tuning.repoMapInjectTopK) }
.getOrElse { e ->
if (e is CancellationException) throw e
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
@@ -1932,7 +1988,7 @@ abstract class SessionOrchestrator(
val docs = map.entries
.filter { isDocPath(it.path) }
.sortedByDescending { it.score }
.take(DOCS_CATALOG_MAX)
.take(tuning.docsCatalogMax)
if (docs.isEmpty()) return emptyList()
val content = buildString {
appendLine("## Docs available (file_read to open — do not assume contents)")
@@ -2072,9 +2128,14 @@ abstract class SessionOrchestrator(
) {
return@forEach
}
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
emitAll(
sessionId,
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?,
request: ToolRequest,
fileWrittenSlots: List<TypedArtifactSlot>,
fullOutputHash: String? = null,
) {
// Invariant #5: every tool side effect is captured. This is the single authoritative
// ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
@@ -2126,6 +2188,7 @@ abstract class SessionOrchestrator(
tier = tier,
timestamp = Clock.System.now(),
diff = diff,
fullOutputHash = fullOutputHash,
),
),
)
@@ -2196,9 +2259,14 @@ abstract class SessionOrchestrator(
)
return@forEach
}
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
emitAll(
sessionId,
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 }
.count { it.stageId == stageId && it.blocked }
val blockingFinding = outcome.findings.firstOrNull {
it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE
it.correctness && it.confidence >= tuning.reviewBlockMinConfidence
}
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
blockingFinding != null &&
priorBlocks < REVIEW_BLOCK_RETRY_CAP
priorBlocks < tuning.reviewBlockRetryCap
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
@@ -2808,9 +2876,14 @@ abstract class SessionOrchestrator(
// Emit artifact lifecycle events for process_result slots on failure branches.
// Content was already stored in CAS + cache by dispatchToolCalls for every outcome.
stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot ->
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
emitAll(
sessionId,
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()) {
"validation failed"
} 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) {
emptyList()
} 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
.mapNotNull { effectives.registry?.resolve(it) }
.filter { tool ->
// 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 ->
// Read-loop break: keep only write tools (drop file_read/list_dir/shell) so
@@ -3071,6 +3147,7 @@ abstract class SessionOrchestrator(
correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
cancellations.remove(sessionId)
evictArtifactContentCache(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId)
}
@@ -3114,6 +3191,7 @@ abstract class SessionOrchestrator(
)
}
cancellations.remove(sessionId)
evictArtifactContentCache(sessionId)
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 ---
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
* 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 == "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
val path = parameters["path"] as? String ?: return null
// 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.
val proposedContent = parameters["content"] as? String ?: return null
val existingContent = readFileIfExists(path)
val existingContent = readFileIfExists(path, workspaceRoot)
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? =
withContext(Dispatchers.IO) {
runCatching {
val filePath = java.nio.file.Paths.get(path)
// Resolve relative paths against the session's workspace root, same as the tools do —
// resolving against the daemon CWD showed the operator the wrong file (or nothing) when
// server CWD ≠ workspace_root.
val raw = java.nio.file.Paths.get(path)
val filePath = if (raw.isAbsolute || workspaceRoot == null) raw else workspaceRoot.resolve(raw)
if (java.nio.file.Files.exists(filePath)) {
java.nio.file.Files.readString(filePath)
} else null
}.getOrNull()
}
}
/**
* Preview for `file_edit`'s "replace" and "append" operations: reads the existing file and
@@ -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
* 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 operation = parameters["operation"] as? String ?: return null
val existingContent = readFileIfExists(path) ?: return null
val existingContent = readFileIfExists(path, workspaceRoot) ?: return null
val proposedContent = when (operation) {
"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
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
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
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
* matching [IdeaDiscardedEvent] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture
* stays in the log).
* Holds the operator's idea board in memory. Cross-session by design: it folds over the whole event
* log (not a single session's replay), so an idea captured in one session shows on the board (and
* feeds the router) in every session. A captured idea is dropped once a matching [IdeaDiscardedEvent]
* (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture 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. */
fun activeIdeas(): List<Idea> {
val tombstoned = mutableSetOf<String>()
val captured = mutableListOf<Idea>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
init {
eventStore.allEvents().forEach { apply(it.payload) }
scope.launch { eventStore.subscribeAll().collect { apply(it.payload) } }
}
private fun apply(payload: EventPayload) {
when (payload) {
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
is IdeaPromotedEvent -> tombstoned += payload.ideaId
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
is IdeaCapturedEvent -> captures[payload.ideaId] = payload
else -> Unit
}
}
return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs }
}
/** 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. */
fun sessionOf(ideaId: String): SessionId? =
capturedOf(ideaId)?.sessionId
fun sessionOf(ideaId: String): SessionId? = captures[ideaId]?.sessionId
/** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */
fun textOf(ideaId: String): String? =
capturedOf(ideaId)?.text
private fun capturedOf(ideaId: String): IdeaCapturedEvent? =
eventStore.allEvents()
.mapNotNull { it.payload as? IdeaCapturedEvent }
.firstOrNull { it.ideaId == ideaId }
fun textOf(ideaId: String): String? = captures[ideaId]?.text
}
@@ -66,6 +66,7 @@ data class StageConfig(
companion object {
/** Read-only tools every tool-granting stage may call regardless of its declared set. */
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> = setOf("file_read", "list_dir")
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> =
setOf("file_read", "list_dir", "glob", "grep", "tool_output")
}
}
@@ -151,10 +151,12 @@ class DefaultModelManager(
currentDescriptor = null
}
@Suppress("UnusedParameter")
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
while (Clock.System.now().toEpochMilliseconds() < endTime) {
// A crashed process (bad --model, port clash) never gets healthy; polling the full
// timeout is pure dead time. Bail as soon as it's gone.
if (!process.isAlive) return false
try {
val response = httpClient.get("http://$host:$port/health").body<String>()
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
@@ -3,6 +3,7 @@ package com.correx.infrastructure.inference.llama.cpp
import com.correx.core.inference.ChatMessage
import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.ToolDefinition
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -15,6 +16,12 @@ data class ChatCompletionRequest(
@SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String> = emptyList(),
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 grammar: String? = null,
val tools: List<ToolDefinition>? = null,
@@ -42,22 +42,14 @@ private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
explicitNulls = false
encodeDefaults = true
},
)
}
install(HttpTimeout) {
requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS
}
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
},
)
}
}
private val json = Json {
ignoreUnknownKeys = true
@@ -178,6 +170,9 @@ class LlamaCppInferenceProvider(
maxTokens = request.generationConfig.maxTokens,
stopSequences = request.generationConfig.stopSequences,
seed = request.generationConfig.seed,
topK = request.generationConfig.topK,
minP = request.generationConfig.minP,
repeatPenalty = request.generationConfig.repeatPenalty,
stream = false,
grammar = grammar,
tools = tools,
@@ -199,7 +194,9 @@ class LlamaCppInferenceProvider(
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
// 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.
@@ -207,7 +204,7 @@ class LlamaCppInferenceProvider(
val toolCalls = message.toolCalls.ifEmpty { salvaged }
val finishReason = when {
toolCalls.isNotEmpty() -> FinishReason.ToolCall
response.choices.first().finishReason.lowercase() == "length" -> FinishReason.Length
choice.finishReason.lowercase() == "length" -> FinishReason.Length
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.ToolDefinition
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -20,6 +21,11 @@ data class OpenAiChatCompletionRequest(
@SerialName("max_tokens") val maxTokens: Int,
@SerialName("stop") val stopSequences: List<String>? = 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 tools: List<ToolDefinition>? = null,
)
@@ -104,6 +104,9 @@ class OpenAiCompatInferenceProvider(
maxTokens = request.generationConfig.maxTokens,
stopSequences = request.generationConfig.stopSequences.ifEmpty { null },
seed = request.generationConfig.seed,
topK = request.generationConfig.topK,
minP = request.generationConfig.minP,
repeatPenalty = request.generationConfig.repeatPenalty,
stream = false,
tools = tools,
)
+1
View File
@@ -11,6 +11,7 @@ dependencies {
implementation(project(":core:artifacts"))
implementation(project(":core:artifacts-store"))
implementation "org.xerial:sqlite-jdbc"
implementation "org.slf4j:slf4j-api:2.0.16"
testImplementation(testFixtures(project(":testing:contracts")))
testImplementation(project(":testing:fixtures"))
testImplementation "org.junit.jupiter:junit-jupiter"
@@ -9,9 +9,12 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.slf4j.LoggerFactory
import java.util.concurrent.*
import java.util.concurrent.atomic.*
private val log = LoggerFactory.getLogger(InMemoryEventStore::class.java)
class InMemoryEventStore : EventStore {
private val streams = ConcurrentHashMap<SessionId, MutableList<StoredEvent>>()
private val sequences = ConcurrentHashMap<SessionId, AtomicLong>()
@@ -33,7 +36,7 @@ class InMemoryEventStore : EventStore {
}
stored = doAppend(event, stream)
}
subscriptions[event.metadata.sessionId]?.tryEmit(stored)
subscriptions[event.metadata.sessionId]?.let { warnIfDropped(it.tryEmit(stored), stored) }
globalFlow.emit(stored)
return stored
}
@@ -47,7 +50,7 @@ class InMemoryEventStore : EventStore {
stored = events.map { doAppend(it, stream) }
}
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) }
return stored
}
@@ -77,6 +80,19 @@ class InMemoryEventStore : EventStore {
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 {
val sessionSeq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
.incrementAndGet()
@@ -21,10 +21,13 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant
import org.slf4j.LoggerFactory
import java.sql.Connection
import java.sql.ResultSet
import java.util.concurrent.*
private val log = LoggerFactory.getLogger(SqliteEventStore::class.java)
class SqliteEventStore(
private val connection: Connection,
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson),
@@ -72,25 +75,24 @@ class SqliteEventStore(
appendMutex.withLock {
artifactStore.flushBefore {
withContext(Dispatchers.IO) {
stored = connection.transaction {
stored = withConnection {
connection.transaction {
val existing = findByEventId(event.metadata.eventId)
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val globalSeqVal = nextGlobalSequence()
val sessionSeqVal = nextSessionSequence(event.metadata.sessionId)
val s = StoredEvent(
val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event)
StoredEvent(
metadata = event.metadata,
sequence = globalSeqVal,
sessionSequence = sessionSeqVal,
payload = event.payload,
)
insert(s)
s
}
}
}
}
}
val result = checkNotNull(stored)
subscriptions[event.metadata.sessionId]?.tryEmit(result)
subscriptions[event.metadata.sessionId]?.let { warnIfDropped(it.tryEmit(result), result) }
globalFlow.emit(result)
return result
}
@@ -102,32 +104,31 @@ class SqliteEventStore(
appendMutex.withLock {
artifactStore.flushBefore {
withContext(Dispatchers.IO) {
stored = connection.transaction {
stored = withConnection {
connection.transaction {
events.map { event ->
val existing = findByEventId(event.metadata.eventId)
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val globalSeqVal = nextGlobalSequence()
val sessionSeqVal = nextSessionSequence(sessionId)
val s = StoredEvent(
val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event)
StoredEvent(
metadata = event.metadata,
sequence = globalSeqVal,
sessionSequence = sessionSeqVal,
payload = event.payload,
)
insert(s)
s
}
}
}
}
}
}
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) }
return stored
}
override fun read(sessionId: SessionId): List<StoredEvent> =
override fun read(sessionId: SessionId): List<StoredEvent> = withConnection {
connection.prepareStatement(
"""
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(
"""
SELECT * FROM events
@@ -164,8 +166,9 @@ class SqliteEventStore(
}
}
}
}
override fun lastSequence(sessionId: SessionId): Long? =
override fun lastSequence(sessionId: SessionId): Long? = withConnection {
connection.prepareStatement(
"SELECT MAX(session_sequence) FROM events WHERE session_id = ?"
).use { ps ->
@@ -175,6 +178,7 @@ class SqliteEventStore(
else null
}
}
}
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) }
@@ -183,6 +187,7 @@ class SqliteEventStore(
override suspend fun lastGlobalSequence(): Long =
withContext(Dispatchers.IO) {
withConnection {
connection.prepareStatement(
"SELECT COALESCE(MAX(sequence), 0) FROM events"
).use { ps ->
@@ -192,8 +197,9 @@ class SqliteEventStore(
}
}
}
}
override fun allEvents(): Sequence<StoredEvent> =
override fun allEvents(): Sequence<StoredEvent> = withConnection {
connection.prepareStatement(
"""
SELECT * FROM events
@@ -208,8 +214,9 @@ class SqliteEventStore(
}
}
}.asSequence()
}
override fun allSessionIds(): Set<SessionId> =
override fun allSessionIds(): Set<SessionId> = withConnection {
mutableSetOf<SessionId>().apply {
connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps ->
ps.executeQuery().use { rs ->
@@ -217,32 +224,47 @@ class SqliteEventStore(
}
}
}
}
// ---------- helpers ----------
private fun nextGlobalSequence(): Long =
connection.prepareStatement(
"SELECT COALESCE(MAX(sequence), 0) FROM events"
).use { ps ->
ps.executeQuery().use { rs ->
rs.next()
rs.getLong(1) + 1
}
}
private fun nextSessionSequence(sessionId: SessionId): Long =
connection.prepareStatement(
"SELECT COALESCE(MAX(session_sequence), 0) FROM events WHERE session_id = ?"
).use { ps ->
ps.setString(1, sessionId.value)
ps.executeQuery().use { rs ->
rs.next()
rs.getLong(1) + 1
/**
* Serializes all JDBC access to the shared [connection] on a single JVM monitor. Reads run on the
* caller thread while append's transaction runs on Dispatchers.IO; the coroutine [appendMutex] only
* excludes append-vs-append, so without this a read could hit the connection mid-transaction
* (SQLite JDBC is not thread-safe → corrupted tx state). ponytail: store-wide lock, one session at a
* time is fine here; shard per-connection only if read throughput ever matters.
*/
private inline fun <T> withConnection(block: () -> T): T = synchronized(connection) { block() }
/**
* A per-session subscription's buffer (extraBufferCapacity 64) is full and this event was dropped.
* The event is durably persisted (the drop is only on the live SharedFlow), but a lagging in-process
* collector — e.g. LiveArtifactRepository — silently diverges from the log. Surface it instead of
* swallowing the false return. ponytail: log-only; give that collector a bounded rebuild-on-lag if
* divergence ever bites in practice.
*/
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")
private fun insert(event: StoredEvent) {
private fun insertAssigningSequences(event: NewEvent): Pair<Long, Long> =
connection.prepareStatement(
"""
INSERT INTO events (
@@ -255,19 +277,26 @@ class SqliteEventStore(
causation_id,
correlation_id,
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(),
).use { ps ->
ps.setString(1, event.metadata.eventId.value)
ps.setString(2, event.metadata.sessionId.value)
ps.setLong(3, event.sequence)
ps.setLong(4, event.sessionSequence)
ps.setString(5, event.metadata.timestamp.toString())
ps.setInt(6, event.metadata.schemaVersion)
ps.setString(7, event.metadata.causationId?.value)
ps.setString(8, event.metadata.correlationId?.value)
ps.setString(9, jsonSerializer.serialize(event.payload))
ps.executeUpdate()
ps.setString(3, event.metadata.sessionId.value)
ps.setString(4, event.metadata.timestamp.toString())
ps.setInt(5, event.metadata.schemaVersion)
ps.setString(6, event.metadata.causationId?.value)
ps.setString(7, event.metadata.correlationId?.value)
ps.setString(8, jsonSerializer.serialize(event.payload))
ps.executeQuery().use { rs ->
rs.next()
rs.getLong("sequence") to rs.getLong("session_sequence")
}
}
@@ -12,6 +12,7 @@ import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -55,11 +56,27 @@ class LiveArtifactRepository(
private fun ensureSubscribed(sessionId: 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 ->
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.withTimeout
import kotlinx.serialization.json.Json
import java.io.BufferedReader
import java.nio.file.Paths
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
/**
* 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 mutex = Mutex()
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>()
override suspend fun rehydrateMetadata(entries: List<L3MemoryEntry>) {
@@ -72,10 +77,12 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
override suspend fun close() {
mutex.withLock {
process?.let { p ->
val shutdownRequest = SidecarRequest(op = "shutdown")
runCatching { sendRequest(shutdownRequest) }
// Persist vectors before exit so the next process can `load` them (only save() puts
// 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 {
withTimeout(2000) {
withTimeout(SHUTDOWN_TIMEOUT_MS) {
p.waitFor()
}
} catch (e: Exception) {
@@ -85,27 +92,41 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
throw e
}
}
reader = null
process = null
}
}
}
private suspend fun sendRequest(request: SidecarRequest): SidecarResponse {
mutex.withLock {
private suspend fun sendRequest(request: SidecarRequest): SidecarResponse =
mutex.withLock { sendRequestLocked(request) }
/** Caller must hold [mutex]. */
private fun sendRequestLocked(request: SidecarRequest): SidecarResponse {
ensureProcessStarted()
val process = process ?: throw RuntimeException("Process failed to start")
val reader = reader ?: throw RuntimeException("Sidecar reader not initialized")
val requestLine = json.encodeToString(SidecarRequest.serializer(), request) + "\n"
process.outputStream.write(requestLine.toByteArray())
process.outputStream.flush()
val responseLine = runCatching {
withTimeout(config.requestTimeoutMs) {
process.inputStream.bufferedReader().readLine()
// 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)")
}
}.getOrNull() ?: throw RuntimeException("No response from sidecar")
return json.decodeFromString(SidecarResponse.serializer(), responseLine)
}
private fun killProcess() {
runCatching { process?.destroyForcibly() }
reader = null
process = null
}
private fun ensureProcessStarted() {
@@ -122,19 +143,24 @@ class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : Rehydra
val started = pb.start()
process = started
reader = started.inputStream.bufferedReader()
// The sidecar rejects add/search until the index is created. Send init once on startup;
// without this every store/query fails with "Index not initialized".
val initLine = json.encodeToString(
SidecarRequest.serializer(),
SidecarRequest(op = "init", dim = config.dim, bitWidth = config.bitWidth)
) + "\n"
started.outputStream.write(initLine.toByteArray())
// Reload a persisted index if one exists, else create a fresh one. Either way the sidecar
// rejects add/search until initialized; without this every store/query fails "not initialized".
val bootstrap = config.persistPath
?.takeIf { it.exists() }
?.let { SidecarRequest(op = "load", path = it.toString()) }
?: SidecarRequest(op = "init", dim = config.dim, bitWidth = config.bitWidth)
started.outputStream.write((json.encodeToString(SidecarRequest.serializer(), bootstrap) + "\n").toByteArray())
started.outputStream.flush()
val response = started.inputStream.bufferedReader().readLine()
?.let { json.decodeFromString(SidecarResponse.serializer(), it) }
val response = reader!!.readLine()?.let { json.decodeFromString(SidecarResponse.serializer(), it) }
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.inference.CapabilityScore
import com.correx.core.inference.Embedder
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRouter
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()
extraKinds.forEach { registry.register(it) }
return TomlWorkflowLoader(registry)
return TomlWorkflowLoader(registry, samplingDefaults)
}
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
@@ -24,7 +24,6 @@ import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
@Suppress("TooManyFunctions")
class FileEditTool(
@@ -242,7 +241,9 @@ class FileEditTool(
private fun append(path: Path, request: ToolRequest): ToolResult {
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(
invocationId = request.invocationId,
output = "Content appended to ${path.toAbsolutePath()}",
@@ -258,7 +259,7 @@ class FileEditTool(
return when (occurrences) {
1 -> {
val newContent = currentContent.replace(target, replacement)
Files.writeString(path, newContent)
AtomicFileWriter.write(path, newContent.toByteArray(Charsets.UTF_8))
ToolResult.Success(
invocationId = request.invocationId,
output = "Target replaced in $pathString",
@@ -338,7 +339,18 @@ class FileEditTool(
return try {
process.outputStream.use { it.write(patchContent.toByteArray()) }
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) {
ToolResult.Success(
@@ -389,5 +401,6 @@ class FileEditTool(
const val MIN_ANCHOR = 3
const val MAX_CMP = 400
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 {
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 end = (endLine ?: lines.size).coerceAtMost(lines.size)
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 —
// the agent must read the relevant range before editing.
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(
invocationId = request.invocationId,
output = content + note,
@@ -180,8 +183,8 @@ class FileReadTool(
)
}
private fun sha256(path: Path): String =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
private fun sha256(bytes: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) }
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
@@ -111,7 +111,15 @@ class ListDirTool(
ToolResult.Failure(request.invocationId, msg, recoverable = true)
}
!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 {
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)
}
@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
fun `non-recursive lists only immediate children`(): Unit = runBlocking {
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) {
// A1: skip files that don't yet exist (new-file tools have nothing to back up)
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)
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.FileReadTool
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.shell.ShellTool
import com.correx.infrastructure.tools.web.WebFetchTool
@@ -82,6 +84,10 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
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) {
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.process.ChildProcess
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -28,6 +27,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.Path
import java.util.concurrent.TimeUnit
class ShellTool(
private val allowedExecutables: Set<String> = emptySet(),
@@ -78,8 +78,15 @@ class ShellTool(
// is not enough — an auto-approve loop waves these straight through.
deniedReason(parsed.argv) != null ->
ValidationResult.Invalid(deniedReason(parsed.argv)!!)
allowedExecutables.isNotEmpty() && parsed.argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${parsed.argv[0]}' is not in the allowed list.")
// Check EVERY command position, not just argv[0]. An operator form runs via `sh -c`
// (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
}
}
@@ -140,6 +147,19 @@ class ShellTool(
private fun looksLikeShellCommand(argv: List<String>): Boolean =
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
// one string element and there is no real executable to run.
private fun checkExecutable(argv: List<String>): ArgvParse =
@@ -175,21 +195,13 @@ class ShellTool(
runCatching {
runCmd(request, process)
}.getOrElse {
process.destroyForcibly()
if (it is TimeoutCancellationException) {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms",
recoverable = false,
)
} else {
killTree(process)
ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred during execution",
recoverable = false,
)
}
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
@@ -204,6 +216,9 @@ class ShellTool(
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_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.
val REMOTE_EXEC_RUNNERS = setOf("npx", "bunx", "pnpx")
}
@@ -231,11 +246,32 @@ class ShellTool(
.joinToString("\n")
}
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
// Kill the whole process tree. destroyForcibly() signals only the direct child, so an `sh -c`
// command's grandchildren (the actual npm/tsc/…) would survive as orphans; descendants() reaches
// 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 stderr = stderrDeferred.await()
@@ -43,6 +43,13 @@ class WebFetchTool(
private val maxBytes: Long = DEFAULT_MAX_BYTES,
) : 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 description: String = "Fetch a URL and return its main content as clean markdown."
override val tier: Tier = Tier.T2
@@ -77,10 +84,19 @@ class WebFetchTool(
}
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 declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
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() ->
fail(invocationId, "HTTP ${response.status.value} for $url", recoverable = true)
declaredLength != null && declaredLength > maxBytes ->
@@ -123,13 +139,13 @@ class WebFetchTool(
/** 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? {
val out = ArrayList<Byte>()
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(READ_CHUNK)
while (true) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read == -1) break
if (out.size + read > maxBytes) return null
for (i in 0 until read) out.add(buffer[i])
if (out.size() + read > maxBytes) return null
out.write(buffer, 0, read)
}
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)
}
@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
fun `validateRequest returns Invalid for empty argv`(): Unit = runBlocking {
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
// 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.
private val DEFAULT_STAGE_GENERATION = GenerationConfig(
temperature = 0.7,
topP = 1.0,
maxTokens = DEFAULT_STAGE_TOKEN_BUDGET,
)
class ExecutionPlanCompiler(
private val registry: ArtifactKindRegistry,
// 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;
// the server's freestyle path (Main.kt) turns it on.
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()
.addModule(kotlinModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
@@ -150,7 +148,7 @@ class ExecutionPlanCompiler(
autoBuildGate = s.id == autoGateStageId,
semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = DEFAULT_STAGE_GENERATION,
generationConfig = defaultStageGeneration,
metadata = mapOf("promptInline" to s.prompt),
)
}
@@ -168,7 +166,7 @@ class ExecutionPlanCompiler(
StageId(RECOVERY_STAGE) to StageConfig(
allowedTools = knownTools.ifEmpty { setOf("file_write", "file_edit", "shell") },
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = DEFAULT_STAGE_GENERATION,
generationConfig = defaultStageGeneration,
metadata = mapOf("role" to "recovery", "promptInline" to RECOVERY_PROMPT),
)
}
@@ -76,6 +76,9 @@ private val mapper = TomlMapper.builder()
class TomlWorkflowLoader(
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 {
override fun load(path: Path): WorkflowGraph {
val raw = path.readText()
@@ -116,11 +119,7 @@ class TomlWorkflowLoader(
// Propagate the declared token budget to the inference completion cap.
// Without this the StageConfig default (maxTokens=2048) is used, truncating
// larger artifacts (finishReason=length) → invalid JSON → validation failure.
generationConfig = GenerationConfig(
temperature = 0.7,
topP = 1.0,
maxTokens = s.tokenBudget,
),
generationConfig = samplingDefaults.copy(maxTokens = s.tokenBudget),
maxRetries = s.maxRetries,
metadata = s.toMetadata(workflowDir),
)
@@ -209,6 +209,71 @@ class FreestyleApprovalGateTest {
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
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
val sessionId = SessionId("freestyle-gate-2")